Compare commits

..

6 Commits

Author SHA1 Message Date
CI Bot 1db2ee0808 fix: preserve insertion order in asset_id dedup to fix test ordering
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 38s
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m37s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m38s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 1m47s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 2m25s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m41s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 3m28s
AI Code Review / AI Code Review (pull_request) Failing after 4m43s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 4m2s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 4m29s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 5m44s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Successful in 2m33s
CI/CD Pipeline / CI Gate (pull_request) Successful in 9s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 27s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 46s
Changed set-based dedup to order-preserving dedup in _build_asset_url_map.
Set dedup caused non-deterministic iteration order which broke
side_effect-based mock assertions in tests.
2026-08-17 18:08:26 +08:00
CI Bot ac416493e0 fix: update test mocks for batch query + add asset_id dedup
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 35s
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 1m50s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m51s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m53s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m7s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 2m59s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 3m7s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m48s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 3m29s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 6m0s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
AI Code Review / AI Code Review (pull_request) Successful in 7m1s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 2m34s
CI/CD Pipeline / CI Gate (pull_request) Failing after 8s
- Replace find_by_id mocks with find_by_ids (returns list) in all 28 tests
- Add .id attributes to mock assets for asset_map key matching
- Dedup asset_ids in _build_asset_url_map to avoid redundant signing
- Fixes AI Code Review blocking issues #1 and #3
2026-08-17 16:37:39 +08:00
CI Bot 9e97473eec fix: batch query assets to resolve N+1 in _build_asset_url_map
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 58s
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Failing after 0s
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Failing after 0s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 1m46s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 2m18s
AI Code Review / AI Code Review (pull_request) Failing after 2m19s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 2m26s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m52s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m45s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 3m50s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 6m39s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
- Add find_by_ids() to SQLAlchemyAssetRepository (single SQL IN query)
- Replace per-id find_by_id loop with single batch call
- Fixes AI Code Review blocking performance issue in PR #1404
2026-08-17 16:28:49 +08:00
CI Bot 34bd9372ce style: auto-format with black + isort + prettier [skip ci-format-check]
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 37s
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m49s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 1m50s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m0s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 2m5s
AI Code Review / AI Code Review (pull_request) Failing after 2m36s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m26s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 3m24s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 3m59s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 4m18s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 7m34s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Successful in 5m23s
CI/CD Pipeline / CI Gate (pull_request) Successful in 7s
2026-08-17 07:40:34 +00:00
xiaoxia 2e53c59cd7 fix: remove unused typing.Any import + add clips API response structure tests
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 47s
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m35s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 1m45s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m49s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m58s
AI Code Review / AI Code Review (pull_request) Successful in 3m36s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m37s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 3m7s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 3m15s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 3m41s
CI/CD Pipeline / Validate - Code Quality (pull_request) Has been cancelled
CI/CD Pipeline / Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
- Remove unused 'from typing import Any' (Ruff F401)
- Add 28 unit/integration tests covering:
  - _clip_to_response: enum conversion, datetime formatting, asset_url param
  - _build_asset_url_map: empty list, storage failure, asset not found,
    no storage_key, success path, exception handling, mixed scenarios
  - Route integration: list/detail/split/merge with asset URL resolution
- Incremental coverage: 100% (95/95 added lines covered)
2026-08-17 15:35:46 +08:00
CI Bot 9aa0c69b26 fix(clips): 全面修复 Clips API 响应结构
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 49s
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Failing after 0s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 1m51s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 2m20s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 2m22s
AI Code Review / AI Code Review (pull_request) Failing after 2m26s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m56s
CI/CD Pipeline / Validate - Code Quality (pull_request) Failing after 3m36s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 2m51s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m53s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 3m33s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Successful in 2m35s
CI/CD Pipeline / CI Gate (pull_request) Failing after 30s
P0: EditorClipResponse 补全所有数据库字段
- 新增 start_time, transition_duration, asset_id, status,
  template_clip_config_id, created_at, updated_at
- 新增 asset_url (OSS签名URL, 1小时有效)

P1: split/merge 端点统一使用 EditorClipResponse
- split 返回 {left_clip, right_clip} 均为 EditorClipResponse
- merge 返回 {merged_clip, deleted_clip_ids}
- 移除 dict[str, Any] 响应类型

P1: asset_url 签名URL解析
- 批量查询 Asset 表获取 storage_key
- 使用 StorageService.get_download_url() 签名
- 列表/详情/split/merge 端点均支持

P2: ClipsFromAssetsResponse 补上 plan_id 字段
2026-08-17 14:42:58 +08:00
338 changed files with 12521 additions and 45773 deletions
-84
View File
@@ -1,84 +0,0 @@
name: API Base Image Build
on:
push:
branches:
- develop
- main
paths:
- 'requirements-base.txt'
- 'requirements.txt'
- 'infra/docker/api-base.Dockerfile'
workflow_dispatch:
jobs:
build-api-base:
name: Build API Base Image
runs-on: runtime-builder
timeout-minutes: 45
steps:
- name: Checkout code
shell: sh
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
curl -sH "Authorization: token $GITHUB_TOKEN" \
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" \
| bash
- name: Docker login to Registry
shell: sh
env:
ACR_USERNAME: ${{ secrets.ACR_USERNAME }}
ACR_PASSWORD: ${{ secrets.ACR_PASSWORD }}
GITEA_REGISTRY_USER: xiaoxia
GITEA_REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
set -eu
for i in 1 2 3; do
echo "=== Docker login 尝试 $i/3 ==="
if printf '%s' "${ACR_PASSWORD}" | docker login xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com -u "${ACR_USERNAME}" --password-stdin \
&& docker login git.xiaoxiajianji.com -u "${GITEA_REGISTRY_USER}" -p "${GITEA_REGISTRY_TOKEN}"; then
echo "✅ Docker login successful"
break
fi
echo "❌ Docker login 失败(尝试 $i/3),5s 后重试..."
sleep 5
done
- name: Build and push API base image
shell: sh
run: |
set -eu
ACR_IMAGE="xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com/xiaoxiakeji/saas-api-base:latest"
GITEA_IMAGE="git.xiaoxiajianji.com/xiaoxia-saas/saas-api-base:latest"
echo "=== Building API base image ==="
# 使用普通 docker build(单平台不需要 buildx
docker build \
-f infra/docker/api-base.Dockerfile \
-t "${ACR_IMAGE}" \
.
echo ""
echo "✅ Image built successfully"
# 推送到 ACR
echo "=== Pushing to ACR ==="
docker push "${ACR_IMAGE}"
echo "✅ Pushed to ACR"
# 打标签并推送到 Gitea Packages 作为备份
echo "=== Pushing to Gitea Packages ==="
docker tag "${ACR_IMAGE}" "${GITEA_IMAGE}"
docker push "${GITEA_IMAGE}" || echo "⚠️ Gitea Packages push failed (non-fatal)"
echo "✅ Gitea backup push completed"
- name: Cleanup
if: always()
shell: sh
run: |
ACR_IMAGE="xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com/xiaoxiakeji/saas-api-base:latest"
docker rmi "${ACR_IMAGE}" 2>/dev/null || true
echo "Cleanup done"
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -2,13 +2,13 @@ name: CI Trigger Monitor
on: on:
schedule: schedule:
- cron: '*/10 * * * *' # 每10分钟检查一次(与pr-auto-scan同步降频) - cron: '*/5 * * * *' # 每5分钟检查一次
workflow_dispatch: workflow_dispatch:
inputs: inputs:
stale_threshold: stale_threshold:
description: 'CI未触发告警阈值(分钟)' description: 'CI未触发告警阈值(分钟)'
required: false required: false
default: '10' default: '5'
permissions: permissions:
contents: read contents: read
+1 -1
View File
@@ -3,7 +3,7 @@ name: PR Auto Scan
# 作为短作业模式的兜底,防止事件驱动遗漏 # 作为短作业模式的兜底,防止事件驱动遗漏
on: on:
schedule: schedule:
- cron: "*/10 * * * *" # 每10分钟扫描一次(脚本自带240s墙钟上限,降频减负) - cron: "*/5 * * * *" # 每5分钟扫描一次
workflow_dispatch: workflow_dispatch:
permissions: permissions:
+55 -38
View File
@@ -7,25 +7,35 @@ on:
- main - main
paths: paths:
- 'requirements-base.txt' - 'requirements-base.txt'
- 'requirements.txt'
- 'requirements-worker.txt' - 'requirements-worker.txt'
- 'infra/docker/worker-base.Dockerfile' - 'infra/docker/worker-base-builder.Dockerfile'
workflow_dispatch: - 'infra/docker/worker-base-runtime.Dockerfile'
workflow_dispatch: # 支持手动触发
jobs: jobs:
build-worker-base: build-worker-base:
name: Build Worker Base Image name: Build Worker Base Images
runs-on: runtime-builder runs-on: runtime-builder
timeout-minutes: 45 timeout-minutes: 30
strategy:
fail-fast: false
matrix:
include:
- name: builder
dockerfile: infra/docker/worker-base-builder.Dockerfile
image_name: worker-base-builder
cache_name: worker-base-builder-cache
- name: runtime
dockerfile: infra/docker/worker-base-runtime.Dockerfile
image_name: worker-base-runtime
cache_name: worker-base-runtime-cache
steps: steps:
- name: Checkout code - name: Checkout code
shell: sh shell: sh
env: env:
GITHUB_TOKEN: ${{ github.token }} GITHUB_TOKEN: ${{ github.token }}
run: | run: |
curl -sH "Authorization: token $GITHUB_TOKEN" \ curl -sH "Authorization: token $GITHUB_TOKEN" "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" | bash
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" \
| bash
- name: Docker login to Registry - name: Docker login to Registry
shell: sh shell: sh
@@ -38,8 +48,7 @@ jobs:
set -eu set -eu
for i in 1 2 3; do for i in 1 2 3; do
echo "=== Docker login 尝试 $i/3 ===" echo "=== Docker login 尝试 $i/3 ==="
if printf '%s' "${ACR_PASSWORD}" | docker login xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com -u "${ACR_USERNAME}" --password-stdin \ if printf '%s' "${ACR_PASSWORD}" | docker login xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com -u "${ACR_USERNAME}" --password-stdin && docker login git.xiaoxiajianji.com -u "${GITEA_REGISTRY_USER}" -p "${GITEA_REGISTRY_TOKEN}"; then
&& docker login git.xiaoxiajianji.com -u "${GITEA_REGISTRY_USER}" -p "${GITEA_REGISTRY_TOKEN}"; then
echo "✅ Docker login successful" echo "✅ Docker login successful"
break break
fi fi
@@ -47,40 +56,48 @@ jobs:
sleep 5 sleep 5
done done
- name: Build and push Worker base image - name: Setup buildx builder
shell: sh shell: sh
run: | run: |
set -eu set -eu
ACR_IMAGE="xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com/xiaoxiakeji/saas-worker-base:latest" BUILDER_NAME="ci-builder-${GITHUB_RUN_ID}-${{ matrix.name }}"
GITEA_IMAGE="git.xiaoxiajianji.com/xiaoxia-saas/saas-worker-base:latest" if ! docker buildx inspect "$BUILDER_NAME" > /dev/null 2>&1; then
docker buildx create --use --name "$BUILDER_NAME" --driver docker-container
echo "=== Building Worker base image ===" echo "Created $BUILDER_NAME"
else
# 使用普通 docker build(单平台不需要 buildx docker buildx use "$BUILDER_NAME"
docker build \ echo "Using existing $BUILDER_NAME"
-f infra/docker/worker-base.Dockerfile \ fi
-t "${ACR_IMAGE}" \ docker buildx inspect --bootstrap
.
- name: Build and push base image
shell: sh
run: |
set -eu
REGISTRY="xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com/xiaoxiakeji"
IMAGE_TAG="${REGISTRY}/${{ matrix.image_name }}:latest"
SAFE_REF_NAME=$(echo "${GITHUB_REF_NAME}" | tr '/' '-')
CACHE_REF="${REGISTRY}/${{ matrix.cache_name }}:${SAFE_REF_NAME}"
echo "=== Building ${{ matrix.name }} base image ==="
echo "Image: ${IMAGE_TAG}"
echo "Cache: ${CACHE_REF}"
# 用通用构建脚本
bash scripts/ci/docker_build_push.sh ${{ matrix.dockerfile }} "${IMAGE_TAG}" "${CACHE_REF}"
# 同时推送到 Gitea Packages 作为备份(可选)
GITEA_IMAGE="git.xiaoxiajianji.com/xiaoxia-saas/${{ matrix.image_name }}:latest"
docker tag "${IMAGE_TAG}" "${GITEA_IMAGE}"
docker push "${GITEA_IMAGE}" || echo "Gitea Packages push failed (non-fatal)"
echo "" echo ""
echo "✅ Image built successfully" echo "✅ ${{ matrix.name }} base image built and pushed"
# 推送到 ACR - name: Cleanup buildx builder
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() if: always()
shell: sh shell: sh
run: | run: |
ACR_IMAGE="xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com/xiaoxiakeji/saas-worker-base:latest" docker buildx rm "ci-builder-${GITHUB_RUN_ID}-${{ matrix.name }}" 2>/dev/null || true
docker rmi "${ACR_IMAGE}" 2>/dev/null || true docker buildx prune -f 2>/dev/null || true
docker image prune -f 2>/dev/null || true echo "Builder cleanup done"
echo "Cleanup done"
-1
View File
@@ -263,4 +263,3 @@ pytest --cov=packages --cov-report=html
--- ---
**License**: MIT **License**: MIT
<!-- CI trigger: 1788229339 -->
@@ -1,60 +0,0 @@
"""Add confirm generation fields
Revision ID: 034
Revises: 033
Create Date: 2026-07-08
确认生成 API 改造:为 generation_tasks 表添加 is_preview、source_task_id、
output_width、output_height、cover_url、custom_title 字段。
"""
import sqlalchemy as sa
from alembic import op
revision = "034"
down_revision = "033"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"generation_tasks",
sa.Column("is_preview", sa.Boolean(), nullable=False, server_default=sa.true()),
)
op.add_column(
"generation_tasks",
sa.Column("source_task_id", sa.String(32), nullable=False, server_default=""),
)
op.add_column(
"generation_tasks",
sa.Column("output_width", sa.Integer(), nullable=False, server_default=sa.text("1280")),
)
op.add_column(
"generation_tasks",
sa.Column("output_height", sa.Integer(), nullable=False, server_default=sa.text("720")),
)
op.add_column(
"generation_tasks",
sa.Column("cover_url", sa.String(1000), nullable=False, server_default=""),
)
op.add_column(
"generation_tasks",
sa.Column("custom_title", sa.String(500), nullable=False, server_default=""),
)
op.create_index(
op.f("ix_generation_tasks_source_task_id"),
"generation_tasks",
["source_task_id"],
)
def downgrade() -> None:
op.drop_index(op.f("ix_generation_tasks_source_task_id"), table_name="generation_tasks")
op.drop_column("generation_tasks", "custom_title")
op.drop_column("generation_tasks", "cover_url")
op.drop_column("generation_tasks", "output_height")
op.drop_column("generation_tasks", "output_width")
op.drop_column("generation_tasks", "source_task_id")
op.drop_column("generation_tasks", "is_preview")
@@ -1,26 +0,0 @@
"""Add title_config to generation_tasks
Revision ID: 057_title_config
Revises: 056_fix_cover_templates_config
Create Date: 2026-08-23
"""
import sqlalchemy as sa
from alembic import op
revision = "057_title_config"
down_revision = "056_fix_cover_templates_config"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"generation_tasks",
sa.Column("title_config", sa.JSON(), nullable=False, server_default="{}"),
)
def downgrade() -> None:
op.drop_column("generation_tasks", "title_config")
@@ -1,49 +0,0 @@
"""Add unique index on asset_libraries(project_id, kind)
Revision ID: 058_uq_asset_lib_project_kind
Revises: 057_title_config
Create Date: 2026-08-30
同一项目下同 kind 的素材库业务上唯一(前端 getOrCreate 语义、TTS 保存自动建库)。
加唯一索引兜底并发创建竞态,避免重复素材库。
"""
import sqlalchemy as sa
from alembic import op
revision = "058_uq_asset_lib_project_kind"
down_revision = "057_title_config"
branch_labels = None
depends_on = None
def upgrade() -> None:
# 建唯一索引前清洗历史重复:同 (project_id, kind) 只保留 created_at 最新的一条。
# project_id 为 NULL 的系统级行不参与去重(NULL 在唯一索引中互不冲突)。
op.execute("""
DELETE FROM asset_libraries
WHERE id IN (
SELECT id FROM (
SELECT id,
ROW_NUMBER() OVER (
PARTITION BY project_id, kind
ORDER BY created_at DESC, id DESC
) AS rn
FROM asset_libraries
WHERE project_id IS NOT NULL
) t
WHERE t.rn > 1
)
""")
# 与 model 的 UniqueConstraint 定义保持一致(pg_constraint + pg_index 同时注册),
# 避免 Alembic autogenerate 检测到 schema drift
op.create_unique_constraint(
"uq_asset_libraries_project_kind",
"asset_libraries",
["project_id", "kind"],
)
def downgrade() -> None:
op.drop_constraint("uq_asset_libraries_project_kind", "asset_libraries", type_="unique")
@@ -1,23 +0,0 @@
"""add duplicate_rate to generated_videos
Revision ID: 059_duplicate_rate
Revises: 058_uq_asset_lib_project_kind
Create Date: 2026-08-31
"""
import sqlalchemy as sa
from alembic import op
revision = "059_duplicate_rate"
down_revision = "058_uq_asset_lib_project_kind"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("generated_videos", sa.Column("duplicate_rate", sa.Float(), nullable=True))
def downgrade() -> None:
op.drop_column("generated_videos", "duplicate_rate")
@@ -1,57 +0,0 @@
"""migrate template_segments data to template_clip_configs
Revision ID: 060_migrate_segments
Revises: 059_duplicate_rate
Create Date: 2026-08-31
"""
import sqlalchemy as sa
from alembic import op
revision = "060_migrate_segments"
down_revision = "059_duplicate_rate"
branch_labels = None
depends_on = None
def upgrade() -> None:
dialect = op.get_bind().dialect.name
if dialect == "postgresql":
config_expr = (
"CASE WHEN s.material_type IS NOT NULL AND s.material_type != '' "
"THEN json_build_object('material_type', s.material_type)::jsonb "
"ELSE '{}'::jsonb END"
)
empty_json = "'{}'::jsonb"
else:
config_expr = (
"CASE WHEN s.material_type IS NOT NULL AND s.material_type != '' "
"THEN JSON_OBJECT('material_type', s.material_type) "
"ELSE '{}' END"
)
empty_json = "'{}'"
sql_str = (
"INSERT INTO template_clip_configs "
'(id, template_id, clip_type, "order", min_duration, max_duration, '
"text_template, material_requirements, transition_effect, config, "
"created_at, updated_at) "
"SELECT "
"s.id, s.template_id, 'main', s.segment_order, "
"s.duration_min, s.duration_max, "
"'', " + empty_json + ", "
"'cut', " + config_expr + ", "
"s.created_at, s.updated_at "
"FROM template_segments s "
"WHERE NOT EXISTS ("
" SELECT 1 FROM template_clip_configs c "
" WHERE c.template_id = s.template_id"
")"
)
op.execute(sa.text(sql_str))
def downgrade() -> None:
pass
+68 -129
View File
@@ -1,5 +1,5 @@
import logging import logging
from typing import Any, List, Optional from typing import Any, Optional
from app.api.routes._helpers import check_project_access, format_utc_datetime from app.api.routes._helpers import check_project_access, format_utc_datetime
from app.auth import AuthenticatedUser, get_current_user from app.auth import AuthenticatedUser, get_current_user
@@ -14,10 +14,10 @@ from app.schemas.asset import (
AssetResponse, AssetResponse,
BatchClassifyRequest, BatchClassifyRequest,
BatchDeleteRequest, BatchDeleteRequest,
BatchGetRequest,
BatchMarkRequest, BatchMarkRequest,
BatchOperationResponse, BatchOperationResponse,
BatchTagRequest, BatchTagRequest,
CreateAssetRequest,
ListAssetsResponse, ListAssetsResponse,
SmartMatchItem, SmartMatchItem,
SmartMatchRequest, SmartMatchRequest,
@@ -26,9 +26,13 @@ from app.schemas.asset import (
UpdateAssetReviewRequest, UpdateAssetReviewRequest,
) )
from app.schemas.tag import TagAssetsRequest from app.schemas.tag import TagAssetsRequest
from app.services.asset_segment_tracker import compute_asset_availability, get_asset_recent_use_counts
from fastapi import APIRouter, Depends, HTTPException, Query, Response 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 from packages.domain.smart_match import smart_select_assets
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -36,23 +40,6 @@ logger = logging.getLogger(__name__)
router = APIRouter() router = APIRouter()
def _asset_availability_fields(item) -> dict:
"""视频素材返回余量四字段;非视频/无时长/异常时返回 None + usable=True(零影响)。"""
try:
info = compute_asset_availability(item)
except Exception:
logger.warning("计算素材余量失败,按可用处理: asset_id=%s", getattr(item, "id", "?"), exc_info=True)
info = None
if info is None:
return {
"used_duration": None,
"available_duration": None,
"used_ratio": None,
"usable": True,
}
return info
def _to_asset_response(item, storage_service=None) -> AssetResponse: def _to_asset_response(item, storage_service=None) -> AssetResponse:
# 生成签名文件 URL(用于视频播放 / 文件下载) # 生成签名文件 URL(用于视频播放 / 文件下载)
file_url = None file_url = None
@@ -64,16 +51,10 @@ def _to_asset_response(item, storage_service=None) -> AssetResponse:
logger.warning("生成签名URL失败: storage_key=%s", item.storage_key, exc_info=True) logger.warning("生成签名URL失败: storage_key=%s", item.storage_key, exc_info=True)
file_url = None file_url = None
# 缩略图:存储的是 storage_key,需要生成签名 URL 供前端使用 # 缩略图:优先用已有 thumbnail_url,否则对视频素材复用文件签名 URL
# 不再降级使用视频文件 URL(浏览器 <img> 无法渲染 .mp4,会显示黑屏) thumbnail_url = item.thumbnail_url
thumbnail_url = None if not thumbnail_url and item.mime_type and item.mime_type.startswith("video") and file_url:
if item.thumbnail_url: thumbnail_url = file_url
try:
svc = storage_service or get_storage_service()
thumbnail_url = svc.get_download_url(item.thumbnail_url)
except Exception:
logger.warning("生成缩略图签名URL失败: key=%s", item.thumbnail_url, exc_info=True)
thumbnail_url = None
return AssetResponse( return AssetResponse(
id=item.id, id=item.id,
@@ -97,7 +78,6 @@ def _to_asset_response(item, storage_service=None) -> AssetResponse:
created_at=format_utc_datetime(item.created_at), created_at=format_utc_datetime(item.created_at),
uploaded_by_user_id=item.uploaded_by_user_id, uploaded_by_user_id=item.uploaded_by_user_id,
tag_ids=getattr(item, "tag_ids", []), tag_ids=getattr(item, "tag_ids", []),
**_asset_availability_fields(item),
) )
@@ -390,18 +370,6 @@ def update_asset_review_status(
return _to_asset_response(updated) return _to_asset_response(updated)
@router.post("/batch", response_model=List[AssetResponse])
def batch_get_assets(
request: BatchGetRequest,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
asset_repository: Any = Depends(get_asset_repository),
) -> list[AssetResponse]:
"""批量获取素材详情(根据 ID 列表)。"""
items = asset_repository.find_by_ids(request.ids)
storage_service = get_storage_service()
return [_to_asset_response(item, storage_service) for item in items]
@router.post("/batch-delete", response_model=BatchOperationResponse) @router.post("/batch-delete", response_model=BatchOperationResponse)
def batch_delete_assets( def batch_delete_assets(
request: BatchDeleteRequest, request: BatchDeleteRequest,
@@ -576,89 +544,21 @@ def smart_match_assets(
request.library_id, request.kind, status=["ready"], limit=10000 request.library_id, request.kind, status=["ready"], limit=10000
) )
else: else:
filtered_assets = asset_repository.find_by_library(request.library_id, status=["ready"], limit=10000) filtered_assets = asset_repository.find_by_library(
request.library_id, status=["ready"], limit=10000
)
total_candidates = len(filtered_assets) total_candidates = len(filtered_assets)
# ── 过滤前置:余量 + 高频使用,过滤在评分/截取 limit 之前完成 ────────── # 调用统一智能选素材算法(kind 已在 DB 层过滤,无需重复过滤)
# 旧实现先 smart_select_assets(limit=N) 再对这 N 条做过滤,过滤后不回补, results = smart_select_assets(
# 当排名靠前的素材恰好都被排除时返回空 items(前端回退全选,smart-match 名存实亡)。 filtered_assets,
# 现在先过滤全量候选,每级过滤后为空/不足则回退上一级,最后才评分截取。 limit=request.limit,
kind=None,
)
# 1) 余量过滤:usable=False(零重复可切区间耗尽且历史区间均达复用上限)的素材排除
usable_assets = []
exhausted_assets = []
for a in filtered_assets:
try:
avail = compute_asset_availability(a)
except Exception:
logger.warning(
"smart-match 余量计算失败,按可用处理: asset_id=%s",
getattr(a, "id", "?"),
exc_info=True,
)
avail = None
if avail is not None and not avail["usable"]:
exhausted_assets.append(a)
else:
usable_assets.append(a)
if exhausted_assets:
logger.info(
"smart-match 余量过滤: 候选 %d,可切区间耗尽 %d",
len(filtered_assets), len(exhausted_assets),
)
# 回退策略:余量过滤后为空(全部耗尽)时,保留全部候选,不返回空结果。
# 宁可让用户在已耗尽素材上复用,也比 smart-match 空结果回退全选更可控
# (全选同样会选到这些素材,且不经过评分排序)。
pool = usable_assets if usable_assets else filtered_assets
# 2) 高频使用排除:同一素材在最近 5 个视频中出现超过 3 次则排除
MAX_RECENT_USE_COUNT = 3
high_freq_assets = set()
if pool:
asset_ids = [getattr(a, "id", "") for a in pool if getattr(a, "id", "")]
if asset_ids:
try:
use_counts = get_asset_recent_use_counts(
db=asset_repository.session,
asset_ids=asset_ids,
recent_video_count=5,
)
for a in pool:
aid = getattr(a, "id", "")
count = use_counts.get(aid, 0)
if count > MAX_RECENT_USE_COUNT:
high_freq_assets.add(aid)
logger.info(
"smart-match 排除高频使用素材: asset_id=%s use_count=%d limit=%d",
aid, count, MAX_RECENT_USE_COUNT,
)
# 回退策略:排除后剩余素材不足(为空或不够 limit)时,
# 不再全部排除,保留全部可用素材
if high_freq_assets:
remaining_count = len(pool) - len(high_freq_assets)
enough = request.limit is None or remaining_count >= request.limit
if remaining_count > 0 and enough:
pool = [a for a in pool if getattr(a, "id", "") not in high_freq_assets]
else:
logger.info(
"smart-match 高频排除后素材不足(%d<%s),保留全部 %d",
remaining_count,
request.limit if request.limit is not None else "不限",
len(pool),
)
except Exception:
logger.warning("smart-match 高频使用查询失败,跳过排除", exc_info=True)
# 3) 调用统一智能选素材算法(kind 已在 DB 层过滤,无需重复过滤)
results = smart_select_assets(pool, limit=request.limit, kind=None)
# 扁平结构:SmartMatchItem 继承 AssetResponse,素材字段直接在条目顶层,
# 前端无需解析 item.asset 包装层,item.id / item.usable / 余量字段直接可读
items = [ items = [
SmartMatchItem( SmartMatchItem(
**_to_asset_response(r.asset).model_dump(), asset=_to_asset_response(r.asset),
score=r.score, score=r.score,
breakdown=r.breakdown, breakdown=r.breakdown,
) )
@@ -764,12 +664,51 @@ def untag_asset(
@router.post("", response_model=AssetResponse) @router.post("", response_model=AssetResponse)
def create_asset() -> None: def create_asset(
""" request: CreateAssetRequest,
已废弃接口。 authenticated_user: AuthenticatedUser = Depends(get_current_user),
所有素材上传统一走 uploadAssetDirect → completeDirectUpload → ingest-jobs 流程。 asset_repository: Any = Depends(get_asset_repository),
""" asset_library_repository: Any = Depends(get_asset_library_repository),
raise HTTPException( project_repository: Any = Depends(get_project_repository),
status_code=410, ) -> AssetResponse:
detail="此接口已废弃。请使用 uploadAssetDirect 接口上传素材,Worker 会自动处理(视频转码、图片/音频元数据提取)并创建 Asset 记录。", # 先获取素材库,用于推导 project_id(前端可能不传)
library = asset_library_repository.get(request.library_id)
if library is None:
raise HTTPException(status_code=404, detail=f"AssetLibrary {request.library_id} not found")
# project_id 自动推导:优先用请求值,否则从 library 关联的项目获取
project_id = request.project_id or library.project_id
project = project_repository.find_by_id(project_id)
if project is None:
raise HTTPException(status_code=404, detail=f"Project {project_id} not found")
if not project.can_access(authenticated_user.user.id):
raise HTTPException(status_code=403, detail="Access denied to project")
# 确保 library 和 project 归属一致
if library.project_id != project_id:
raise HTTPException(status_code=400, detail="AssetLibrary does not belong to the specified project")
use_case = CreateAssetUseCase(asset_repository)
item = use_case.execute(
CreateAssetCommand(
project_id=project_id,
library_id=request.library_id,
name=request.name,
storage_key=request.storage_key,
mime_type=request.mime_type,
metadata=request.metadata,
file_size=request.file_size,
thumbnail_url=request.thumbnail_url,
duration=request.duration,
width=request.width,
height=request.height,
fps=request.fps,
codec=request.codec,
status=AssetStatus(request.status),
classification_status=ClassificationStatus(request.classification_status),
quality_score=request.quality_score,
uploaded_by_user_id=authenticated_user.user.id,
)
) )
return _to_asset_response(item)
+87 -549
View File
@@ -1,18 +1,15 @@
"""封面生成路由 — Generation 模块. """封面生成路由 — Generation 模块.
端点: 端点:
- POST /generate-cover AI 生成封面(从最终成片视频中抽帧,兼容预览片段回退 - POST /generate-cover AI 生成封面(从预览视频中抽帧)
挂载路径: /api/v1/generation/generate-cover 挂载路径: /api/v1/generation/generate-cover
""" """
from __future__ import annotations from __future__ import annotations
import ipaddress
import logging import logging
import re
from typing import Any, List, Optional from typing import Any, List, Optional
from urllib.parse import urlparse
from app.auth import AuthenticatedUser, get_current_user from app.auth import AuthenticatedUser, get_current_user
from app.dependencies import get_db_session, get_generated_video_repository from app.dependencies import get_db_session, get_generated_video_repository
@@ -27,7 +24,6 @@ from packages.adapters.sqlalchemy_impl.generation_task_repository import (
) )
from packages.application import ListGeneratedVideosByTaskUseCase from packages.application import ListGeneratedVideosByTaskUseCase
from packages.domain.config_schemas import normalize_plan_config from packages.domain.config_schemas import normalize_plan_config
from packages.shared.storage import get_shared_storage_service
from .templates_editor.dependencies import get_draft_plan_id, get_editor_services from .templates_editor.dependencies import get_draft_plan_id, get_editor_services
@@ -35,6 +31,8 @@ logger = logging.getLogger(__name__)
router = APIRouter(tags=["Generation"]) router = APIRouter(tags=["Generation"])
# ── Schemas ────────────────────────────────────────────────────────────── # ── Schemas ──────────────────────────────────────────────────────────────
@@ -51,18 +49,6 @@ class GenerateCoverRequest(BaseModel):
ge=0.0, ge=0.0,
description="手动选帧时间点(秒),仅 cover_type=manual 时有效", description="手动选帧时间点(秒),仅 cover_type=manual 时有效",
) )
cover_url: Optional[str] = Field(
default=None,
description="上传的封面图片 URL,仅 cover_type=upload 时有效",
)
generated_video_id: Optional[str] = Field(
default=None,
description="确认生成产出的最终视频 ID。传入后封面从该视频文件抽帧,而非预览片段。",
)
video_url: Optional[str] = Field(
default=None,
description="最终视频 URL(兜底)。当 generated_video_id 不可用时,直接从此 URL 对应的视频抽帧。",
)
class GenerateCoverResponse(BaseModel): class GenerateCoverResponse(BaseModel):
@@ -75,181 +61,6 @@ class GenerateCoverResponse(BaseModel):
# ── Route ──────────────────────────────────────────────────────────────── # ── Route ────────────────────────────────────────────────────────────────
def _persist_cover_frame(
frame_url: str,
plan_id: str,
title_text: str = "",
*,
title_color: str = "#ffffff",
title_position: str = "bottom",
title_font_size: int | None = None,
) -> str:
"""下载 MediaKit 返回的临时帧图,可选叠加标题后转存到 OSS covers/ 路径。
Args:
frame_url: MediaKit 返回的临时帧图 URL
plan_id: 剪辑计划 ID(生成 OSS key
title_text: 非空时用 Pillow 在帧上叠加标题(用于 E2 从源素材抽帧,
因为源素材本身没有烧录标题)
title_color: 标题字体颜色(#RRGGBB
title_position: 标题位置 top/center/bottom
title_font_size: 标题字号,None 时自动计算
"""
import tempfile
import uuid
from pathlib import Path
tmp_path: str | None = None
try:
import httpx
resp = httpx.get(frame_url, timeout=30, follow_redirects=True)
resp.raise_for_status()
if not resp.content:
return frame_url
with tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) as tmp:
tmp.write(resp.content)
tmp_path = tmp.name
# E2 从源素材抽帧时,源素材无标题,叠加标题文字
if title_text and title_text.strip():
try:
from packages.shared.title_overlay import apply_title_to_image
applied = apply_title_to_image(
tmp_path,
title_text,
color=title_color,
position=title_position,
font_size=title_font_size,
)
if applied:
logger.info("[封面生成] E2 帧图已叠加标题: plan_id=%s", plan_id)
except Exception:
logger.warning(
"[封面生成] E2 标题叠加失败(返回无标题帧): plan_id=%s",
plan_id,
exc_info=True,
)
storage = get_shared_storage_service()
cover_key = f"covers/{plan_id}/cover_{uuid.uuid4().hex[:8]}.jpg"
storage.upload_file(
file_or_path=tmp_path,
storage_key=cover_key,
content_type="image/jpeg",
)
public_url = storage.get_url(cover_key)
return public_url or frame_url
except Exception:
logger.warning("封面帧转存失败,返回原始 URL: plan_id=%s", plan_id, exc_info=True)
return frame_url
finally:
if tmp_path:
Path(tmp_path).unlink(missing_ok=True)
def _get_task_video_url(db: Session, task_id: str) -> Optional[str]:
"""从 GenerationTask 关联的 GeneratedVideo 中获取视频 storage_key / URL."""
try:
video_repo = get_generated_video_repository(db)
use_case = ListGeneratedVideosByTaskUseCase(video_repo)
videos = use_case.execute(task_id)
if videos:
return getattr(videos[0], "file_url", "") or ""
except Exception:
logger.warning("[封面生成] 获取任务视频失败: task_id=%s", task_id, exc_info=True)
return None
def _resolve_storage_key_to_url(storage_key: str) -> Optional[str]:
"""将 storage_key 或完整 URL 转换为可访问的裸 URL。"""
if not storage_key:
return None
try:
if storage_key.startswith("http"):
url = storage_key
else:
storage_svc = get_shared_storage_service()
url = storage_svc.get_url(storage_key)
if url:
url = re.sub(r"(?<!:)//", "/", url)
return url
except Exception as e:
logger.warning("[封面生成] storage_key 转 URL 失败: key=%s err=%s", storage_key, e)
return None
def _endpoint_host(value: str) -> str:
"""从 endpoint / URL 字符串中安全提取主机名(兼容有无 scheme 两种配置)。"""
v = (value or "").strip().lower()
if not v:
return ""
if "://" in v:
return (urlparse(v).hostname or "").lower()
# 无 scheme:去掉可能的端口(host:port),urlparse 补 // 以正确解析
return (urlparse("//" + v).hostname or "").lower()
def _is_private_or_reserved_host(host: str) -> bool:
"""判断主机名是否为内网/回环/链路本地/保留地址(IPv4 与 IPv6 统一处理)。
使用标准库 ipaddress 判定;非 IP 主机名(如 localhost)单独处理。
"""
h = host.strip().lower()
if h in {"localhost", "0.0.0.0", "::", "::1"}:
return True
try:
addr = ipaddress.ip_address(h)
# is_private 覆盖 10/8、172.16/12、192.168/16、127/8、169.254/16、
# ::1、fc00::/7、fe80::/10 等全部私有/保留段
return bool(addr.is_private or addr.is_loopback or addr.is_link_local or addr.is_reserved)
except ValueError:
return False
def _is_trusted_media_url(url: str) -> bool:
"""校验 URL 是否指向受信任的存储域名(OSS bucket / 本地存储),防止 SSRF。
用户可通过 video_url 传入视频地址,但服务端(MediaKit)会主动请求该 URL
因此必须限制为自家存储域名,拒绝内网地址、元数据地址等任意主机。
"""
if not url:
return False
try:
parsed = urlparse(url.strip())
if parsed.scheme not in ("http", "https"):
return False
host = (parsed.hostname or "").lower()
if not host:
return False
# 拒绝一切内网/回环/链路本地/保留地址(IPv4 + IPv6,标准库判定)
if _is_private_or_reserved_host(host):
return False
# 允许:自家 OSS bucket 域名(<bucket>.<endpoint>)或 endpoint 自身及其子域
try:
storage_svc = get_shared_storage_service()
trusted_hosts = set()
public_base = getattr(storage_svc, "public_url", "") or ""
h1 = _endpoint_host(public_base)
if h1:
trusted_hosts.add(h1)
h2 = _endpoint_host(getattr(storage_svc, "endpoint", "") or "")
if h2:
trusted_hosts.add(h2)
for trusted in trusted_hosts:
if host == trusted or host.endswith("." + trusted):
return True
except Exception:
logger.warning("[封面生成] 存储域名白名单初始化失败,URL 校验从严拒绝", exc_info=True)
return False
return False
except Exception:
logger.warning("[封面生成] video_url 白名单校验异常,从严拒绝: url=%s", url[:80], exc_info=True)
return False
@router.post("/generate-cover", response_model=GenerateCoverResponse) @router.post("/generate-cover", response_model=GenerateCoverResponse)
def generate_cover( def generate_cover(
body: GenerateCoverRequest, body: GenerateCoverRequest,
@@ -259,148 +70,37 @@ def generate_cover(
db: Session = Depends(get_db_session), db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user), current_user: AuthenticatedUser = Depends(get_current_user),
) -> GenerateCoverResponse: ) -> GenerateCoverResponse:
"""AI 生成封面 — 优先从最终成片视频中抽帧,回退到预览片段. """AI 生成封面 — 从预览视频中抽帧.
流程(串行): 流程(串行):
1. 优先使用前端传入的 generation_task_id 定位最终成片任务, 1. 预览视频已渲染完成(通过 3 步查找获取 URL)
或自动查找 plan 关联的已完成最终成片任务(is_preview=False 2. 用裸 URL 让 MediaKit 下载视频并抽帧
2. 回退:从预览片段获取视频 URL(兼容旧流程) 3. 帧图下载后上传到 OSS covers/ 路径
3. 用裸 URL 让 MediaKit 下载视频并抽帧
4. 帧图下载后上传到 OSS covers/ 路径
MediaKit 的调用方式(strategy / max_frames / 轮询 / 重试 / 降级)不变。
""" """
_, plan_svc = services _, plan_svc = services
plan = plan_svc.get_plan_or_raise(plan_id) plan = plan_svc.get_plan_or_raise(plan_id)
# ── upload 类型:直接保存前端上传的封面图片,不需要预览视频 ────── # ── 3 步查找预览视频 URL ──────────────────────────────────────────
if body.cover_type == "upload": # 第一步:从 plan.config 读取
if not body.cover_url:
raise HTTPException(
status_code=400,
detail="cover_type=upload 时必须提供 cover_url",
)
cover_data = {
"type": "upload",
"image_url": body.cover_url,
}
current_config = dict(plan.config) if plan.config else {}
current_config["cover"] = cover_data
normalized = normalize_plan_config(current_config)
plan_svc.update_plan_config(plan_id, {"cover": normalized["cover"]})
logger.info(
"封面上传完成: plan_id=%s cover_url=%s by user=%s",
plan_id,
body.cover_url[:80] if body.cover_url else "",
current_user.user.id,
)
return GenerateCoverResponse(plan_id=plan_id, cover=cover_data)
# ── 查找用于抽帧的视频 URL ────────────────────────────────────────
# 优先级:
# 0. 请求体显式传入的 generation_task_id(最终成片任务)
# 1. plan.config.rendered_storage_key
# 2. plan.config.generation_task_id 对应的任务
# 3. source_edit_plan_id 关联的已完成「最终成片」任务(is_preview=False
# 4. source_edit_plan_id 关联的已完成预览任务(is_preview=True,兼容回退)
# 5. user + template 最近的已完成预览任务(兜底)
logger.info("[封面生成] 步骤1: 从 plan.config 查找 rendered_storage_key: plan_id=%s", plan_id) logger.info("[封面生成] 步骤1: 从 plan.config 查找 rendered_storage_key: plan_id=%s", plan_id)
rendered_storage_key = (plan.config or {}).get("rendered_storage_key", "") rendered_storage_key = (plan.config or {}).get("rendered_storage_key", "")
# 步骤 0:请求体传入最终视频标识(generated_video_id 或 video_url # 第二步:如果还没有,通过 generation_task_id 查找预览任务的产物
if not rendered_storage_key:
# 0a:通过 generated_video_id 查找最终成片视频
if body.generated_video_id:
logger.info(
"[封面生成] 步骤0a: 使用 generated_video_id: plan_id=%s video_id=%s",
plan_id,
body.generated_video_id,
)
try:
gv_repo = get_generated_video_repository(db)
gv = gv_repo.get(body.generated_video_id)
if gv:
file_url = getattr(gv, "file_url", "") or ""
if file_url:
# 权限校验(双重,任何一层确认归属不符即拒绝):
# 1) GeneratedVideo.user_id 直接归属(老数据可能为空,为空时不据此放行)
gv_owner = (getattr(gv, "user_id", "") or "").strip()
if gv_owner and gv_owner != current_user.user.id:
raise HTTPException(status_code=403, detail="无权访问该视频")
# 2) 关联 generation_task 归属校验;关联任务缺失时不可静默放行:
# 若 video 自身无 owner 信息且关联任务也查不到,拒绝访问
gv_task_id = getattr(gv, "generation_task_id", "") or ""
task0 = None
if gv_task_id:
try:
task0 = SQLAlchemyGenerationTaskRepository(db).get(gv_task_id)
except Exception:
logger.warning(
"[封面生成] 步骤0a关联任务查询异常: plan_id=%s task_id=%s",
plan_id,
gv_task_id,
exc_info=True,
)
if task0 is not None:
task_owner = (getattr(task0, "created_by_user_id", "") or "").strip()
if task_owner and task_owner != current_user.user.id:
raise HTTPException(status_code=403, detail="无权访问该视频")
elif not gv_owner:
# video 无 owner 且关联任务不存在/无法确认归属 → 拒绝,防止越权
logger.warning(
"[封面生成] 步骤0a视频归属无法确认,拒绝访问: plan_id=%s video_id=%s",
plan_id,
body.generated_video_id,
)
raise HTTPException(status_code=403, detail="无权访问该视频")
rendered_storage_key = file_url
logger.info(
"[封面生成] ✅ 步骤0a找到最终成片: plan_id=%s video_id=%s url=%s",
plan_id,
body.generated_video_id,
file_url[:80],
)
except HTTPException:
raise
except Exception:
logger.warning(
"[封面生成] 步骤0a查找视频失败: plan_id=%s video_id=%s",
plan_id,
body.generated_video_id,
exc_info=True,
)
# 0b:直接使用 video_url(兜底)— 必须通过存储域名白名单校验,防止 SSRF
if not rendered_storage_key and body.video_url:
if _is_trusted_media_url(body.video_url):
logger.info(
"[封面生成] 步骤0b: 使用请求体传入的 video_url(白名单通过): plan_id=%s url=%s",
plan_id,
body.video_url[:80],
)
rendered_storage_key = body.video_url
else:
logger.warning(
"[封面生成] 步骤0b: video_url 不在受信任存储域名白名单内,已忽略: plan_id=%s url=%s",
plan_id,
body.video_url[:80],
)
# 步骤 2:通过 plan.config.generation_task_id 查找
if not rendered_storage_key: if not rendered_storage_key:
generation_task_id = (plan.config or {}).get("generation_task_id", "") generation_task_id = (plan.config or {}).get("generation_task_id", "")
logger.info(
"[封面生成] 步骤2: 通过 generation_task_id 查找: plan_id=%s task_id=%s", plan_id, generation_task_id
)
if generation_task_id: if generation_task_id:
logger.info(
"[封面生成] 步骤2: 通过 plan.config.generation_task_id 查找: plan_id=%s task_id=%s",
plan_id,
generation_task_id,
)
try: try:
_repo = SQLAlchemyGenerationTaskRepository(db) gen_task_repo = SQLAlchemyGenerationTaskRepository(db)
task = _repo.get(generation_task_id) task = gen_task_repo.get(generation_task_id)
if task: if task:
rendered_storage_key = _get_task_video_url(db, task.id) or "" video_repo = get_generated_video_repository(db)
if rendered_storage_key: use_case = ListGeneratedVideosByTaskUseCase(video_repo)
videos = use_case.execute(task.id)
if videos:
rendered_storage_key = getattr(videos[0], "file_url", "") or ""
logger.info( logger.info(
"[封面生成] ✅ 步骤2找到视频: plan_id=%s task_id=%s url=%s", "[封面生成] ✅ 步骤2找到视频: plan_id=%s task_id=%s url=%s",
plan_id, plan_id,
@@ -409,47 +109,26 @@ def generate_cover(
) )
except Exception: except Exception:
logger.warning( logger.warning(
"[封面生成] 步骤2查找失败: plan_id=%s", "封面生成: 通过 generation_task_id 查找视频失败: plan_id=%s",
plan_id, plan_id,
exc_info=True, exc_info=True,
) )
# 步骤 3:通过 source_edit_plan_id 查找已完成「最终成片」任务(is_preview=False # 第 2.5 步:通过 plan_id 作为 source_edit_plan_id 查找关联的已完成预览任务
if not rendered_storage_key: if not rendered_storage_key:
try: try:
_repo = SQLAlchemyGenerationTaskRepository(db) gen_task_repo = SQLAlchemyGenerationTaskRepository(db)
logger.info("[封面生成] 步骤3: 查找最终成片任务(is_preview=False): plan_id=%s", plan_id) logger.info("[封面生成] 步骤2.5: 通过 source_edit_plan_id 查找: plan_id=%s", plan_id)
all_tasks = _repo.list_by_source_edit_plan(plan_id) preview_tasks = gen_task_repo.list_by_source_edit_plan(plan_id)
for pt in all_tasks:
if getattr(pt, "status", "") == "completed" and not getattr(pt, "is_preview", False):
rendered_storage_key = _get_task_video_url(db, pt.id) or ""
if rendered_storage_key:
logger.info(
"[封面生成] ✅ 步骤3找到最终成片: plan_id=%s task_id=%s url=%s",
plan_id,
pt.id,
rendered_storage_key[:80],
)
break
except Exception:
logger.warning(
"[封面生成] 步骤3查找最终成片失败: plan_id=%s",
plan_id,
exc_info=True,
)
# 步骤 4:兼容回退 — 通过 source_edit_plan_id 查找已完成预览任务
if not rendered_storage_key:
try:
_repo = SQLAlchemyGenerationTaskRepository(db)
logger.info("[封面生成] 步骤4: 回退查找预览任务(is_preview=True): plan_id=%s", plan_id)
preview_tasks = _repo.list_by_source_edit_plan(plan_id)
for pt in preview_tasks: for pt in preview_tasks:
if getattr(pt, "status", "") == "completed" and getattr(pt, "is_preview", False): if getattr(pt, "status", "") == "completed" and getattr(pt, "is_preview", False):
rendered_storage_key = _get_task_video_url(db, pt.id) or "" video_repo = get_generated_video_repository(db)
if rendered_storage_key: use_case = ListGeneratedVideosByTaskUseCase(video_repo)
videos = use_case.execute(pt.id)
if videos:
rendered_storage_key = getattr(videos[0], "file_url", "") or ""
logger.info( logger.info(
"[封面生成] ✅ 步骤4找到预览视频: plan_id=%s task_id=%s url=%s", "[封面生成] ✅ 步骤2.5找到视频: plan_id=%s task_id=%s url=%s",
plan_id, plan_id,
pt.id, pt.id,
rendered_storage_key[:80], rendered_storage_key[:80],
@@ -457,50 +136,79 @@ def generate_cover(
break break
except Exception: except Exception:
logger.warning( logger.warning(
"[封面生成] 步骤4查找预览任务失败: plan_id=%s", "封面生成: 通过 source_edit_plan_id 查找预览任务失败: plan_id=%s",
plan_id, plan_id,
exc_info=True, exc_info=True,
) )
# 步骤 5:按 user + template 查找最近的已完成预览任务(兜底) # 第三步:按 user + template 查找最近的已完成预览任务(兜底)
if not rendered_storage_key: if not rendered_storage_key:
try: try:
_repo = SQLAlchemyGenerationTaskRepository(db) gen_task_repo = SQLAlchemyGenerationTaskRepository(db)
logger.info( logger.info("[封面生成] 步骤3: 通过 user+template 查找: plan_id=%s template_id=%s", plan_id, template_id)
"[封面生成] 步骤5: 通过 user+template 查找预览任务: plan_id=%s template_id=%s", preview_tasks = gen_task_repo.list_latest_completed_preview(
plan_id,
template_id,
)
preview_tasks = _repo.list_latest_completed_preview(
user_id=str(current_user.user.id), user_id=str(current_user.user.id),
template_id=template_id, template_id=template_id,
) )
if preview_tasks: if preview_tasks:
rendered_storage_key = _get_task_video_url(db, preview_tasks[0].id) or "" completed_preview = preview_tasks[0]
if rendered_storage_key: video_repo = get_generated_video_repository(db)
use_case = ListGeneratedVideosByTaskUseCase(video_repo)
videos = use_case.execute(completed_preview.id)
if videos:
rendered_storage_key = getattr(videos[0], "file_url", "") or ""
logger.info( logger.info(
"[封面生成] ✅ 步骤5找到预览视频: plan_id=%s task_id=%s", "封面视频: 通过 user+template 找到预览任务: plan_id=%s template_id=%s task_id=%s",
plan_id, plan_id,
preview_tasks[0].id, template_id,
completed_preview.id,
) )
except Exception: except Exception:
logger.warning( logger.warning(
"[封面生成] 步骤5 user+template 查找失败: plan_id=%s", "封面警告: user+template 查找预览任务失败: plan_id=%s template_id=%s",
plan_id, plan_id,
template_id,
exc_info=True, exc_info=True,
) )
# 将 storage_key 转换为可访问 URL;找不到视频时不立即报错, # 仍然找不到才报 400
# 因为步骤 E2 可以直接从源素材抽帧(历史数据或 Worker 抽帧失败时的兜底) if not rendered_storage_key:
logger.error("[封面生成] ❌ 找不到预览视频: plan_id=%s", plan_id)
raise HTTPException(
status_code=400,
detail="请先生成预览视频,再生成封面",
)
# 回写到 plan.config
plan_svc.update_plan_config(plan_id, {"rendered_storage_key": rendered_storage_key})
# 使用裸 URLrendered/* 已配置公开读)
primary_video_url = None primary_video_url = None
if rendered_storage_key: try:
plan_svc.update_plan_config(plan_id, {"rendered_storage_key": rendered_storage_key}) if rendered_storage_key.startswith("http"):
primary_video_url = _resolve_storage_key_to_url(rendered_storage_key) primary_video_url = rendered_storage_key
else:
from packages.shared.storage import get_shared_storage_service
storage_svc = get_shared_storage_service()
primary_video_url = storage_svc.get_url(rendered_storage_key)
# 防御性规范化:合并路径中的双斜杠(// -> /),但保留协议头的 ://
# 历史数据中 project_id 为空时会产生 projects//tasks/ 路径,
# MediaKit 的 HTTP 客户端会规范化 URL 导致 404
if primary_video_url:
import re as _re
primary_video_url = _re.sub(r"(?<!:)//", "/", primary_video_url)
logger.info( logger.info(
"[封面生成] 封面抽帧视频URL: plan_id=%s url=%s", "获取预览视频URL用于封面生成: plan_id=%s url=%s",
plan_id, plan_id,
primary_video_url[:80] if primary_video_url else "", primary_video_url[:80] if primary_video_url else "",
) )
except Exception as e:
raise HTTPException(
status_code=500,
detail=f"获取预览视频URL失败: {e}",
) from e
# 统一封面管道:优先从 GenerationTask.cover_url 读取渲染后视频抽帧的封面 # 统一封面管道:优先从 GenerationTask.cover_url 读取渲染后视频抽帧的封面
# 多步查找 cover_url,和查找视频 URL 一样的 fallback 逻辑 # 多步查找 cover_url,和查找视频 URL 一样的 fallback 逻辑
@@ -529,67 +237,20 @@ def generate_cover(
exc_info=True, exc_info=True,
) )
# 步骤 A2:通过 generated_video_id 查找关联任务的 cover_url # 步骤 B:通过 source_edit_plan_id 查找关联预览任务的 cover_url
if not cover_url_from_task and body.generated_video_id:
try:
gv_repo = get_generated_video_repository(db)
gv = gv_repo.get(body.generated_video_id)
if gv:
gv_task_id = getattr(gv, "generation_task_id", "") or ""
if gv_task_id:
task_a2 = gen_task_repo.get(gv_task_id)
if task_a2 and getattr(task_a2, "cover_url", ""):
cover_url_from_task = task_a2.cover_url
logger.info(
"[封面生成] 封面(步骤A2-video-task): plan_id=%s video_id=%s url=%s",
plan_id,
body.generated_video_id,
cover_url_from_task[:80],
)
except Exception:
logger.warning(
"[封面生成] 步骤A2读取 cover_url 失败: plan_id=%s video_id=%s",
plan_id,
body.generated_video_id,
exc_info=True,
)
# 步骤 B:通过 source_edit_plan_id 查找关联任务的 cover_url
# 优先最终成片任务(is_preview=False),其次预览任务
if not cover_url_from_task: if not cover_url_from_task:
try: try:
all_tasks = gen_task_repo.list_by_source_edit_plan(plan_id) preview_tasks = gen_task_repo.list_by_source_edit_plan(plan_id)
# 先找最终成片 for pt in preview_tasks:
for pt in all_tasks: if getattr(pt, "status", "") == "completed" and getattr(pt, "cover_url", ""):
if (
getattr(pt, "status", "") == "completed"
and not getattr(pt, "is_preview", False)
and getattr(pt, "cover_url", "")
):
cover_url_from_task = pt.cover_url cover_url_from_task = pt.cover_url
logger.info( logger.info(
"[封面生成] 封面(步骤B-final): plan_id=%s task_id=%s url=%s", "[封面生成] 统一管道封面(步骤B-source_plan): plan_id=%s task_id=%s url=%s",
plan_id, plan_id,
pt.id, pt.id,
cover_url_from_task[:80], cover_url_from_task[:80],
) )
break break
# 再找预览
if not cover_url_from_task:
for pt in all_tasks:
if (
getattr(pt, "status", "") == "completed"
and getattr(pt, "is_preview", False)
and getattr(pt, "cover_url", "")
):
cover_url_from_task = pt.cover_url
logger.info(
"[封面生成] 封面(步骤B-preview): plan_id=%s task_id=%s url=%s",
plan_id,
pt.id,
cover_url_from_task[:80],
)
break
except Exception: except Exception:
logger.warning( logger.warning(
"[封面生成] 步骤B查找 cover_url 失败: plan_id=%s", "[封面生成] 步骤B查找 cover_url 失败: plan_id=%s",
@@ -622,129 +283,6 @@ def generate_cover(
exc_info=True, exc_info=True,
) )
# 步骤 D:从 plan.config.cover_candidates 读取(Worker 渲染时写入)
if not cover_url_from_task:
_candidates = (plan.config or {}).get("cover_candidates") or []
if isinstance(_candidates, list) and _candidates:
_first = _candidates[0]
if isinstance(_first, dict):
cover_url_from_task = _first.get("image_url") or _first.get("url") or ""
if cover_url_from_task:
logger.info(
"[封面生成] 统一管道封面(步骤D-cover_candidates): plan_id=%s url=%s",
plan_id,
cover_url_from_task[:80],
)
# 步骤 E1:如果有已渲染的预览视频 URL 但 cover_url 未持久化(历史数据),
# 直接从渲染视频抽帧
if not cover_url_from_task and primary_video_url:
try:
from packages.shared.mediakit_client import get_mediakit_client
mk_client = get_mediakit_client()
if mk_client.is_available:
logger.info(
"[封面生成] 步骤E1-从渲染视频抽帧: plan_id=%s url=%s",
plan_id,
primary_video_url[:80],
)
snapshots = mk_client.extract_frames(
video_url=primary_video_url,
strategy="SpecifiedFrames",
max_frames=1,
poll_interval=2.0,
max_poll_attempts=5,
max_retries=0,
)
if snapshots:
raw = snapshots[0].get("image_url") or snapshots[0].get("url") or ""
if raw:
cover_url_from_task = _persist_cover_frame(raw, plan_id)
logger.info(
"[封面生成] 统一管道封面(步骤E1-rendered-video): plan_id=%s url=%s",
plan_id,
cover_url_from_task[:80],
)
except Exception:
logger.warning(
"[封面生成] 步骤E1从渲染视频抽帧失败: plan_id=%s",
plan_id,
exc_info=True,
)
# 步骤 E2:当 A/B/C/D/E1 均未命中(如历史预览任务无 cover_url)时,
# 直接从用户选择的第一个视频素材中抽取封面帧作为兜底。API 请求内短超时,不阻塞。
if not cover_url_from_task and body.asset_ids:
from packages.adapters.sqlalchemy_impl.asset_repository import (
SQLAlchemyAssetRepository,
)
from packages.shared.mediakit_client import get_mediakit_client
from packages.shared.storage import get_shared_storage_service
asset_repo = SQLAlchemyAssetRepository(db)
storage_svc = get_shared_storage_service()
mk_client = get_mediakit_client()
# 从 plan.config 读取完整标题样式,E2 从源素材抽帧时叠加(源素材本身无标题)
_e2_title_cfg = (plan.config or {}).get("title", {}) or {}
if not isinstance(_e2_title_cfg, dict):
_e2_title_cfg = {}
_e2_title_text = (_e2_title_cfg.get("text", "") or "").strip() if _e2_title_cfg.get("enabled", True) else ""
# 读取标题样式:前端可能传 color 或 font_color,都兼容
_e2_title_color = _e2_title_cfg.get("color") or _e2_title_cfg.get("font_color") or "#ffffff"
_e2_title_position = _e2_title_cfg.get("position", "bottom") or "bottom"
_e2_title_font_size = _e2_title_cfg.get("font_size") or _e2_title_cfg.get("size")
if mk_client.is_available:
for aid in body.asset_ids:
try:
asset = asset_repo.get(aid)
if not asset or asset.file_type != "video":
continue
sk = asset.storage_key or ""
if not sk:
continue
src_url = sk if sk.startswith("http") else storage_svc.get_url(sk)
if not src_url:
continue
logger.info(
"[封面生成] 步骤E-从素材抽帧: plan_id=%s asset_id=%s url=%s",
plan_id,
aid,
src_url[:80],
)
snapshots = mk_client.extract_frames(
video_url=src_url,
strategy="SpecifiedFrames",
max_frames=1,
poll_interval=2.0,
max_poll_attempts=5,
max_retries=0,
)
if snapshots:
raw = snapshots[0].get("image_url") or snapshots[0].get("url") or ""
if raw:
cover_url_from_task = _persist_cover_frame(
raw,
plan_id,
title_text=_e2_title_text,
title_color=_e2_title_color,
title_position=_e2_title_position,
title_font_size=_e2_title_font_size,
)
logger.info(
"[封面生成] 统一管道封面(步骤E-source-asset): plan_id=%s url=%s",
plan_id,
cover_url_from_task[:80],
)
break
except Exception:
logger.warning(
"[封面生成] 步骤E从素材抽帧失败: plan_id=%s asset_id=%s",
plan_id,
aid,
exc_info=True,
)
if cover_url_from_task: if cover_url_from_task:
# 标题已在预览视频渲染时烧录(ASS字幕),封面帧自然包含标题 # 标题已在预览视频渲染时烧录(ASS字幕),封面帧自然包含标题
cover_data = { cover_data = {
@@ -760,13 +298,13 @@ def generate_cover(
return GenerateCoverResponse(plan_id=plan_id, cover=cover_data) return GenerateCoverResponse(plan_id=plan_id, cover=cover_data)
logger.warning( logger.warning(
"[封面生成] 统一管道未找到 cover_url (A/B/C/D均未命中): plan_id=%s", "[封面生成] 统一管道未找到 cover_url: plan_id=%s",
plan_id, plan_id,
) )
# ai_frame/ai_regenerate 类型必须从渲染管道获取,不再回退到 AI 服务 # ai_frame/ai_regenerate 类型必须从渲染管道获取,不再回退到 AI 服务
raise HTTPException( raise HTTPException(
status_code=400, status_code=400,
detail="封面生成失败:未找到可抽帧的视频素材,请确认已上传视频素材后重试", detail="封面尚未生成,请先重新生成预览视频以触发封面自动提取",
) )
from packages.shared.ai_service import run_generate_cover from packages.shared.ai_service import run_generate_cover
+15 -67
View File
@@ -5,6 +5,7 @@
from __future__ import annotations from __future__ import annotations
import json
import logging import logging
from app.auth import AuthenticatedUser, get_current_user from app.auth import AuthenticatedUser, get_current_user
@@ -264,38 +265,22 @@ def create_preview_generation_task(
if not video_ratio and request.template_id: if not video_ratio and request.template_id:
video_ratio = _infer_video_ratio_from_template(request.template_id, db, user_id) video_ratio = _infer_video_ratio_from_template(request.template_id, db, user_id)
# 根据 video_ratio 计算输出分辨率(默认竖屏 1080x1920)
output_width, output_height = 1080, 1920
if video_ratio:
parts = video_ratio.split(":")
if len(parts) == 2:
try:
w, h = int(parts[0]), int(parts[1])
base = 1920
if w < h:
# 竖屏
output_width = round(base * w / h)
output_height = base
else:
# 横屏
output_width = base
output_height = round(base * h / w)
# 对齐到偶数
output_width = output_width - output_width % 2
output_height = output_height - output_height % 2
except (ValueError, ZeroDivisionError):
output_width, output_height = 1080, 1920
resolution = f"{output_width}x{output_height}"
logger.info(
"[预览生成] 分辨率: video_ratio=%s%s (%dx%d)",
video_ratio, resolution, output_width, output_height,
)
# 从模板读取 editing_mode / mode 作为 strategy_id(渲染 pipeline 的 mode 参数) # 从模板读取 editing_mode / mode 作为 strategy_id(渲染 pipeline 的 mode 参数)
strategy_id = _resolve_strategy_id_from_template(request.template_id, db, user_id) strategy_id = _resolve_strategy_id_from_template(request.template_id, db, user_id)
# 处理标题配置:如果有标题文本,序列化到 custom_title 字段传递给 worker
title_config = request.title_config or {} title_config = request.title_config or {}
title_text = (title_config.get("text") or "").strip()
custom_title_value = ""
if title_text:
# 将标题文本和样式配置序列化为 JSON 存入 custom_title
# Worker 端会解析 JSON 获取完整标题配置
custom_title_value = json.dumps(title_config, ensure_ascii=False)
logger.info(
"[预览生成] 标题配置: text=%s, config_keys=%s",
title_text[:30],
list(title_config.keys()),
)
use_case = CreateGenerationTaskUseCase(generation_task_repository) use_case = CreateGenerationTaskUseCase(generation_task_repository)
@@ -315,14 +300,12 @@ def create_preview_generation_task(
asset_select_mode="", asset_select_mode="",
batch_id="", batch_id="",
video_title=request.video_title, video_title=request.video_title,
resolution=resolution, resolution="",
bgm_config=request.bgm_config or {}, bgm_config=request.bgm_config or {},
auto_retry_enabled=False, auto_retry_enabled=False,
auto_retry_max=0, auto_retry_max=0,
is_preview=True, is_preview=True,
title_config=title_config, custom_title=custom_title_value,
output_width=output_width,
output_height=output_height,
) )
) )
except ValueError as e: except ValueError as e:
@@ -358,41 +341,6 @@ def create_preview_generation_task(
exc_info=True, exc_info=True,
) )
# 每条预览都关联独立克隆 plan:多预览前端为 N 次并发调用,若共用同一 plan
# 则 N 条预览片段完全相同;克隆时片段起点按持久化历史区间重算(含受控复用),
# 保证各预览版本内容不同
if task.source_edit_plan_id:
try:
from app.services.edit_plan_service import EditPlanService
_plan_svc = EditPlanService(db)
_preview_plan = _plan_svc.clone_plan_for_variant(
task.source_edit_plan_id,
created_by_user_id=user_id,
name_suffix="预览变体",
)
task.source_edit_plan_id = _preview_plan.id
generation_task_repository.update(task)
logger.info(
"[预览生成] 预览关联独立克隆 plan: task_id=%s clone_plan_id=%s",
task.id,
_preview_plan.id,
)
except Exception as clone_err:
# 不退回共用原 plan(否则多条预览内容相同,违反去重诉求):
# 标记任务失败并中断,前端可重新发起预览
logger.error(
"[预览生成] 克隆预览变体 plan 失败,任务标记失败: task_id=%s error=%s",
task.id,
clone_err,
exc_info=True,
)
_mark_task_failed(generation_task_repository, task, "预览变体计划创建失败")
raise HTTPException(
status_code=500,
detail="创建预览任务失败:无法生成独立剪辑计划,请重试",
) from clone_err
# 入队执行;若入队失败则标记任务为 failed 避免僵尸数据 # 入队执行;若入队失败则标记任务为 failed 避免僵尸数据
try: try:
if not safe_enqueue_generation_task( if not safe_enqueue_generation_task(
+18 -265
View File
@@ -1,4 +1,5 @@
import logging import logging
import random
import uuid import uuid
from typing import Any from typing import Any
@@ -15,7 +16,6 @@ from app.core.task_enqueue import (
from app.dependencies import ( from app.dependencies import (
get_asset_library_repository, get_asset_library_repository,
get_asset_repository, get_asset_repository,
get_db_session,
get_generated_video_repository, get_generated_video_repository,
get_generation_task_repository, get_generation_task_repository,
get_project_repository, get_project_repository,
@@ -32,7 +32,6 @@ from app.schemas.generation_task import (
ListGenerationTasksResponse, ListGenerationTasksResponse,
) )
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from packages.application import ( from packages.application import (
CreateGenerationTaskCommand, CreateGenerationTaskCommand,
@@ -69,7 +68,7 @@ def _to_generation_task_response(task) -> GenerationTaskResponse:
output_width=getattr(task, "output_width", 1280), output_width=getattr(task, "output_width", 1280),
output_height=getattr(task, "output_height", 720), output_height=getattr(task, "output_height", 720),
cover_url=getattr(task, "cover_url", ""), cover_url=getattr(task, "cover_url", ""),
title_config=getattr(task, "title_config", {}) or {}, custom_title=getattr(task, "custom_title", ""),
logs=getattr(task, "logs", "[]"), logs=getattr(task, "logs", "[]"),
status=task.status, status=task.status,
progress=task.progress, progress=task.progress,
@@ -115,8 +114,8 @@ def _select_assets_from_library(
Args: Args:
assets: 素材库中所有素材(Asset 实体列表) assets: 素材库中所有素材(Asset 实体列表)
mode: 选取模式 — all=全部, smart=智能匹配(多维度评分+多样性) mode: 选取模式 — all=全部, random=随机, smart=智能匹配(多维度评分+多样性)
count: 选取数量,0 表示全部(仅 smart 模式有效) count: 选取数量,0 表示全部(仅 random/smart 模式有效)
Returns: Returns:
选中的素材 ID 列表 选中的素材 ID 列表
@@ -126,6 +125,12 @@ def _select_assets_from_library(
if not ready_video_assets: if not ready_video_assets:
return [] return []
if mode == "random":
selected = (
ready_video_assets if count <= 0 else random.sample(ready_video_assets, min(count, len(ready_video_assets)))
)
return [a.id for a in selected]
if mode == "smart": if mode == "smart":
# 智能匹配:统一使用 packages/domain/smart_match.py 的多维评分+多样性选取 # 智能匹配:统一使用 packages/domain/smart_match.py 的多维评分+多样性选取
# 评分维度:质量分(40%) + 时长适配(30%) + 新鲜度(20%) + 未使用加分(10%) # 评分维度:质量分(40%) + 时长适配(30%) + 新鲜度(20%) + 未使用加分(10%)
@@ -137,68 +142,6 @@ def _select_assets_from_library(
return [a.id for a in ready_video_assets] return [a.id for a in ready_video_assets]
def _writeback_edit_plan_config(
plan_id: str,
task_id: str,
title_config: dict | None,
db: Session,
) -> None:
"""任务入队成功后,回写 EditPlan.configgeneration_task_id + title_config。
用 merge 方式更新,不整体覆盖 config,避免丢失其他字段。
失败只记日志,不影响任务创建。
"""
if not plan_id:
return
try:
from packages.adapters.sqlalchemy_impl.models import EditPlanModel
plan_model = db.query(EditPlanModel).filter(EditPlanModel.id == plan_id).first()
if plan_model is None:
logger.warning("[生成任务] 回写plan.config失败: plan不存在 plan_id=%s", plan_id)
return
current_config = plan_model.config if isinstance(plan_model.config, dict) else {}
merged = dict(current_config)
merged["generation_task_id"] = task_id
# 检查标题是否发生变化,如果变化则清除 cover 字段强制重新生成封面
if title_config:
old_title_config = merged.get("title_config", {}) or {}
old_title_text = (old_title_config.get("text") or "").strip()
new_title_text = (title_config.get("text") or "").strip()
if old_title_text != new_title_text:
# 标题变化,清除旧封面
if "cover" in merged:
del merged["cover"]
logger.info(
"[生成任务] 标题变化,清除旧封面: plan_id=%s old_title=%s new_title=%s",
plan_id, old_title_text, new_title_text,
)
merged["title_config"] = title_config
plan_model.config = merged
db.commit()
logger.info(
"[生成任务] 回写plan.config成功: plan_id=%s task_id=%s keys=%s",
plan_id,
task_id,
list(merged.keys()),
)
except Exception as e:
logger.warning(
"[生成任务] 回写plan.config异常(不影响任务创建): plan_id=%s error=%s",
plan_id,
e,
exc_info=True,
)
try:
db.rollback()
except Exception:
pass
def _resolve_project_and_library( def _resolve_project_and_library(
request: CreateGenerationTaskRequest, request: CreateGenerationTaskRequest,
project_repository: Any, project_repository: Any,
@@ -244,7 +187,6 @@ def create_generation_task(
project_repository: Any = Depends(get_project_repository), project_repository: Any = Depends(get_project_repository),
asset_library_repository: Any = Depends(get_asset_library_repository), asset_library_repository: Any = Depends(get_asset_library_repository),
asset_repository: Any = Depends(get_asset_repository), asset_repository: Any = Depends(get_asset_repository),
db: Session = Depends(get_db_session),
) -> BatchGenerationTaskResponse: ) -> BatchGenerationTaskResponse:
logger.info( logger.info(
"[生成任务] 接收请求: user_id=%s, template_id=%s, asset_count=%d, mode=%s, count=%d", "[生成任务] 接收请求: user_id=%s, template_id=%s, asset_count=%d, mode=%s, count=%d",
@@ -285,8 +227,8 @@ def create_generation_task(
mode=request.asset_select_mode, mode=request.asset_select_mode,
count=request.asset_select_count, count=request.asset_select_count,
) )
elif project_id and not resolved_asset_ids and request.asset_select_mode in ("smart",): elif project_id and not resolved_asset_ids and request.asset_select_mode in ("random", "smart"):
# 项目级模式:未指定 asset_ids 且选择了 smart 模式时,也自动选取 # 项目级模式:未指定 asset_ids 且选择了 random/smart 模式时,也自动选取
assets = asset_repository.find_by_project(project_id) assets = asset_repository.find_by_project(project_id)
if assets: if assets:
resolved_asset_ids = _select_assets_from_library( resolved_asset_ids = _select_assets_from_library(
@@ -300,89 +242,6 @@ def create_generation_task(
detail="当前项目没有符合条件的视频素材,请先上传并等待导入完成后再生成。", detail="当前项目没有符合条件的视频素材,请先上传并等待导入完成后再生成。",
) )
# ── 兜底复用预览产物 ──
# 前端刷新后 previewTaskId 丢失,降级调 create 接口时,
# 如果同一 edit_plan 有已完成的预览任务,直接复用(秒出)。
if request.source_edit_plan_id and not request.is_preview:
try:
from packages.adapters.sqlalchemy_impl.models import (
GenerationTaskModel,
)
_preview_model = (
db.query(GenerationTaskModel)
.filter(
GenerationTaskModel.source_edit_plan_id == request.source_edit_plan_id,
GenerationTaskModel.is_preview.is_(True),
GenerationTaskModel.status == "completed",
GenerationTaskModel.created_by_user_id == authenticated_user.user.id,
)
.order_by(GenerationTaskModel.created_at.desc())
.first()
)
if _preview_model is not None:
# 校验分辨率一致性(与 confirm 端点逻辑相同)
req_w = request.output_width or 0
req_h = request.output_height or 0
src_w = getattr(_preview_model, "output_width", 0) or 0
src_h = getattr(_preview_model, "output_height", 0) or 0
resolution_match = (req_w == 0 or req_w == src_w) and (req_h == 0 or req_h == src_h)
if resolution_match:
from packages.adapters.sqlalchemy_impl.generation_task_repository import (
_to_domain,
)
preview_task = _to_domain(_preview_model)
# 如果传了标题,更新 title_config
fallback_title_config = None
if request.title_config and request.title_config.get("text", "").strip():
fallback_title_config = dict(preview_task.title_config or {})
fallback_title_config.update(request.title_config)
preview_task.mark_confirmed(
cover_url=request.cover_url or preview_task.cover_url,
output_width=request.output_width or preview_task.output_width,
output_height=request.output_height or preview_task.output_height,
title_config=fallback_title_config,
)
generation_task_repository.update(preview_task)
# 同步标题到 EditPlan.config
if fallback_title_config:
_writeback_edit_plan_config(
plan_id=request.source_edit_plan_id,
task_id=preview_task.id,
title_config=fallback_title_config,
db=db,
)
logger.info(
"[生成任务] 兜底复用预览产物: preview_task_id=%s, plan_id=%s",
preview_task.id,
request.source_edit_plan_id,
)
return BatchGenerationTaskResponse(
items=[_to_generation_task_response(preview_task)],
total=1,
)
else:
logger.info(
"[生成任务] 兜底复用跳过(分辨率不一致): plan_id=%s, src=%sx%s, req=%sx%s",
request.source_edit_plan_id,
src_w,
src_h,
req_w,
req_h,
)
except Exception:
logger.warning(
"[生成任务] 兜底复用预览产物异常(不影响主流程): plan_id=%s",
request.source_edit_plan_id,
exc_info=True,
)
use_case = CreateGenerationTaskUseCase(generation_task_repository) use_case = CreateGenerationTaskUseCase(generation_task_repository)
count = request.count count = request.count
created_tasks = [] created_tasks = []
@@ -418,56 +277,8 @@ def create_generation_task(
logger.info("画中画已下线,strategy_id %s → one_take", effective_strategy_id) logger.info("画中画已下线,strategy_id %s → one_take", effective_strategy_id)
effective_strategy_id = "one_take" effective_strategy_id = "one_take"
# 批量生成时每个任务关联独立克隆 plan(片段起点重算),
# 禁止 N 条任务共用同一 source_edit_plan_id 导致片段一模一样。
# 在创建任何任务【之前】预克隆全部变体:克隆失败直接中断(此时无脏数据),
# 绝不静默退回共用源 plan(否则批量视频内容重复,违反去重诉求)。
variant_plan_ids: list[str] = []
if count > 1 and request.source_edit_plan_id:
from app.services.edit_plan_service import EditPlanService
_plan_svc = EditPlanService(db)
for task_index in range(1, count):
variant = None
last_err: Exception | None = None
for _attempt in range(2): # 1 次重试,抗 DB 瞬时抖动
try:
variant = _plan_svc.clone_plan_for_variant(
request.source_edit_plan_id,
created_by_user_id=user_id,
name_suffix=f"批量{task_index + 1}",
)
break
except Exception as clone_err: # noqa: PERF203
last_err = clone_err
logger.warning(
"[生成任务] 克隆变体 plan 失败(尝试%d/2): source=%s error=%s",
_attempt + 1,
request.source_edit_plan_id,
clone_err,
exc_info=True,
)
if variant is None:
logger.error(
"[生成任务] 克隆变体 plan 重试仍失败,中断批量创建: source=%s",
request.source_edit_plan_id,
exc_info=last_err,
)
raise HTTPException(
status_code=500,
detail="创建批量任务失败:无法生成独立剪辑计划,请重试",
) from last_err
variant_plan_ids.append(variant.id)
try: try:
for task_index in range(count): for _ in range(count):
# 第 1 条复用源 plan(保留用户编辑结果);其余使用预克隆的独立变体 plan。
# 无源 plansource_edit_plan_id 为空)时无可克隆对象,variant_plan_ids
# 为空列表:各任务走自身随机选片流程,不做索引访问(防 IndexError)
effective_plan_id = request.source_edit_plan_id
if task_index > 0 and variant_plan_ids:
effective_plan_id = variant_plan_ids[task_index - 1]
task = use_case.execute( task = use_case.execute(
CreateGenerationTaskCommand( CreateGenerationTaskCommand(
project_id=project_id, project_id=project_id,
@@ -479,7 +290,7 @@ def create_generation_task(
title_ids=request.title_ids, title_ids=request.title_ids,
voice_ids=request.voice_ids, voice_ids=request.voice_ids,
created_by_user_id=user_id, created_by_user_id=user_id,
source_edit_plan_id=effective_plan_id, source_edit_plan_id=request.source_edit_plan_id,
asset_select_mode=request.asset_select_mode, asset_select_mode=request.asset_select_mode,
batch_id=batch_id, batch_id=batch_id,
video_title=request.video_title, video_title=request.video_title,
@@ -492,53 +303,10 @@ def create_generation_task(
output_width=request.output_width, output_width=request.output_width,
output_height=request.output_height, output_height=request.output_height,
cover_url=request.cover_url, cover_url=request.cover_url,
title_config=request.title_config or {}, custom_title=request.custom_title,
) )
) )
try: try:
# 兜底关联编辑计划:前端未传 source_edit_plan_id 时,
# 通过 template_id + user_id 在 DB 层直接查找最新的 plan。
# 必须在 enqueue 之前执行,避免 worker 读取时 source_edit_plan_id 为空(竞态条件)
if not task.source_edit_plan_id and request.template_id:
try:
from packages.adapters.sqlalchemy_impl.models import EditPlanModel
_plan_model = (
db.query(EditPlanModel)
.filter(
EditPlanModel.template_id == request.template_id,
EditPlanModel.created_by_user_id == user_id,
)
.order_by(EditPlanModel.created_at.desc())
.first()
)
if _plan_model:
task.source_edit_plan_id = _plan_model.id
generation_task_repository.update(task)
logger.info(
"[生成任务] 自动关联编辑计划: task_id=%s plan_id=%s",
task.id,
_plan_model.id,
)
except Exception:
logger.warning(
"[生成任务] 查找关联编辑计划失败(不影响主流程): task_id=%s",
task.id,
exc_info=True,
)
# 回写 plan.config:必须在 enqueue 之前执行,
# 确保 worker 读取 plan 时 config 中已包含 generation_task_id。
# 只在首个任务时回写一次,避免批量生成时循环覆盖。
_effective_plan_id = task.source_edit_plan_id
if _effective_plan_id and len(created_tasks) == 0:
_writeback_edit_plan_config(
plan_id=_effective_plan_id,
task_id=task.id,
title_config=request.title_config,
db=db,
)
if safe_enqueue_generation_task( if safe_enqueue_generation_task(
task, task,
generation_task_repository, generation_task_repository,
@@ -583,7 +351,6 @@ def confirm_generation(
authenticated_user: AuthenticatedUser = Depends(get_current_user), authenticated_user: AuthenticatedUser = Depends(get_current_user),
generation_task_repository: Any = Depends(get_generation_task_repository), generation_task_repository: Any = Depends(get_generation_task_repository),
project_repository: Any = Depends(get_project_repository), project_repository: Any = Depends(get_project_repository),
db: Session = Depends(get_db_session),
) -> BatchGenerationTaskResponse: ) -> BatchGenerationTaskResponse:
"""确认生成 -- 复用预览渲染产物(预览与正式品质一致)。 """确认生成 -- 复用预览渲染产物(预览与正式品质一致)。
@@ -612,29 +379,13 @@ def confirm_generation(
resolution_match = (req_w == 0 or req_w == src_w) and (req_h == 0 or req_h == src_h) resolution_match = (req_w == 0 or req_w == src_w) and (req_h == 0 or req_h == src_h)
if resolution_match: if resolution_match:
# 如果用户传了 custom_title,同步更新 title_config
confirmed_title_config = None
if request.custom_title and request.custom_title.strip():
confirmed_title_config = dict(getattr(source_task, "title_config", {}) or {})
confirmed_title_config["text"] = request.custom_title.strip()
source_task.mark_confirmed( source_task.mark_confirmed(
cover_url=request.cover_url, cover_url=request.cover_url,
custom_title=request.custom_title,
output_width=request.output_width, output_width=request.output_width,
output_height=request.output_height, output_height=request.output_height,
title_config=confirmed_title_config,
) )
generation_task_repository.update(source_task) generation_task_repository.update(source_task)
# 同步标题到 EditPlan.config
if confirmed_title_config and source_task.source_edit_plan_id:
_writeback_edit_plan_config(
plan_id=source_task.source_edit_plan_id,
task_id=source_task.id,
title_config=confirmed_title_config,
db=db,
)
logger.info( logger.info(
"[确认生成] 复用预览产物: task_id=%s, user_id=%s", "[确认生成] 复用预览产物: task_id=%s, user_id=%s",
task_id, task_id,
@@ -676,6 +427,7 @@ def confirm_generation(
output_width=request.output_width, output_width=request.output_width,
output_height=request.output_height, output_height=request.output_height,
cover_url=request.cover_url, cover_url=request.cover_url,
custom_title=request.custom_title,
) )
) )
@@ -808,6 +560,7 @@ def retry_generation_task(
output_width=getattr(task, "output_width", 1280), output_width=getattr(task, "output_width", 1280),
output_height=getattr(task, "output_height", 720), output_height=getattr(task, "output_height", 720),
cover_url=getattr(task, "cover_url", ""), cover_url=getattr(task, "cover_url", ""),
custom_title=getattr(task, "custom_title", ""),
) )
) )
try: try:
+3 -3
View File
@@ -1,6 +1,6 @@
from datetime import datetime, timezone from datetime import datetime, timezone
import psycopg import psycopg2
import redis import redis
from app.config import settings from app.config import settings
from fastapi import APIRouter, status from fastapi import APIRouter, status
@@ -49,7 +49,7 @@ async def _check_database() -> dict:
"message": "Using in-memory database", "message": "Using in-memory database",
} }
try: try:
conn = psycopg.connect(settings.DATABASE_URL, connect_timeout=3) conn = psycopg2.connect(settings.DATABASE_URL, connect_timeout=3)
with conn.cursor() as cur: with conn.cursor() as cur:
cur.execute("SELECT 1") cur.execute("SELECT 1")
cur.fetchone() cur.fetchone()
@@ -124,7 +124,7 @@ async def _check_migrations() -> dict:
"message": "Using in-memory database, no migrations needed", "message": "Using in-memory database, no migrations needed",
} }
try: try:
conn = psycopg.connect(settings.DATABASE_URL, connect_timeout=3) conn = psycopg2.connect(settings.DATABASE_URL, connect_timeout=3)
with conn.cursor() as cur: with conn.cursor() as cur:
cur.execute(""" cur.execute("""
SELECT COUNT(*) FROM information_schema.tables SELECT COUNT(*) FROM information_schema.tables
+2 -2
View File
@@ -3,7 +3,7 @@ from typing import Any
from app.core.celery_app import celery_app from app.core.celery_app import celery_app
from app.dependencies import get_ingest_job_repository from app.dependencies import get_ingest_job_repository
from app.schemas.ingest_job import IngestJobResponse, SubmitIngestJobRequest from app.schemas.ingest_job import IngestJobResponse, SubmitIngestJobRequest
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends
from packages.application import SubmitIngestJobCommand, SubmitIngestJobUseCase from packages.application import SubmitIngestJobCommand, SubmitIngestJobUseCase
@@ -17,7 +17,7 @@ def get_ingest_job(
) -> IngestJobResponse: ) -> IngestJobResponse:
job = ingest_job_repository.get(job_id) job = ingest_job_repository.get(job_id)
if job is None: if job is None:
raise HTTPException(status_code=404, detail=f"IngestJob {job_id} not found") raise ValueError(f"IngestJob {job_id} not found")
return IngestJobResponse( return IngestJobResponse(
id=job.id, id=job.id,
project_id=job.project_id, project_id=job.project_id,
@@ -1,9 +1,10 @@
"""模板编辑器 API 路由包. """模板编辑器 API 路由包.
模块拆分 将原来 2560 行的 templates_editor.py 巨无霸拆分为 12 个模块:
- schemas.py: 所有 Pydantic model - schemas.py: 所有 Pydantic model
- dependencies.py: 依赖注入 - dependencies.py: 依赖注入
- _utils.py: 工具函数 - _utils.py: 工具函数
- _fallback.py: 自动兜底逻辑
- draft.py: 草稿管理(详情/更新/发布/版本/回滚) - draft.py: 草稿管理(详情/更新/发布/版本/回滚)
- clips.py: 片段管理(CRUD/分割/合并/重排/批量删除/从素材创建) - clips.py: 片段管理(CRUD/分割/合并/重排/批量删除/从素材创建)
- adjustments.py: 片段调整(速度/音量/裁剪/批量调速) - adjustments.py: 片段调整(速度/音量/裁剪/批量调速)
@@ -12,6 +13,7 @@
- export.py: 导出配置 - export.py: 导出配置
- subtitles.py: 字幕管理 - subtitles.py: 字幕管理
- ai_features.py: AI 推荐 - ai_features.py: AI 推荐
- generation.py: 生成(触发/进度/记录)
- timeline.py: 时间线 - timeline.py: 时间线
挂载路径: /api/v1/templates/{template_id}/editor/ 挂载路径: /api/v1/templates/{template_id}/editor/
@@ -32,6 +34,7 @@ from .dependencies import get_draft_plan_id, get_editor_services # noqa: F401
from .draft import router as draft_router from .draft import router as draft_router
from .effects import router as effects_router from .effects import router as effects_router
from .export import router as export_router from .export import router as export_router
from .generation import router as generation_router
from .subtitles import router as subtitles_router from .subtitles import router as subtitles_router
from .timeline import router as timeline_router from .timeline import router as timeline_router
@@ -48,6 +51,7 @@ _sub_routers = [
export_router, export_router,
subtitles_router, subtitles_router,
ai_features_router, ai_features_router,
generation_router,
timeline_router, timeline_router,
] ]
+227
View File
@@ -0,0 +1,227 @@
"""模板编辑器自动兜底逻辑.
generate_editor_draft 触发生成前的自动修复流程:
1. draft → editing 状态迁移
2. 无片段时从模板复制片段配置
3. 为无素材片段分配指定素材
4. 项目有素材库时自动选素材
"""
from __future__ import annotations
import logging
import random
from typing import Any
from app.services.edit_plan_service import EditPlanService
from sqlalchemy.orm import Session
from packages.adapters.sqlalchemy_impl.template_clip_config_repository import (
SQLAlchemyTemplateClipConfigRepository,
)
from packages.adapters.sqlalchemy_impl.template_repository import (
SQLAlchemyTemplateRepository,
)
from packages.domain.edit_plan import EditPlanStatus
logger = logging.getLogger(__name__)
def _auto_fallback_draft_to_editing(svc: EditPlanService, plan_id: str, plan_check) -> None:
"""自动兜底 1: draft → editing"""
if plan_check.status == EditPlanStatus.DRAFT:
logger.info("模板编辑器自动兜底: plan=%s draft→editing", plan_id)
svc.transition_status(plan_id, EditPlanStatus.EDITING)
def _auto_fallback_copy_template_clips(svc: EditPlanService, plan_id: str, plan_check, db: Session) -> None:
"""自动兜底 2: 无片段 + 有 template_id → 从模板复制片段配置"""
existing_clips = svc.count_clips(plan_id)
if existing_clips == 0 and plan_check.template_id:
logger.info(
"模板编辑器自动兜底: plan=%s 无片段,从模板 %s 复制片段配置",
plan_id,
plan_check.template_id,
)
clip_config_repo = SQLAlchemyTemplateClipConfigRepository(db)
configs = clip_config_repo.list_by_template(plan_check.template_id)
if configs:
for cfg in configs:
svc.create_clip(
plan_id=plan_id,
clip_type=cfg.clip_type.value if hasattr(cfg.clip_type, "value") else cfg.clip_type,
order=cfg.order,
template_clip_config_id=cfg.id,
duration=cfg.default_duration,
transition_effect=(
cfg.transition_effect.value
if hasattr(cfg.transition_effect, "value")
else cfg.transition_effect
),
)
logger.info(
"模板编辑器自动兜底: plan=%s 从 template_clip_configs 复制了 %d 个片段",
plan_id,
len(configs),
)
else:
tpl_repo = SQLAlchemyTemplateRepository(db)
segments = tpl_repo.list_segments(plan_check.template_id)
for seg in segments:
avg_duration = (seg.duration_min + seg.duration_max) / 2
svc.create_clip(
plan_id=plan_id,
clip_type="main",
order=seg.segment_order,
duration=avg_duration,
config={
"material_type": seg.material_type or "",
"template_segment_id": seg.id,
},
)
logger.info(
"模板编辑器自动兜底: plan=%s 从旧模板 segments 复制了 %d 个片段",
plan_id,
len(segments),
)
def _auto_fallback_assign_assets(svc: EditPlanService, plan_id: str, plan_check) -> list:
"""自动兜底 3: 为没有素材的片段分配素材。返回剩余无素材片段列表。"""
all_clips = svc.list_clips(plan_id)
clips_without_asset = [c for c in all_clips if not c.asset_id]
config_asset_ids = (plan_check.config or {}).get("asset_ids", [])
logger.info(
"模板编辑器自动兜底3 诊断: plan=%s total_clips=%d " "clips_without_asset=%d config_asset_ids=%r",
plan_id,
len(all_clips),
len(clips_without_asset),
config_asset_ids[:5] if config_asset_ids else [],
)
if clips_without_asset and config_asset_ids:
logger.info(
"模板编辑器自动兜底3: plan=%s%d 个无素材片段分配 %d 个指定素材",
plan_id,
len(clips_without_asset),
len(config_asset_ids),
)
assigned = 0
for i, clip in enumerate(clips_without_asset):
asset_idx = i % len(config_asset_ids)
try:
svc.assign_asset(clip.id, config_asset_ids[asset_idx])
assigned += 1
except Exception as exc:
logger.error(
"模板编辑器自动兜底3: plan=%s clip=%s 分配素材 %s 失败: %s",
plan_id,
clip.id,
config_asset_ids[asset_idx],
exc,
)
logger.info(
"模板编辑器自动兜底3: plan=%s 素材分配完成 assigned=%d/%d",
plan_id,
assigned,
len(clips_without_asset),
)
# 重新检查剩余无素材片段
all_clips_after = svc.list_clips(plan_id)
clips_without_asset = [c for c in all_clips_after if not c.asset_id]
if clips_without_asset:
logger.warning(
"模板编辑器自动兜底3: plan=%s 仍有 %d 个片段无素材",
plan_id,
len(clips_without_asset),
)
elif not clips_without_asset:
logger.info("模板编辑器自动兜底3: plan=%s 所有片段已有素材,跳过", plan_id)
elif not config_asset_ids:
logger.info(
"模板编辑器自动兜底3: plan=%s config.asset_ids 为空,跳过分配",
plan_id,
)
return clips_without_asset
def _auto_fallback_auto_material_mode(
svc: EditPlanService,
plan_id: str,
plan_check,
clips_without_asset: list,
asset_library_repo: Any,
asset_repo: Any,
user_id: str = "",
) -> None:
"""自动兜底 4: 自动选素材分配给无素材片段
查找策略(按优先级):
1. plan 有 project_id → 从项目素材库查找
2. plan 无 project_id 但有 user_id → 从用户上传的素材中查找
"""
if not clips_without_asset:
return
ready_videos: list = []
source_desc = ""
# 策略 1: 通过 project_id 查找项目素材库
if plan_check.project_id:
libs = asset_library_repo.find_by_project(plan_check.project_id)
video_lib = None
for lib in libs:
lib_kind = lib.kind.value if hasattr(lib.kind, "value") else lib.kind
if lib_kind == "video":
video_lib = lib
break
if video_lib:
assets = asset_repo.find_by_library(video_lib.id)
ready_videos = [
a
for a in assets
if (a.status.value if hasattr(a.status, "value") else a.status) == "ready"
and a.mime_type
and a.mime_type.startswith("video")
]
source_desc = f"素材库 {video_lib.name}"
# 策略 2: 通过 user_id 查找用户上传的素材
if not ready_videos and user_id and hasattr(asset_repo, "find_ready_videos_by_user"):
logger.info(
"模板编辑器自动兜底4: plan=%s project_id 为空,尝试通过 user_id=%s 查找素材",
plan_id,
user_id,
)
ready_videos = asset_repo.find_ready_videos_by_user(user_id)
source_desc = f"用户上传 (user_id={user_id[:8]}...)"
if not ready_videos:
logger.warning(
"模板编辑器自动兜底4: plan=%s 未找到可用素材 (project_id=%s, user_id=%s)",
plan_id,
plan_check.project_id or "(empty)",
user_id[:8] + "..." if user_id else "(empty)",
)
return
logger.info(
"模板编辑器自动兜底4: plan=%s 自动选素材分配给 %d 个无素材片段 (来源: %s, 共 %d 个)",
plan_id,
len(clips_without_asset),
source_desc,
len(ready_videos),
)
random.shuffle(ready_videos)
for i, clip in enumerate(clips_without_asset):
asset = ready_videos[i % len(ready_videos)]
svc.assign_asset(clip.id, asset.id)
logger.info(
"模板编辑器自动兜底4: plan=%s%s 分配了 %d 个素材给 %d 个片段",
plan_id,
source_desc,
len(ready_videos),
len(clips_without_asset),
)
+30 -589
View File
@@ -15,36 +15,16 @@
from __future__ import annotations from __future__ import annotations
import json
import logging import logging
import random
import re
from app.auth import AuthenticatedUser, get_current_user from app.auth import AuthenticatedUser, get_current_user
from app.core.storage import get_storage_service from app.core.storage import get_storage_service
from app.dependencies import get_asset_repository, get_db_session from app.dependencies import get_asset_repository
from app.services.asset_segment_tracker import (
REUSE_RATIO_LIMIT,
SEGMENT_EDGE_GAP,
get_used_segments,
make_reuse_callback,
record_used_segments,
remove_used_segment,
)
from app.services.edit_plan_service import EditPlanService from app.services.edit_plan_service import EditPlanService
from app.services.edit_template_service import EditTemplateService from app.services.edit_template_service import EditTemplateService
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, status from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.orm import Session
from packages.adapters.sqlalchemy_impl.asset_repository import SQLAlchemyAssetRepository from packages.adapters.sqlalchemy_impl.asset_repository import SQLAlchemyAssetRepository
from packages.adapters.sqlalchemy_impl.template_clip_config_repository import (
SQLAlchemyTemplateClipConfigRepository,
)
from packages.adapters.sqlalchemy_impl.template_repository import (
SQLAlchemyTemplateRepository,
)
from packages.domain.plan_generator_utils import _calc_random_start_time
from packages.shared.mediakit_client import get_mediakit_client
from .dependencies import get_draft_plan_id, get_editor_services from .dependencies import get_draft_plan_id, get_editor_services
from .schemas import ( from .schemas import (
@@ -65,9 +45,6 @@ from .schemas import (
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter(tags=["Template Editor"]) router = APIRouter(tags=["Template Editor"])
# 编辑器默认片段时长(秒)
_DEFAULT_EDITOR_CLIP_DURATION = 5.0
def _clip_to_response(clip, asset_url: str | None = None) -> EditorClipResponse: def _clip_to_response(clip, asset_url: str | None = None) -> EditorClipResponse:
"""统一构造片段响应 — 与 edit_plan_clips 表字段完全对齐""" """统一构造片段响应 — 与 edit_plan_clips 表字段完全对齐"""
@@ -178,7 +155,10 @@ def list_draft_clips(
url_map = _build_asset_url_map(asset_ids, asset_repo) url_map = _build_asset_url_map(asset_ids, asset_repo)
return EditorClipListResponse( return EditorClipListResponse(
items=[_clip_to_response(c, asset_url=url_map.get(getattr(c, "asset_id", "") or "")) for c in clips], items=[
_clip_to_response(c, asset_url=url_map.get(getattr(c, "asset_id", "") or ""))
for c in clips
],
total=total, total=total,
) )
@@ -292,7 +272,9 @@ def split_draft_clip(
try: try:
result = plan_svc.split_clip(clip_id, body.split_time) result = plan_svc.split_clip(clip_id, body.split_time)
except ValueError as exc: except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)
) from exc
left = result["left_clip"] left = result["left_clip"]
right = result["right_clip"] right = result["right_clip"]
asset_ids = [getattr(left, "asset_id", "") or "", getattr(right, "asset_id", "") or ""] asset_ids = [getattr(left, "asset_id", "") or "", getattr(right, "asset_id", "") or ""]
@@ -322,7 +304,9 @@ def merge_draft_clips(
try: try:
merged = plan_svc.merge_clips(body.clip_ids) merged = plan_svc.merge_clips(body.clip_ids)
except ValueError as exc: except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)
) from exc
asset_id = getattr(merged, "asset_id", "") or "" asset_id = getattr(merged, "asset_id", "") or ""
url_map = _build_asset_url_map([asset_id], asset_repo) if asset_id else {} url_map = _build_asset_url_map([asset_id], asset_repo) if asset_id else {}
return { return {
@@ -370,583 +354,40 @@ def batch_delete_editor_clips(
return ClipBatchDeleteResponse(deleted_count=deleted, plan_id=plan_id) return ClipBatchDeleteResponse(deleted_count=deleted, plan_id=plan_id)
def _safe_segment_duration(value, default: float) -> float:
"""安全地将数据库中的时长值转换为正浮点数.
处理 None、无效类型、负数、NaN 等异常情况。
"""
if value is None:
return default
try:
result = float(value)
except (ValueError, TypeError):
return default
if result != result or result <= 0: # NaN check or non-positive
return default
return result
def _get_template_segments(
template_id: str,
tpl_svc: EditTemplateService,
db: Session,
) -> list[tuple[int, float, float]]:
"""获取模板的片段配置(顺序、最短时长、最长时长).
优先从新模板系统(template_clip_configs)查询,
若不存在则回退到旧模板系统(template_segments)。
Returns:
[(segment_order, duration_min, duration_max), ...] 按 order 排序
"""
# 优先查新模板系统
try:
clip_configs = tpl_svc.list_clip_configs(template_id)
if clip_configs:
result = []
for cc in clip_configs:
dur_min = _safe_segment_duration(cc.min_duration, _DEFAULT_EDITOR_CLIP_DURATION)
dur_max = _safe_segment_duration(
cc.max_duration or cc.min_duration,
_DEFAULT_EDITOR_CLIP_DURATION,
)
dur_min, dur_max = min(dur_min, dur_max), max(dur_min, dur_max)
result.append((cc.order, dur_min, dur_max))
return sorted(result, key=lambda x: x[0])
except Exception:
logger.warning("新模板系统查询clip_configs失败(主表可能不存在),直接查clip_configs表", exc_info=True)
# 兜底:直接查 template_clip_configs 表(片段表有 template_id 外键,不依赖模板主表)
try:
direct_repo = SQLAlchemyTemplateClipConfigRepository(db)
direct_configs = direct_repo.list_by_template(template_id)
if direct_configs:
result = []
for cc in direct_configs:
dur_min = _safe_segment_duration(cc.min_duration, _DEFAULT_EDITOR_CLIP_DURATION)
dur_max = _safe_segment_duration(
cc.max_duration or cc.min_duration,
_DEFAULT_EDITOR_CLIP_DURATION,
)
dur_min, dur_max = min(dur_min, dur_max), max(dur_min, dur_max)
result.append((cc.order, dur_min, dur_max))
return sorted(result, key=lambda x: x[0])
except Exception:
logger.warning("直接查clip_configs表也失败,继续回退旧系统", exc_info=True)
# 回退到旧模板系统(template_segments表)
try:
old_repo = SQLAlchemyTemplateRepository(db)
segments = old_repo.list_segments(template_id)
if segments:
result = []
for s in segments:
dur_min = _safe_segment_duration(s.duration_min, _DEFAULT_EDITOR_CLIP_DURATION)
dur_max = _safe_segment_duration(s.duration_max, _DEFAULT_EDITOR_CLIP_DURATION)
dur_min, dur_max = min(dur_min, dur_max), max(dur_min, dur_max)
result.append((s.segment_order, dur_min, dur_max))
return sorted(result, key=lambda x: x[0])
except Exception:
logger.warning("旧模板系统查询segments失败", exc_info=True)
return []
def _recommended_time_conflicts(
start: float,
duration: float,
used: list[tuple[float, float]],
edge_gap: float = SEGMENT_EDGE_GAP,
) -> bool:
"""检查推荐起始时间是否与已使用时间段冲突.
冲突检测统一加 ``edge_gap`` 秒边缘间隙:已用区间按 [s-gap, e+gap] 扩边后判定,
避免推荐片段与已用片段首尾紧贴导致画面观感重复。
"""
end = start + duration
for used_start, used_end in used:
if start < used_end + edge_gap and end > used_start - edge_gap:
return True
return False
def _get_mediakit_recommendations(
asset_ids: list[str],
asset_repo,
) -> dict[str, float]:
"""调用 MediaKit 视频理解,获取智能选片推荐起始时间.
尝试让 MediaKit 分析视频内容,返回每个素材的推荐起始时间。
任何异常都优雅降级,返回空字典(调用方降级到随机选择)。
"""
try:
client = get_mediakit_client()
if not client.is_available:
logger.info("MediaKit 未配置,使用随机起始时间")
return {}
storage = get_storage_service()
video_urls: list[str] = []
valid_asset_ids: list[str] = []
for asset_id in asset_ids[:10]:
asset = asset_repo.get(asset_id)
if not asset or not getattr(asset, "storage_key", None):
continue
mime = getattr(asset, "mime_type", "")
if not mime.startswith("video/"):
continue
try:
url = storage.get_download_url(asset.storage_key)
if url:
video_urls.append(url)
valid_asset_ids.append(asset_id)
except Exception as e:
logger.warning("获取素材URL失败: asset_id=%s error=%s", asset_id, e)
if not video_urls:
return {}
prompt = (
"请分析每段视频,找出最精彩的5秒片段应该从哪个时间点开始。"
"考虑因素:画面清晰度、主体是否明确、是否有明显的动作或场景变化。"
"请严格以JSON数组格式返回,不要包含其他文字:"
'[{"asset_id": "素材ID", "recommended_start_time": 12.5, "reason": "原因"}]'
)
contents = client.analyze_videos(
video_urls=video_urls,
prompt=prompt,
level="Economy",
poll_interval=2.0,
max_poll_attempts=15,
)
if not contents:
logger.info("MediaKit 分析无结果,降级为随机选择")
return {}
# 按索引映射结果:contents[i] 对应 valid_asset_ids[i]
recommendations: dict[str, float] = {}
for idx, content_text in enumerate(contents):
if idx >= len(valid_asset_ids):
break
asset_id = valid_asset_ids[idx]
if not content_text:
continue
# 尝试从文本中提取 JSON
parsed = False
# 尝试直接解析
try:
data = json.loads(content_text.strip())
if isinstance(data, list) and data:
for item in data:
if isinstance(item, dict) and "recommended_start_time" in item:
recommendations[asset_id] = float(item["recommended_start_time"])
parsed = True
break
except (json.JSONDecodeError, ValueError, TypeError):
pass
# 尝试从 markdown 代码块中提取 JSON
if not parsed:
json_match = re.search(r"\[\s*(\{.*?\})\s*\]", content_text, re.DOTALL)
if json_match:
try:
item = json.loads(json_match.group(1))
if isinstance(item, dict) and "recommended_start_time" in item:
recommendations[asset_id] = float(item["recommended_start_time"])
parsed = True
except (json.JSONDecodeError, ValueError, TypeError):
pass
# 尝试正则提取
if not parsed:
time_match = re.search(r'recommended_start_time["\s:]+([\d.]+)', content_text)
if time_match:
try:
recommendations[asset_id] = float(time_match.group(1))
except (ValueError, TypeError):
pass
if recommendations:
logger.info("MediaKit 智能选片推荐: %s", recommendations)
else:
logger.info("MediaKit 结果解析失败,降级为随机选择")
return recommendations
except Exception as e:
logger.warning("MediaKit 智能选片异常,降级为随机选择: %s", e)
return {}
@router.post("/clips/from-assets", response_model=ClipsFromAssetsResponse) @router.post("/clips/from-assets", response_model=ClipsFromAssetsResponse)
def create_clips_from_assets_editor( def create_clips_from_assets_editor(
template_id: str, template_id: str,
body: ClipsFromAssetsRequest, body: ClipsFromAssetsRequest,
background_tasks: BackgroundTasks,
plan_id: str = Depends(get_draft_plan_id), plan_id: str = Depends(get_draft_plan_id),
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services), services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
asset_repo: SQLAlchemyAssetRepository = Depends(get_asset_repository),
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user), current_user: AuthenticatedUser = Depends(get_current_user),
) -> ClipsFromAssetsResponse: ) -> ClipsFromAssetsResponse:
"""从素材批量创建片段(按模板segment配置创建,MediaKit异步更新). """从素材批量创建片段"""
_, plan_svc = services
逻辑: clips = []
1. 从模板读取 segments,片段数量 = segment 数量(忽略前端传的 required_clips_count for i, asset_id in enumerate(body.asset_ids):
2. 每个片段时长在 segment 的 duration_min ~ duration_max 之间随机取值(保留一位小数) try:
3. 素材按片段顺序轮询分配,素材不够时同一素材切多个片段 clip = plan_svc.create_clip(
4. 使用 replace_all_clips_transactional 原子性地清空旧片段并创建新的(随机起始时间) plan_id,
5. 立即返回响应(目标 <1秒) clip_type="main",
6. 后台异步任务:调用 MediaKit 智能选片并更新片段的 start_time order=body.start_order + i if hasattr(body, "start_order") else i,
7. 素材时长为 0 或缺失时报 400,不创建无效片段 duration=5.0,
""" asset_id=asset_id,
tpl_svc, plan_svc = services
# 1. 查询模板 segments
segments = _get_template_segments(template_id, tpl_svc, db)
if not segments:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="模板没有片段配置,无法创建片段",
)
# 防御:schema validator 已过滤 null/空串,这里再归一化一次,
# 避免异常入参(undefined → null)导致后续 /assets/{id} 404 / 422
asset_ids = [str(aid).strip() for aid in (body.asset_ids or []) if isinstance(aid, str) and aid.strip()]
if not asset_ids:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="素材列表为空,无法创建片段",
)
# 2. 获取素材实际时长(去重查询)
unique_asset_ids = list(dict.fromkeys(asset_ids))
asset_durations: dict[str, float] = {}
for asset_id in unique_asset_ids:
asset = asset_repo.get(asset_id)
if asset and hasattr(asset, "duration"):
asset_durations[asset_id] = float(asset.duration or 0.0)
# 3. 在内存中计算所有片段数据(使用随机起始时间,不调用MediaKit)
# 读取素材 metadata 中持久化的历史已用区间(跨任务/跨调用去重),
# 格式与 _calc_random_start_time 的 used_segments 参数一致
used_segments: dict[str, list[tuple[float, float]]] = get_used_segments(db, unique_asset_ids)
# 受控复用回调:可用区间耗尽时复用最久未用且未达复用上限(3次)的历史区间,
# 复用片段时长累加到 reused_durations 供 15% 占比控制
reused_durations: dict[str, float] = {}
# 本条成片中每个素材被分配的片段总时长(复用占比分母)
asset_assigned_durations: dict[str, float] = {}
# 受控复用回调:区间耗尽时复用最久未用且 use_count<3 的历史区间;
# 回调内部预判复用后占比是否超 15%,超限拒绝复用(返回 None)
reuse_cb = make_reuse_callback(
db,
asset_durations,
reused_durations,
assigned_tracker=asset_assigned_durations,
)
clips_data: list[dict] = []
def _reuse_ratio_exceeded(aid: str, extra: float = 0.0) -> bool:
"""该素材在本条成片中「已复用片段时长 / 已分配片段总时长」是否已超 15%
在为下一片段选素材时调用:本片段尚未分配,复用状态只在分配后的回调里
更新,因此直接检查当前占比——一旦已超 15%,该素材不再参与后续分配。
assigned=0(首个片段)放行;reused=0(尚未发生复用)时不误拦正常分配。
"""
assigned = asset_assigned_durations.get(aid, 0.0)
if assigned <= 0:
return False
return reused_durations.get(aid, 0.0) / assigned > REUSE_RATIO_LIMIT
for i, (_seg_order, dur_min, dur_max) in enumerate(segments):
# 在 segment 的 duration_min ~ duration_max 之间随机取值(保留一位小数)
raw_duration = random.uniform(dur_min, dur_max)
# 轮询分配素材:跳过时长缺失、复用占比已超 15% 阈值的素材;
# 选中后计算起点,若该素材可用区间耗尽且复用被闸门拒绝(calc 返回 None),
# 继续轮询下一个素材
asset_id = ""
clip_duration = 0.0
start_time: float | None = None
n_assets = len(asset_ids)
for offset in range(n_assets):
candidate = asset_ids[(i + offset) % n_assets]
candidate_total = asset_durations.get(candidate, 0.0)
if candidate_total <= 0:
continue
candidate_duration = min(round(raw_duration, 1), candidate_total)
if candidate_duration <= 0:
continue
if _reuse_ratio_exceeded(candidate, candidate_duration):
logger.info(
"from-assets 素材复用占比超 %.0f%% 阈值,跳过分配: asset_id=%s",
REUSE_RATIO_LIMIT * 100,
candidate,
)
continue
# 随机起始时间(不调用 MediaKit,保证接口快速返回);100 次避不开
# 历史区间时走受控复用回调(复用片段累加 reused_durations,回调内部
# 预判复用后占比超 15% 则拒绝并返回 None)
candidate_start = _calc_random_start_time(
candidate,
candidate_duration,
asset_durations,
used_segments,
on_exhausted=reuse_cb,
) )
if candidate_start is None: clips.append(clip)
# 该素材可用区间耗尽且复用被闸门/use_count 上限拒绝 → 尝试下一素材 except ValueError:
logger.info( pass
"from-assets 素材无可用可切区间(复用被拒),轮询下一素材: asset_id=%s",
candidate,
)
continue
asset_id = candidate
clip_duration = candidate_duration
start_time = candidate_start
break
if not asset_id or start_time is None:
# 所有素材时长缺失、复用占比超阈值,或区间耗尽且复用被拒 → 素材可切区间不足
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="素材可切区间不足,请补充新素材",
)
# 记录已使用时间段(内存,供本次后续片段避开)
used_segments.setdefault(asset_id, []).append((start_time, start_time + clip_duration))
asset_assigned_durations[asset_id] = asset_assigned_durations.get(asset_id, 0.0) + clip_duration
# 同步写入素材 metadata(不 commit,与下方 replace_all_clips_transactional
# 处于同一事务,任一步失败整体回滚,不留脏数据);
# 复用区间与历史记录高度重叠时 record 内部自动累加 use_count
record_used_segments(db, asset_id, start_time, start_time + clip_duration, plan_id)
clips_data.append(
{
"order": i,
"asset_id": asset_id,
"start_time": start_time,
"duration": clip_duration,
"clip_type": body.clip_type or "main",
}
)
# 4. 事务性替换:清空旧片段 → 创建新片段 → 标记ready(单事务,失败自动回滚)
created_count = plan_svc.replace_all_clips_transactional(plan_id, clips_data)
logger.info( logger.info(
"from-assets按模板创建片段(异步): template_id=%s plan_id=%s segments=%d created=%d by user=%s", "模板编辑器从素材创建片段: template_id=%s plan_id=%s count=%d by user=%s",
template_id, template_id,
plan_id, plan_id,
len(segments), len(clips),
created_count,
current_user.user.id, current_user.user.id,
) )
# 5. 触发后台任务:异步调用 MediaKit 并更新片段起始时间
background_tasks.add_task(
_update_mediakit_recommendations_async,
plan_id,
unique_asset_ids,
)
# 6. 立即返回响应
return ClipsFromAssetsResponse( return ClipsFromAssetsResponse(
created_count=created_count, created_count=len(clips),
plan_id=plan_id, plan_id=plan_id,
clip_ids=[], clip_ids=[c.id for c in clips],
) )
def _update_mediakit_recommendations_async( # pragma: no cover
plan_id: str,
asset_ids: list[str],
) -> None:
"""后台任务:调用 MediaKit 智能选片并更新片段的起始时间.
此函数在后台异步执行,不影响接口响应时间。
失败时静默处理,不影响已创建的片段。
"""
from collections import defaultdict
from packages.adapters.sqlalchemy_impl.asset_repository import SQLAlchemyAssetRepository
from packages.adapters.sqlalchemy_impl.session import SessionLocal
db = None
try:
# 复用应用全局 Session(避免每次创建新连接池导致资源泄漏)
if SessionLocal is None:
logger.warning("后台任务: SessionLocal 未初始化,跳过 MediaKit 更新")
return
db = SessionLocal()
# 初始化服务
asset_repo = SQLAlchemyAssetRepository(db)
plan_svc = EditPlanService(db)
# 调用 MediaKit 获取推荐时间
recommendations = _get_mediakit_recommendations(asset_ids, asset_repo)
if not recommendations:
logger.info("后台任务: MediaKit 无推荐结果,跳过更新")
return
# 查询该 plan 的所有片段(分批获取,避免硬编码 limit 截断)
batch_size = 500
all_clips = []
offset = 0
while True:
batch = plan_svc.list_clips(plan_id, skip=offset, limit=batch_size)
if not batch:
break
all_clips.extend(batch)
if len(batch) < batch_size:
break
offset += batch_size
clips = all_clips
if not clips:
logger.info("后台任务: plan_id=%s 无片段,跳过更新", plan_id)
return
# 批量预加载所有涉及的素材(消除 N+1 查询)
unique_asset_ids = list({getattr(c, "asset_id", "") or "" for c in clips} - {""})
assets_map: dict[str, object] = {a.id: a for a in asset_repo.find_by_ids(unique_asset_ids)}
# 按 asset_id 预分组片段时间段(消除 O(N^2) 嵌套循环)
clips_by_asset: dict[str, list[tuple[str, float, float]]] = defaultdict(list)
for clip in clips:
aid = getattr(clip, "asset_id", "") or ""
if aid and clip.start_time is not None:
clips_by_asset[aid].append((clip.id, clip.start_time, clip.start_time + clip.duration))
# 读取素材全部历史已用区间(跨任务/跨 plan 持久化记录):
# MediaKit 挪点必须与随机选片一样避让历史区间,否则会把片段挪回已用过的画面
historical_segments = get_used_segments(db, unique_asset_ids)
# 已更新的片段ID(用于排除已移动的旧时间段)
updated_clip_ids: set[str] = set()
# 已更新的时间段
updated_segments: dict[str, list[tuple[float, float]]] = {}
updated_count = 0
# 遍历片段,按 asset_id 匹配推荐时间
for clip in clips:
asset_id = getattr(clip, "asset_id", "") or ""
if not asset_id or asset_id not in recommendations:
continue
recommended_start = recommendations[asset_id]
clip_duration = clip.duration
# 从预加载字典获取素材(O(1) 查找)
asset = assets_map.get(asset_id)
if not asset:
continue
asset_total = float(getattr(asset, "duration", 0.0) or 0.0)
if asset_total <= 0:
continue
# 推荐时间 + 片段时长不能超过素材总时长
if recommended_start + clip_duration > asset_total:
logger.info(
"后台任务: 推荐时间越界,跳过: asset_id=%s recommended=%.2f duration=%.1f total=%.1f",
asset_id,
recommended_start,
clip_duration,
asset_total,
)
continue
# 构建排除当前片段及已更新片段后的占用列表(O(M),M=同素材片段数)
other_segments: list[tuple[float, float]] = [
(cs, ce)
for cid, cs, ce in clips_by_asset.get(asset_id, [])
if cid != clip.id and cid not in updated_clip_ids
]
other_segments.extend(updated_segments.get(asset_id, []))
# 并入该素材全部历史已用区间(含其他 plan/其他任务),set 去重:
# 本 plan 片段创建时已写入历史记录
# 并入该素材全部历史已用区间(含其他 plan/其他任务)。
# set 去重前先归一化精度(round 3 位),避免浮点尾差导致逻辑相同的
# 区间(如 1.0 与 1.0000000001)被误判为不同区间
def _norm(segs):
return {(round(float(a), 3), round(float(b), 3)) for a, b in segs}
other_segments = list(_norm(other_segments) | _norm(historical_segments.get(asset_id, [])))
# 检查推荐时间是否与同 plan 片段或历史已用区间冲突(含 0.3s 边缘间隙):
# 冲突时放弃该推荐、保留原随机起点(不硬挪到已用过的画面)
if _recommended_time_conflicts(recommended_start, clip_duration, other_segments):
logger.info(
"后台任务: 推荐时间与同片/历史区间冲突,保留原起点: asset_id=%s recommended=%.2f",
asset_id,
recommended_start,
)
continue
# 逐个更新并捕获异常(单点失败不影响其他片段)
try:
old_start = clip.start_time
old_end = old_start + clip_duration
# MediaKit 移动片段起点 + 同步素材 metadata 区间记录放在同一事务:
# 删旧区间记录(按 plan_id + 旧 start 匹配,兼容无 plan_id 的旧数据)、
# 写新区间,最后统一 commit;任一步失败整体 rollback
# 保证 clip.start_time 与 metadata.used_time_ranges 不出现不一致。
plan_svc.update_clip(clip.id, start_time=recommended_start)
try:
if remove_used_segment(db, asset_id, old_start, old_end, plan_id=plan_id):
record_used_segments(
db,
asset_id,
recommended_start,
recommended_start + clip_duration,
plan_id,
)
except Exception as me:
logger.warning(
"后台任务: 同步素材区间记录失败,回滚本次片段更新: clip_id=%s error=%s",
clip.id,
me,
)
db.rollback()
continue
db.commit()
updated_count += 1
updated_clip_ids.add(clip.id)
except Exception as ue:
logger.warning("后台任务: 单个片段更新失败: clip_id=%s error=%s", clip.id, ue)
try:
db.rollback()
except Exception:
pass
continue
updated_segments.setdefault(asset_id, []).append((recommended_start, recommended_start + clip_duration))
logger.info(
"后台任务: 更新片段起始时间: clip_id=%s asset_id=%s start_time=%.2f",
clip.id,
asset_id,
recommended_start,
)
logger.info("后台任务完成: plan_id=%s 成功更新 %d 个片段", plan_id, updated_count)
except Exception as e:
# 后台任务失败不影响已创建的片段,静默处理
logger.warning("后台任务异常: plan_id=%s error=%s", plan_id, e, exc_info=True)
if db:
try:
db.rollback()
except Exception:
pass
finally:
if db:
try:
db.close()
except Exception:
pass
@@ -3,6 +3,7 @@
核心依赖: 核心依赖:
- get_editor_services: 获取模板+计划服务 - get_editor_services: 获取模板+计划服务
- get_draft_plan_id: 根据 template_id 获取或创建草稿,返回 plan_id - get_draft_plan_id: 根据 template_id 获取或创建草稿,返回 plan_id
- _check_queue_limits: 生成队列限流检查
""" """
from __future__ import annotations from __future__ import annotations
@@ -10,6 +11,7 @@ from __future__ import annotations
import logging import logging
from app.auth import AuthenticatedUser, get_current_user from app.auth import AuthenticatedUser, get_current_user
from app.core.task_enqueue import GLOBAL_PENDING_LIMIT, USER_PENDING_LIMIT
from app.dependencies import get_db_session from app.dependencies import get_db_session
from app.services.edit_plan_service import EditPlanService from app.services.edit_plan_service import EditPlanService
from app.services.edit_template_service import EditTemplateService from app.services.edit_template_service import EditTemplateService
@@ -111,3 +113,29 @@ def get_draft_plan_id(
user_id, user_id,
) )
return plan.id return plan.id
def _check_queue_limits(gen_task_repo, user_id: str) -> None:
"""队列限流预检查"""
try:
has_count = (
hasattr(gen_task_repo, "count_pending_by_user")
and hasattr(gen_task_repo, "count_pending_total")
)
if has_count:
user_pending = gen_task_repo.count_pending_by_user(user_id)
global_pending = gen_task_repo.count_pending_total()
if user_pending >= USER_PENDING_LIMIT:
raise HTTPException(
status_code=429,
detail=f"您的待处理任务过多(当前 {user_pending}/{USER_PENDING_LIMIT}),请等待完成后再提交",
)
if global_pending >= GLOBAL_PENDING_LIMIT:
raise HTTPException(
status_code=503,
detail="系统繁忙,请稍后再试",
)
except HTTPException:
raise
except Exception as e:
logger.warning("[模板编辑器队列限流] 检查失败,跳过: %s", e)
@@ -17,8 +17,6 @@ from fastapi import APIRouter, Depends, HTTPException, Query, status
from .dependencies import get_draft_plan_id, get_editor_services from .dependencies import get_draft_plan_id, get_editor_services
from .schemas import ( from .schemas import (
EditorClipBatchUpdateRequest,
EditorClipBatchUpdateResponse,
EditorDraftResponse, EditorDraftResponse,
EditorPublishResponse, EditorPublishResponse,
EditorRollbackRequest, EditorRollbackRequest,
@@ -128,7 +126,11 @@ def list_template_versions(
clip_count=len(v.clip_configs), clip_count=len(v.clip_configs),
change_note=v.change_note, change_note=v.change_note,
published_by=v.published_by, published_by=v.published_by,
created_at=(v.created_at.isoformat() if hasattr(v.created_at, "isoformat") else str(v.created_at)), created_at=(
v.created_at.isoformat()
if hasattr(v.created_at, "isoformat")
else str(v.created_at)
),
) )
for v in versions for v in versions
] ]
@@ -160,35 +162,3 @@ def rollback_template(
new_version=tpl.version, new_version=tpl.version,
clip_count=len(clip_configs), clip_count=len(clip_configs),
) )
@router.put("/clips", response_model=EditorClipBatchUpdateResponse)
def batch_update_clips(
template_id: str,
req: EditorClipBatchUpdateRequest,
plan_id: str = Depends(get_draft_plan_id),
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
_: AuthenticatedUser = Depends(get_current_user),
):
"""批量替换草稿clips(全量覆盖,用于前端选择素材后同步片段)
事务保证:清空→创建→标记ready 在同一数据库事务内完成,
任何步骤失败时自动回滚,避免数据不一致。
"""
_, plan_svc = services
plan_svc.get_plan_or_raise(plan_id)
clips_data = []
for clip_item in req.clips:
item = {
"asset_id": clip_item.asset_id,
"start_time": clip_item.start_time,
"duration": clip_item.duration,
}
if clip_item.order is not None:
item["order"] = clip_item.order
clips_data.append(item)
plan_svc.replace_all_clips_transactional(plan_id, clips_data)
return EditorClipBatchUpdateResponse(plan_id=plan_id, clip_count=len(req.clips))
+332
View File
@@ -0,0 +1,332 @@
"""草稿生成路由.
端点:
- POST /generate 触发生成
- GET /generation-status 生成进度
- GET /generations 生成记录列表
"""
from __future__ import annotations
import logging
from typing import Any
from app.auth import AuthenticatedUser, get_current_user
from app.core.celery_app import celery_app
from app.core.storage import OSSStorageService, get_storage_service
from app.dependencies import (
get_asset_library_repository,
get_asset_repository,
get_db_session,
get_generated_video_repository,
)
from app.schemas.generation_task import GenerationTaskResponse
from app.services.edit_plan_service import EditPlanService
from app.services.edit_template_service import EditTemplateService
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from packages.adapters.sqlalchemy_impl.generation_task_repository import (
SQLAlchemyGenerationTaskRepository,
)
from packages.application.generated_videos import ListGeneratedVideosByTaskUseCase
from packages.application.generation_tasks import (
CreateGenerationTaskCommand,
CreateGenerationTaskUseCase,
)
from packages.domain.edit_plan import EditPlanStatus
from ._fallback import (
_auto_fallback_assign_assets,
_auto_fallback_auto_material_mode,
_auto_fallback_copy_template_clips,
_auto_fallback_draft_to_editing,
)
from .dependencies import _check_queue_limits, get_draft_plan_id, get_editor_services
from .schemas import (
ClipStatusItem,
EditPlanGenerateResponse,
EditPlanGenerationsResponse,
EditPlanGenerationStatusResponse,
)
logger = logging.getLogger(__name__)
router = APIRouter(tags=["Template Editor"])
@router.post("/generate", response_model=EditPlanGenerateResponse)
def generate_editor_draft(
template_id: str,
plan_id: str = Depends(get_draft_plan_id),
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
asset_library_repo: Any = Depends(get_asset_library_repository),
asset_repo: Any = Depends(get_asset_repository),
) -> EditPlanGenerateResponse:
"""触发模板草稿渲染生成"""
_, plan_svc = services
plan_check = plan_svc.get_plan_or_raise(plan_id)
# 自动兜底流程
_auto_fallback_draft_to_editing(plan_svc, plan_id, plan_check)
_auto_fallback_copy_template_clips(plan_svc, plan_id, plan_check, db)
clips_without_asset = _auto_fallback_assign_assets(plan_svc, plan_id, plan_check)
_auto_fallback_auto_material_mode(
plan_svc,
plan_id,
plan_check,
clips_without_asset,
asset_library_repo,
asset_repo,
user_id=str(current_user.user.id),
)
# 检查是否可复用已完成的预览产物(预览品质已与正式一致)
gen_task_repo = SQLAlchemyGenerationTaskRepository(db)
reusable_task = _find_reusable_preview_task(gen_task_repo, plan_id, plan_check)
if reusable_task:
# 复用预览产物:标记为正式产出,跳过渲染
reusable_task.mark_confirmed()
gen_task_repo.update(reusable_task)
# 将产物 URL 写入 plan config
rendered_url = _get_task_output_url(reusable_task, gen_task_repo, db)
plan_svc.update_plan_config(
plan_id,
{
"generation_task_id": reusable_task.id,
"rendered_storage_key": rendered_url, # 统一用 rendered_storage_key
},
)
plan_svc.transition_status(plan_id, EditPlanStatus.COMPLETED)
updated_plan = plan_svc.get_plan_or_raise(plan_id)
logger.info(
"模板编辑器复用预览产物: template_id=%s plan_id=%s task_id=%s by user=%s",
template_id,
plan_id,
reusable_task.id,
current_user.user.id,
)
return EditPlanGenerateResponse(
plan_id=plan_id,
plan_status=updated_plan.status.value if hasattr(updated_plan.status, "value") else updated_plan.status,
generation_task_id=reusable_task.id,
clip_count=len((plan_check.config or {}).get("clips", [])),
)
# 检查是否可生成(含最后防线自动修复 + 诊断日志)
try:
can_gen, reason = plan_svc.can_generate(plan_id)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
if not can_gen:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=reason)
try:
clip_count = plan_svc.mark_clips_ready(plan_id)
user_id = current_user.user.id
_check_queue_limits(gen_task_repo, user_id)
gen_task_use_case = CreateGenerationTaskUseCase(gen_task_repo)
plan = plan_svc.get_plan_or_raise(plan_id)
config_asset_ids = (plan.config or {}).get("asset_ids", [])
gen_task = gen_task_use_case.execute(
CreateGenerationTaskCommand(
project_id=plan.project_id or "",
template_id=plan.template_id,
created_by_user_id=current_user.user.id,
source_edit_plan_id=plan_id,
asset_ids=list(config_asset_ids) if config_asset_ids else [],
),
)
plan_svc.update_plan_config(plan_id, {"generation_task_id": gen_task.id})
plan_svc.transition_status(plan_id, EditPlanStatus.RENDERING)
celery_app.send_task("worker.render_edit_plan", args=[plan_id])
updated_plan = plan_svc.get_plan_or_raise(plan_id)
logger.info(
"模板编辑器触发生成: template_id=%s plan_id=%s gen_task_id=%s clips=%d by user=%s",
template_id,
plan_id,
gen_task.id,
clip_count,
current_user.user.id,
)
return EditPlanGenerateResponse(
plan_id=plan_id,
plan_status=updated_plan.status.value if hasattr(updated_plan.status, "value") else updated_plan.status,
generation_task_id=gen_task.id,
clip_count=clip_count,
)
except HTTPException:
raise
except Exception as _e:
logger.exception(
"模板编辑器触发生成失败: template_id=%s plan_id=%s",
template_id,
plan_id,
)
try:
plan_svc.transition_status(plan_id, EditPlanStatus.FAILED)
except Exception:
pass
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="生成失败,请稍后重试",
) from _e
def _find_reusable_preview_task(gen_task_repo, plan_id: str, plan) -> "object | None":
"""查找该 plan 关联的已完成预览任务,判断是否可复用。
复用条件:
1. 存在 source_edit_plan_id == plan_id 的已完成预览任务
2. plan 在预览完成后未被修改(updated_at <= 预览完成时间)
Returns:
可复用的 GenerationTask,或 None
"""
try:
tasks = gen_task_repo.list_by_source_edit_plan(plan_id)
except Exception:
return None
for task in tasks:
if not getattr(task, "is_preview", False):
continue
if not task.is_completed:
continue
# 检查 plan 是否在预览完成后被修改
completed_at = getattr(task, "completed_at", None)
if completed_at and hasattr(plan, "updated_at"):
plan_updated = plan.updated_at
# 如果 plan.updated_at 为空,无法判断是否修改过,跳过
if plan_updated is None:
continue
# 如果 plan 在预览完成后又被修改了,不能复用
if plan_updated > completed_at:
continue
return task
return None
def _get_task_output_url(task, gen_task_repo, db) -> str:
"""获取任务的输出视频 URL。"""
try:
video_repo = get_generated_video_repository(db)
use_case = ListGeneratedVideosByTaskUseCase(video_repo)
videos = use_case.execute(task.id)
if videos:
url = getattr(videos[0], "file_url", "") or ""
# 规范化:合并路径中的双斜杠(保留协议头 ://)
if url:
import re as _re
url = _re.sub(r"(?<!:)//", "/", url)
return url
except Exception:
pass
return ""
@router.get("/generation-status", response_model=EditPlanGenerationStatusResponse)
def get_editor_generation_status(
template_id: str,
plan_id: str = Depends(get_draft_plan_id),
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
storage_service: OSSStorageService = Depends(get_storage_service),
_: AuthenticatedUser = Depends(get_current_user),
) -> EditPlanGenerationStatusResponse:
"""查询草稿生成进度"""
_, plan_svc = services
try:
gen_status = plan_svc.get_generation_status(plan_id)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
plan = gen_status["plan"]
clips = gen_status["clips"]
clip_items = [
ClipStatusItem(
clip_id=c.id,
clip_type=c.clip_type,
order=c.order,
status=c.status.value if hasattr(c.status, "value") else c.status,
asset_id=c.asset_id or "",
text_content=c.text_content or "",
duration=c.duration,
)
for c in clips
]
raw_video_url = (plan.config or {}).get("rendered_storage_key", "") or (plan.config or {}).get("rendered_url", "")
video_url = ""
if raw_video_url:
if raw_video_url.startswith("http"):
video_url = raw_video_url # 已经是完整 URL
else:
try:
video_url = storage_service.get_url(raw_video_url) # storage_key -> 完整 URL
except Exception as e:
logger.warning("生成视频URL获取失败: template_id=%s error=%s", template_id, e)
video_url = raw_video_url
progress = gen_status.get("progress", 0.0)
error_message = gen_status.get("error_message", "")
gen_task_status = gen_status.get("generation_task_status")
plan_status_val = plan.status.value if hasattr(plan.status, "value") else plan.status
if plan_status_val == "completed" and progress < 100:
progress = 100.0
return EditPlanGenerationStatusResponse(
plan_id=plan_id,
plan_status=plan_status_val,
generation_task_id=gen_status["generation_task_id"],
generation_task_status=gen_task_status,
progress=progress,
video_url=video_url,
error_message=error_message,
clips=clip_items,
)
@router.get("/generations", response_model=EditPlanGenerationsResponse)
def list_editor_generations(
template_id: str,
plan_id: str = Depends(get_draft_plan_id),
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
db: Session = Depends(get_db_session),
_: AuthenticatedUser = Depends(get_current_user),
) -> EditPlanGenerationsResponse:
"""查询草稿关联的生成记录列表"""
_, plan_svc = services
plan_svc.get_plan_or_raise(plan_id)
gen_task_repo = SQLAlchemyGenerationTaskRepository(db)
tasks = gen_task_repo.list_by_source_edit_plan(plan_id)
items = [
GenerationTaskResponse(
id=t.id,
project_id=t.project_id,
asset_library_id=t.asset_library_id,
strategy_id=t.strategy_id,
voice_library_id=t.voice_library_id,
template_id=t.template_id,
asset_ids=t.asset_ids,
title_ids=t.title_ids,
voice_ids=t.voice_ids,
source_edit_plan_id=t.source_edit_plan_id or "",
status=t.status.value if hasattr(t.status, "value") else t.status,
progress=t.progress,
result_count=t.result_count,
error_message=t.error_message,
)
for t in tasks
]
return EditPlanGenerationsResponse(items=items, total=len(items))
@@ -8,6 +8,7 @@ from __future__ import annotations
import re as _re import re as _re
from typing import Any, List, Optional from typing import Any, List, Optional
from app.schemas.generation_task import GenerationTaskResponse
from pydantic import BaseModel, Field, validator from pydantic import BaseModel, Field, validator
_EXPORT_RESOLUTION_PATTERN = _re.compile(r"^\d+x\d+$") _EXPORT_RESOLUTION_PATTERN = _re.compile(r"^\d+x\d+$")
@@ -15,6 +16,50 @@ _EXPORT_VALID_QUALITY_PRESETS = {"ultra_fast", "fast", "balanced", "high", "best
_EXPORT_VALID_FORMATS = {"mp4", "mov"} _EXPORT_VALID_FORMATS = {"mp4", "mov"}
# ── 生成状态相关 ────────────────────────────────────────────────────────────
class ClipStatusItem(BaseModel):
"""片段生成状态"""
clip_id: str
clip_type: str
order: int
status: str
asset_id: str
text_content: str
duration: float
class EditPlanGenerationStatusResponse(BaseModel):
"""剪辑计划生成进度响应体"""
plan_id: str
plan_status: str
generation_task_id: Optional[str] = None
generation_task_status: Optional[str] = None
progress: float = 0.0
video_url: str = ""
error_message: str = ""
clips: List[ClipStatusItem]
class EditPlanGenerateResponse(BaseModel):
"""剪辑计划触发生成响应体"""
plan_id: str
plan_status: str
generation_task_id: str
clip_count: int
class EditPlanGenerationsResponse(BaseModel):
"""剪辑计划关联的生成记录列表响应体"""
items: List[GenerationTaskResponse]
total: int
# ── AI 推荐 ──────────────────────────────────────────────────────────────── # ── AI 推荐 ────────────────────────────────────────────────────────────────
@@ -22,8 +67,12 @@ class AIRecommendRequest(BaseModel):
"""AI 推荐片段方案请求体""" """AI 推荐片段方案请求体"""
asset_ids: List[str] = Field(default_factory=list, description="素材 ID 列表") asset_ids: List[str] = Field(default_factory=list, description="素材 ID 列表")
editing_mode: str = Field(default="one_take", description="剪辑模式: one_take / pip / voice_over / voice_pip") editing_mode: str = Field(
target_duration: float = Field(default=30.0, ge=1.0, le=600.0, description="目标时长(秒)") default="one_take", description="剪辑模式: one_take / pip / voice_over / voice_pip"
)
target_duration: float = Field(
default=30.0, ge=1.0, le=600.0, description="目标时长(秒)"
)
class AIRecommendClipItem(BaseModel): class AIRecommendClipItem(BaseModel):
@@ -50,6 +99,8 @@ class AIRecommendResponse(BaseModel):
confidence: float = Field(..., ge=0.0, le=1.0, description="AI 推荐置信度 (0~1)") confidence: float = Field(..., ge=0.0, le=1.0, description="AI 推荐置信度 (0~1)")
# ── BGM ──────────────────────────────────────────────────────────────────── # ── BGM ────────────────────────────────────────────────────────────────────
@@ -165,20 +216,10 @@ class ClipBatchDeleteResponse(BaseModel):
class ClipsFromAssetsRequest(BaseModel): class ClipsFromAssetsRequest(BaseModel):
"""从素材批量创建片段请求""" """从素材批量创建片段请求"""
asset_ids: List[str] = Field(..., min_length=1, max_length=200, description="素材 ID 列表,按顺序追加到时间线末尾") asset_ids: List[str] = Field(
clip_type: str = Field(default="main", description="片段类型,默认 main") ..., min_length=1, max_length=200, description="素材 ID 列表,按顺序追加到时间线末尾"
required_clips_count: Optional[int] = Field(
default=None, ge=1, le=200, description="要求创建的片段数量;不传则等于素材数量"
) )
clip_type: str = Field(default="main", description="片段类型,默认 main")
@validator("asset_ids", pre=True)
def _drop_invalid_asset_ids(cls, v): # noqa: N805
"""容错过滤:前端异常情况下可能把 undefined 序列化成 null 或空串混入
asset_ids(会直接 422 或导致后续 /assets/{id} 404),这里统一剔除。
过滤后为空时由 Field(min_length=1) / 路由层 400 兜底。"""
if not isinstance(v, list):
return v
return [x for x in v if isinstance(x, str) and x.strip()]
class ClipsFromAssetsResponse(BaseModel): class ClipsFromAssetsResponse(BaseModel):
@@ -452,28 +493,6 @@ class EditorClipUpdateRequest(BaseModel):
config: Optional[dict[str, Any]] = None config: Optional[dict[str, Any]] = None
class EditorClipBatchItem(BaseModel):
"""批量更新clips的单个片段"""
asset_id: str = Field(default="", max_length=100, description="关联素材ID,可为空(占位片段)")
start_time: float = Field(default=0.0, ge=0.0)
duration: float = Field(default=0.0, ge=0.0)
order: Optional[int] = Field(default=None, ge=0, description="排序,None表示按数组顺序")
class EditorClipBatchUpdateRequest(BaseModel):
"""批量替换clips请求(全量覆盖)"""
clips: List[EditorClipBatchItem] = Field(default_factory=list)
class EditorClipBatchUpdateResponse(BaseModel):
"""批量更新clips响应"""
plan_id: str
clip_count: int
class EditorPublishResponse(BaseModel): class EditorPublishResponse(BaseModel):
"""发布草稿响应""" """发布草稿响应"""
+56 -247
View File
@@ -3,41 +3,35 @@
from __future__ import annotations from __future__ import annotations
import logging import logging
import tempfile from typing import Optional
from pathlib import Path
from typing import Any, Optional
from app.auth import AuthenticatedUser, get_current_user from app.auth import AuthenticatedUser, get_current_user
from app.core.celery_app import celery_app from app.core.celery_app import celery_app
from app.core.storage import get_storage_service
from app.dependencies import ( from app.dependencies import (
get_asset_library_repository,
get_asset_repository,
get_audio_url_signer, get_audio_url_signer,
get_cosyvoice_service, get_cosyvoice_service,
get_db_session, get_db_session,
get_project_repository, get_user_repository,
get_voice_clone_profile_repository, get_voice_clone_profile_repository,
get_voice_library_repository,
) )
from app.schemas.tts import ( from app.schemas.tts import (
ListTTSJobResponse, ListTTSJobResponse,
SaveToLibraryRequest, SaveToLibraryRequest,
SaveToLibraryResponse, SaveToLibraryResponse,
TTSJobResponse, TTSJobResponse,
TTSPreviewRequest,
TTSPreviewResponse,
TTSStatusResponse, TTSStatusResponse,
TTSSynthesizeRequest, TTSSynthesizeRequest,
TTSSynthesizeResponse, TTSSynthesizeResponse,
) )
from fastapi import APIRouter, Depends, HTTPException, Query, Response, WebSocket, WebSocketDisconnect, status from fastapi import APIRouter, Depends, HTTPException, Query, Response, WebSocket, WebSocketDisconnect, status
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from packages.adapters.sqlalchemy_impl.tts_job_repository import ( from packages.adapters.sqlalchemy_impl.tts_job_repository import (
SQLAlchemyTTSJobRepository, SQLAlchemyTTSJobRepository,
) )
from packages.application.cosyvoice_service import CosyVoiceError, CosyVoiceService from packages.adapters.sqlalchemy_impl.voice_library_repository import SQLAlchemyVoiceLibraryRepository
from packages.application.cosyvoice_service import CosyVoiceService
from packages.application.tts_job.streaming_service import TTSStreamingService from packages.application.tts_job.streaming_service import TTSStreamingService
from packages.application.tts_job.use_cases import ( from packages.application.tts_job.use_cases import (
CreateTTSJobUseCase, CreateTTSJobUseCase,
@@ -48,12 +42,13 @@ from packages.application.tts_job.use_cases import (
TTSJobNotFoundError, TTSJobNotFoundError,
) )
from packages.application.tts_job.workflow import TTSWorkflowService from packages.application.tts_job.workflow import TTSWorkflowService
from packages.domain import Asset, AssetLibrary, AssetLibraryKind, AssetStatus, ClassificationStatus from packages.application.voice_library.commands import CreateVoiceLibraryCommand
from packages.application.voice_library.use_cases import (
CreateVoiceLibraryUseCase,
QuotaExceededError,
)
from packages.domain.voice_presets import list_voices from packages.domain.voice_presets import list_voices
from packages.ports.asset_library_repository import AssetLibraryRepository from packages.ports.user_repository import UserRepository
from packages.ports.asset_repository import AssetRepository
from packages.ports.project_repository import ProjectRepository
from packages.shared.storage import SharedStorageService
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -137,47 +132,27 @@ def synthesize(
""" """
user_id = authenticated_user.user.id user_id = authenticated_user.user.id
# 解析 voice_id:前端可能传克隆音色 profile UUID(而非 CosyVoice voice_id), # 校验 voice_clone_profile_id 归属(防止越权使用他人克隆音色)
# 与 /tts/preview 保持一致:命中 profile → 校验归属 → 取 CosyVoice voice_id if request.voice_clone_profile_id:
actual_voice_id = request.voice_id profile = voice_clone_repo.get(request.voice_clone_profile_id)
voice_clone_profile_id = request.voice_clone_profile_id if profile is None:
resolved_profile = None
if actual_voice_id:
resolved_profile = voice_clone_repo.get(actual_voice_id)
if resolved_profile is not None:
voice_clone_profile_id = actual_voice_id
# 显式传了 voice_clone_profile_id(且与 voice_id 不同)时再查一次归属
if voice_clone_profile_id and (resolved_profile is None or resolved_profile.id != voice_clone_profile_id):
resolved_profile = voice_clone_repo.get(voice_clone_profile_id)
if resolved_profile is None:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, status_code=status.HTTP_404_NOT_FOUND,
detail="Voice clone profile not found", detail="Voice clone profile not found",
) )
if profile.user_id != user_id:
if resolved_profile is not None:
if resolved_profile.user_id != user_id:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="无权访问该音色", detail="Access denied to voice clone profile",
) )
if not resolved_profile.voice_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="音色克隆尚未完成,请稍后再试",
)
# 命中克隆音色:无论 voice_id 直接传 profile UUID 还是显式传 voice_clone_profile_id
# job.voice_id 统一存解析后的 CosyVoice voice_id
actual_voice_id = resolved_profile.voice_id
use_case = CreateTTSJobUseCase(repository) use_case = CreateTTSJobUseCase(repository)
job = use_case.execute( job = use_case.execute(
user_id=user_id, user_id=user_id,
input_text=request.text, input_text=request.text,
voice_id=actual_voice_id, voice_id=request.voice_id,
voice_model=request.voice_model, voice_model=request.voice_model,
voice_clone_profile_id=voice_clone_profile_id, voice_clone_profile_id=request.voice_clone_profile_id,
metadata=request.metadata_, metadata=request.metadata_,
) )
@@ -307,62 +282,6 @@ def delete_tts_job(
return return
def _find_or_create_voice_library(
*,
user_id: str,
project_repository: ProjectRepository,
asset_library_repository: Any, # port Protocol 声明为 asyncSQLAlchemy 实现为同步,与 upload/asset_libraries 路由惯例一致用 Any
) -> AssetLibrary:
"""在用户可访问的项目中找到(或自动创建)voice 素材库。
与前端配音素材页逻辑一致:素材库挂在项目下,配音素材读取
getAssetsByKind("voice") → 用户所有可访问项目中的 voice 库。
优先使用已有 voice 库;没有则在第一个可访问项目中自动创建。
"""
projects = project_repository.find_accessible_projects(user_id)
if not projects:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="没有可用的项目,请先创建项目后再保存配音素材",
)
for project in projects:
for lib in asset_library_repository.find_by_project(project.id):
kind = lib.kind.value if hasattr(lib.kind, "value") else lib.kind
if kind == AssetLibraryKind.VOICE.value:
return lib
# 所有项目都没有 voice 库 → 在第一个可访问项目中自动创建默认配音素材库。
# asset_libraries 有 (project_id, kind) 唯一索引兜底并发:若两个请求同时创建,
# 落败方捕获 IntegrityError 回滚后重新查询,返回抢先创建成功的库。
project = projects[0]
library = AssetLibrary.create(
project_id=project.id,
name="配音素材库",
kind=AssetLibraryKind.VOICE,
)
try:
return asset_library_repository.create(library)
except IntegrityError:
# 并发下另一个请求已抢先创建:回滚当前事务(立即 commit 模式下 session 已
# 自动回滚,rollback 为幂等 no-opUoW/flush 模式下必须显式回滚才能继续查询),
# 再重查返回抢先创建成功的库。
session = getattr(asset_library_repository, "session", None)
if session is not None:
try:
session.rollback()
except Exception:
logger.warning("IntegrityError 后回滚 session 失败(可能已关闭)", exc_info=True)
for lib in asset_library_repository.find_by_project(project.id):
kind = lib.kind.value if hasattr(lib.kind, "value") else lib.kind
if kind == AssetLibraryKind.VOICE.value:
return lib
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="配音素材库创建失败,请重试",
) from None # IntegrityError 已处理,不保留异常链
@router.post( @router.post(
"/jobs/{job_id}/save-to-library", "/jobs/{job_id}/save-to-library",
response_model=SaveToLibraryResponse, response_model=SaveToLibraryResponse,
@@ -373,17 +292,13 @@ def save_tts_job_to_library(
request: SaveToLibraryRequest = SaveToLibraryRequest(), request: SaveToLibraryRequest = SaveToLibraryRequest(),
authenticated_user: AuthenticatedUser = Depends(get_current_user), authenticated_user: AuthenticatedUser = Depends(get_current_user),
tts_repository: SQLAlchemyTTSJobRepository = Depends(_get_repository), tts_repository: SQLAlchemyTTSJobRepository = Depends(_get_repository),
asset_repository: AssetRepository = Depends(get_asset_repository), voice_library_repository: SQLAlchemyVoiceLibraryRepository = Depends(get_voice_library_repository),
asset_library_repository: AssetLibraryRepository = Depends(get_asset_library_repository), user_repository: UserRepository = Depends(get_user_repository),
project_repository: ProjectRepository = Depends(get_project_repository),
storage_service: SharedStorageService = Depends(get_storage_service),
sign_url=Depends(get_audio_url_signer), sign_url=Depends(get_audio_url_signer),
) -> SaveToLibraryResponse: ) -> SaveToLibraryResponse:
"""将已完成的 TTS 合成结果保存到配音素材库(assets 表新素材体系) """将已完成的 TTS 合成结果保存到配音
流程:把 TTS 输出音频转存到用户素材 OSS 路径 → 创建 file_type=audio、 自动携带音色名、时长、语速等元信息。
status=ready 的 asset(挂用户 voice 素材库)→ 返回前端可用结构。
配额策略与素材上传一致(上传/ingest 链路无额外配额拦截)。
""" """
user_id = authenticated_user.user.id user_id = authenticated_user.user.id
@@ -401,166 +316,60 @@ def save_tts_job_to_library(
detail="TTS job is not completed yet", detail="TTS job is not completed yet",
) )
if not job.output_audio_url and not job.output_audio_key: # 构建配音素材名称
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="TTS job 缺少输出音频,无法保存",
)
# 素材名称
name = request.name or f"TTS-{job.id[:8]}" name = request.name or f"TTS-{job.id[:8]}"
# 找到(或自动创建)用户 voice 素材库 # 构建元信息
library = _find_or_create_voice_library( metadata_ = {
user_id=user_id,
project_repository=project_repository,
asset_library_repository=asset_library_repository,
)
# 转存音频到素材 OSS 路径(tts-outputs/ 下的产物归 TTS 任务所有,
# 素材独立持有副本,删除 TTS 任务不影响配音库素材)
audio_format = (job.format or "mp3").strip() or "mp3"
content_type_map = {
"mp3": "audio/mpeg",
"wav": "audio/wav",
"pcm": "audio/pcm",
"opus": "audio/opus",
}
content_type = content_type_map.get(audio_format, "audio/mpeg")
storage_key = f"uploads/voice/tts/{job.id}.{audio_format}"
tmp_path: Path | None = None
try:
with tempfile.NamedTemporaryFile(suffix=f".{audio_format}", delete=False) as tmp:
tmp_path = Path(tmp.name)
# 优先用 OSS storage_key(走 oss2 SDK,私有 bucket 也可下载);
# 兜底用 output_audio_url(旧任务可能没有 key)。
# download_asset 自动识别输入:http(s):// 开头走 HTTP 下载,否则按 OSS key 走 SDK。
download_source = job.output_audio_key or job.output_audio_url
downloaded = storage_service.download_asset(download_source, tmp_path)
if not downloaded or not tmp_path.exists() or tmp_path.stat().st_size == 0:
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail="TTS 音频下载失败,无法保存到配音库",
)
file_size = tmp_path.stat().st_size
storage_service.upload_file(tmp_path, storage_key, content_type=content_type)
except HTTPException:
raise
except Exception as e:
logger.error("TTS 音频转存素材失败: job_id=%s, error=%s", job.id, e, exc_info=True)
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail="TTS 音频转存失败,无法保存到配音库",
) from e
finally:
if tmp_path and tmp_path.exists():
try:
tmp_path.unlink()
except OSError:
pass
# 构建素材元信息
metadata_: dict[str, object] = {
"source": "tts_job", "source": "tts_job",
"tts_job_id": job.id, "tts_job_id": job.id,
"format": job.format, "format": job.format,
"sample_rate": job.sample_rate, "sample_rate": job.sample_rate,
"voice_id": job.voice_id,
"voice_name": job.voice_model or "",
} }
if job.metadata: if job.metadata:
# 保留原始 job 的有用元信息
for key in ("speed", "language"): for key in ("speed", "language"):
if key in job.metadata: if key in job.metadata:
metadata_[key] = job.metadata[key] metadata_[key] = job.metadata[key]
asset = Asset.create( # 获取用户套餐(用于配额检查)
project_id=library.project_id, user = user_repository.find_by_id(user_id)
library_id=library.id, plan_name = getattr(user, "subscription_plan", "free") if user else "free"
name=name,
storage_key=storage_key,
mime_type=content_type,
metadata=metadata_,
file_size=file_size,
duration=job.duration or None,
status=AssetStatus.READY,
classification_status=ClassificationStatus.PENDING, # 音频不参与内容分类,保持 pending 与 ingest 链路一致
uploaded_by_user_id=user_id,
)
try:
asset = asset_repository.create(asset)
except Exception as e:
# DB 写入失败:清理已上传到 OSS 的素材文件,避免产生无法索引的孤儿文件
logger.error("素材记录创建失败,清理 OSS 文件: %s, error=%s", storage_key, e, exc_info=True)
try:
storage_service.delete_file(storage_key)
except Exception:
logger.warning("清理孤儿 OSS 文件失败: %s", storage_key, exc_info=True)
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail="素材保存失败,请重试",
) from e
return SaveToLibraryResponse( # 构建命令并执行
id=asset.id, command = CreateVoiceLibraryCommand(
name=asset.name, user_id=user_id,
audio_url=sign_url(storage_key), name=name,
duration=asset.duration or 0.0, text=job.input_text,
voice_provider="cosyvoice",
voice_id=job.voice_id, voice_id=job.voice_id,
voice_name=job.voice_model or "", voice_name=job.voice_model or "",
audio_url=job.output_audio_url,
duration=job.duration,
file_size=job.file_size,
status="completed", status="completed",
project_id=job.project_id or "",
tags=[],
metadata_=metadata_,
) )
use_case = CreateVoiceLibraryUseCase(voice_library_repository)
@router.post("/preview", response_model=TTSPreviewResponse)
def preview_tts(
request: TTSPreviewRequest,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
cosyvoice_service: CosyVoiceService = Depends(get_cosyvoice_service),
voice_clone_repo=Depends(get_voice_clone_profile_repository),
) -> TTSPreviewResponse:
"""TTS 预览(试听)——同步合成,立即返回音频 URL。
用于前端预览配音效果,限制文本长度 200 字以内。
支持预设音色和克隆音色:克隆音色传的是 profile UUID,需解析为 CosyVoice voice_id。
"""
# 解析 voice_id:前端可能传 VoiceCloneProfile UUID 或预设音色 ID
actual_voice_id = request.voice_id
profile = voice_clone_repo.get(request.voice_id)
if profile is not None:
# 命中克隆音色 profile — 校验归属权限
if profile.user_id != authenticated_user.user.id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="无权访问该音色",
)
if not profile.voice_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="音色克隆尚未完成,请稍后再试",
)
actual_voice_id = profile.voice_id
try: try:
result = cosyvoice_service.synthesize_speech( item = use_case.execute(command, plan_name=plan_name or "free")
text=request.text, except QuotaExceededError as exc:
voice_id=actual_voice_id,
speed=request.speed,
)
except CosyVoiceError as e:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY, status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail=f"TTS 合成失败: {e}", detail=f"配音库配额已满({exc.used}/{exc.limit}),请升级套餐",
) from e ) from exc
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e),
) from e
return TTSPreviewResponse( return SaveToLibraryResponse(
audio_url=result.audio_url, id=item.id,
duration=result.duration if result.duration and result.duration > 0 else None, name=item.name,
audio_url=sign_url(item.audio_url) if item.audio_url else "",
duration=item.duration,
voice_id=item.voice_id,
voice_name=item.voice_name,
status=item.status,
) )
+1 -2
View File
@@ -206,7 +206,6 @@ async def complete_direct_upload(
ingest_job_id="", ingest_job_id="",
duplicated=True, duplicated=True,
asset_id=existing.id, asset_id=existing.id,
url=storage_service.get_url(normalized_key),
) )
job = _submit_ingest_job( job = _submit_ingest_job(
@@ -216,7 +215,7 @@ async def complete_direct_upload(
ingest_job_repository=ingest_job_repository, ingest_job_repository=ingest_job_repository,
file_hash=request.file_hash, file_hash=request.file_hash,
) )
return DirectUploadCompleteResponse(storage_key=normalized_key, ingest_job_id=job.id, url=storage_service.get_url(normalized_key)) return DirectUploadCompleteResponse(storage_key=normalized_key, ingest_job_id=job.id)
@router.post( @router.post(
-1
View File
@@ -52,7 +52,6 @@ def _to_video_response(item, storage: OSSStorageService | None = None) -> VideoI
generation_params=item.generation_params, generation_params=item.generation_params,
download_url=download_url, download_url=download_url,
generated_at=format_utc_datetime(item.generated_at) if hasattr(item, "generated_at") else "", generated_at=format_utc_datetime(item.generated_at) if hasattr(item, "generated_at") else "",
duplicate_rate=getattr(item, "duplicate_rate", None),
) )
+4 -58
View File
@@ -7,13 +7,7 @@ from typing import Optional
from app.auth import AuthenticatedUser, get_current_user from app.auth import AuthenticatedUser, get_current_user
from app.core.celery_app import celery_app from app.core.celery_app import celery_app
from app.core.storage import get_storage_service from app.dependencies import get_cosyvoice_service, get_voice_clone_profile_repository
from app.dependencies import (
get_asset_repository,
get_cosyvoice_service,
get_project_repository,
get_voice_clone_profile_repository,
)
from app.schemas.voice_clone import ( from app.schemas.voice_clone import (
CreateVoiceCloneRequest, CreateVoiceCloneRequest,
ListVoiceCloneResponse, ListVoiceCloneResponse,
@@ -38,9 +32,6 @@ from packages.application.voice_clone.use_cases import (
from packages.application.voice_clone.workflow import ( from packages.application.voice_clone.workflow import (
VoiceCloneWorkflowService, VoiceCloneWorkflowService,
) )
from packages.ports.asset_repository import AssetRepository
from packages.ports.project_repository import ProjectRepository
from packages.shared.storage import SharedStorageService
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -92,68 +83,23 @@ def create_voice_clone(
request: CreateVoiceCloneRequest, request: CreateVoiceCloneRequest,
authenticated_user: AuthenticatedUser = Depends(get_current_user), authenticated_user: AuthenticatedUser = Depends(get_current_user),
workflow: VoiceCloneWorkflowService = Depends(_get_workflow_service), workflow: VoiceCloneWorkflowService = Depends(_get_workflow_service),
asset_repository: AssetRepository = Depends(get_asset_repository),
project_repository: ProjectRepository = Depends(get_project_repository),
storage_service: SharedStorageService = Depends(get_storage_service),
) -> VoiceCloneProfileResponse: ) -> VoiceCloneProfileResponse:
"""创建音色克隆任务。 """创建音色克隆任务。
创建 VoiceCloneProfile → 提交 CosyVoice 克隆任务 → 触发 Celery 异步轮询。 创建 VoiceCloneProfile → 提交 CosyVoice 克隆任务 → 触发 Celery 异步轮询。
参考音频两种来源(二选一): 如果有 source_audio_url,状态会变为 processing;否则保持 pending。
- source_audio_url:前端直传后的音频 URL(兼容旧流程)
- asset_id:配音素材库中的音频素材,服务端用其 OSS storage_key 生成
预签名下载 URL(不依赖前端签名,避免签名过期导致克隆失败)
如果有参考音频,状态会变为 processing;否则保持 pending。
""" """
user_id = authenticated_user.user.id user_id = authenticated_user.user.id
source_audio_url = request.source_audio_url
clone_metadata = dict(request.metadata_ or {})
if request.asset_id:
if source_audio_url:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="asset_id 与 source_audio_url 只能传一个",
)
asset = asset_repository.find_by_id(request.asset_id)
if asset is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="素材不存在",
)
# 归属校验:素材挂在项目素材库下,用户必须能访问该项目
project = project_repository.find_by_id(asset.project_id)
if project is None or not project.can_access(user_id):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="无权使用该素材",
)
# 类型校验:仅支持音频素材
if asset.file_type != "audio":
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="仅支持音频素材进行音色克隆",
)
if not asset.storage_key:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="该素材缺少音频文件,无法用于克隆",
)
# 用 OSS storage_key 生成服务端预签名 URL(7 天有效,覆盖克隆重试周期)
source_audio_url = storage_service.get_download_url(asset.storage_key, expires_seconds=7 * 24 * 3600)
clone_metadata["source_asset_id"] = asset.id
profile = workflow.start_clone( profile = workflow.start_clone(
user_id=user_id, user_id=user_id,
name=request.name, name=request.name,
description=request.description, description=request.description,
source_audio_url=source_audio_url, source_audio_url=request.source_audio_url,
voice_model=request.voice_model, voice_model=request.voice_model,
language=request.language, language=request.language,
gender=request.gender, gender=request.gender,
max_retries=request.max_retries, max_retries=request.max_retries,
metadata=clone_metadata, metadata=request.metadata_,
) )
# 如果 profile 处于 processing 且有 task_id,触发 Celery 异步轮询 # 如果 profile 处于 processing 且有 task_id,触发 Celery 异步轮询
+3 -20
View File
@@ -53,25 +53,11 @@ class AssetResponse(BaseModel):
created_at: str created_at: str
uploaded_by_user_id: str uploaded_by_user_id: str
tag_ids: list[str] = Field(default_factory=list) tag_ids: list[str] = Field(default_factory=list)
# 片段级余量信息(仅视频素材返回,非视频/无时长记录为 None,前端按可用处理)
used_duration: float | None = Field(default=None, description="已使用片段时长(秒,历史区间合并去重后)")
available_duration: float | None = Field(default=None, description="剩余可用时长(秒)= 素材总时长 - 已用时长")
used_ratio: float | None = Field(default=None, description="已用时长占比(0~1")
usable: bool = Field(
default=True,
description="是否仍可用于新片段:零重复可切区间耗尽且所有历史区间复用次数" "use_count)均达上限时为 false",
)
MAX_BATCH_SIZE = 200 MAX_BATCH_SIZE = 200
class BatchGetRequest(BaseModel):
"""批量获取素材详情请求。"""
ids: list[str] = Field(..., min_length=1, max_length=MAX_BATCH_SIZE, description="素材 ID 列表")
class BatchDeleteRequest(BaseModel): class BatchDeleteRequest(BaseModel):
"""批量删除请求(软删除)。""" """批量删除请求(软删除)。"""
@@ -129,13 +115,10 @@ class SmartMatchRequest(BaseModel):
) )
class SmartMatchItem(AssetResponse): class SmartMatchItem(BaseModel):
"""智能选素材结果条目(扁平结构)。 """智能选素材结果条目"""
素材字段(id/usable/余量等)直接挂在条目顶层,前端拿到 item 即可读 item.id
与 AssetResponse 字段完全一致;score/breakdown 为智能匹配附加的评分字段。
"""
asset: AssetResponse
score: float = Field(..., ge=0, le=100, description="综合得分 0-100") score: float = Field(..., ge=0, le=100, description="综合得分 0-100")
breakdown: dict[str, float] = Field(default_factory=dict, description="各维度得分明细") breakdown: dict[str, float] = Field(default_factory=dict, description="各维度得分明细")
+7 -9
View File
@@ -10,7 +10,7 @@ class ConfirmGenerationRequest(BaseModel):
output_width: int = Field(default=1080, ge=100, description="输出视频宽度") output_width: int = Field(default=1080, ge=100, description="输出视频宽度")
output_height: int = Field(default=1920, ge=100, description="输出视频高度") output_height: int = Field(default=1920, ge=100, description="输出视频高度")
cover_url: str = Field(default="", description="自定义封面图片 URL") cover_url: str = Field(default="", description="自定义封面图片 URL")
custom_title: str = Field(default="", description="用户自定义标题文本,非空时同步到任务和编辑计划") custom_title: str = Field(default="", description="自定义视频标题")
class CreateGenerationTaskRequest(BaseModel): class CreateGenerationTaskRequest(BaseModel):
@@ -33,11 +33,6 @@ class CreateGenerationTaskRequest(BaseModel):
voice_ids: list[str] = Field(default_factory=list) voice_ids: list[str] = Field(default_factory=list)
# ── 来源剪辑计划 ── # ── 来源剪辑计划 ──
source_edit_plan_id: str = "" source_edit_plan_id: str = ""
# ── 标题配置(结构化)──
title_config: dict | None = Field(
default=None,
description="标题样式对象,包含 text/font/font_size/font_color/position/bold/stroke/shadow 等。为空时不影响现有行为。",
)
# ── 视频标题 ── # ── 视频标题 ──
video_title: str = Field(default="", description="生成视频的标题/名称,为空则使用默认命名") video_title: str = Field(default="", description="生成视频的标题/名称,为空则使用默认命名")
# ── 批量生成 ── # ── 批量生成 ──
@@ -45,9 +40,11 @@ class CreateGenerationTaskRequest(BaseModel):
# ── 素材库自动匹配 ── # ── 素材库自动匹配 ──
asset_select_mode: str = Field( asset_select_mode: str = Field(
default="all", default="all",
description="素材选取模式:all=全部ready视频, smart=智能匹配(按质量/时长评分)", description="素材选取模式:all=全部ready视频, random=随机选取, smart=智能匹配(按质量/时长评分)",
)
asset_select_count: int = Field(
default=0, ge=0, le=100, description="选取数量,0表示全部(仅 random/smart 模式有效)"
) )
asset_select_count: int = Field(default=0, ge=0, le=100, description="选取数量,0表示全部(仅 smart 模式有效)")
# ── 自动重试 ── # ── 自动重试 ──
auto_retry_enabled: bool = Field( auto_retry_enabled: bool = Field(
default=False, default=False,
@@ -75,6 +72,7 @@ class CreateGenerationTaskRequest(BaseModel):
output_width: int = Field(default=1280, description="输出视频宽度") output_width: int = Field(default=1280, description="输出视频宽度")
output_height: int = Field(default=720, description="输出视频高度") output_height: int = Field(default=720, description="输出视频高度")
cover_url: str = Field(default="", description="封面图片 URL") cover_url: str = Field(default="", description="封面图片 URL")
custom_title: str = Field(default="", description="自定义视频标题")
@model_validator(mode="after") @model_validator(mode="after")
def _check_at_least_one_mode(self) -> "CreateGenerationTaskRequest": def _check_at_least_one_mode(self) -> "CreateGenerationTaskRequest":
@@ -110,7 +108,7 @@ class GenerationTaskResponse(BaseModel):
output_width: int = 1280 output_width: int = 1280
output_height: int = 720 output_height: int = 720
cover_url: str = "" cover_url: str = ""
title_config: dict = Field(default_factory=dict) custom_title: str = ""
status: str status: str
progress: float progress: float
result_count: int result_count: int
-16
View File
@@ -101,19 +101,3 @@ class SaveToLibraryResponse(BaseModel):
voice_id: str voice_id: str
voice_name: str voice_name: str
status: str status: str
class TTSPreviewRequest(BaseModel):
"""TTS 预览(试听)请求。"""
text: str = Field(..., min_length=1, max_length=200, description="合成文本,限制 200 字")
voice_id: str = Field(..., min_length=1, description="音色 ID")
speed: float = Field(1.0, ge=0.5, le=2.0, description="语速")
pitch: float = Field(1.0, ge=0.5, le=2.0, description="音调(预留,当前未使用)")
class TTSPreviewResponse(BaseModel):
"""TTS 预览(试听)响应。"""
audio_url: str = Field(..., description="合成音频 URL")
duration: Optional[float] = Field(default=None, description="音频时长(秒)")
-1
View File
@@ -39,7 +39,6 @@ class DirectUploadCompleteResponse(BaseModel):
ingest_job_id: str ingest_job_id: str
duplicated: bool = Field(default=False, description="是否为重复素材(命中去重)") duplicated: bool = Field(default=False, description="是否为重复素材(命中去重)")
asset_id: str = Field(default="", description="重复素材的 asset_idduplicated=true 时返回)") asset_id: str = Field(default="", description="重复素材的 asset_idduplicated=true 时返回)")
url: str = Field(default="", description="Public URL of uploaded file")
class UploadAssetResponse(BaseModel): class UploadAssetResponse(BaseModel):
-1
View File
@@ -22,7 +22,6 @@ class VideoItemResponse(BaseModel):
generation_params: dict = Field(default_factory=dict) generation_params: dict = Field(default_factory=dict)
download_url: str | None = None download_url: str | None = None
generated_at: str = "" generated_at: str = ""
duplicate_rate: float | None = None
class ListVideosResponse(BaseModel): class ListVideosResponse(BaseModel):
+1 -2
View File
@@ -13,8 +13,7 @@ class CreateVoiceCloneRequest(BaseModel):
name: str = Field(..., min_length=1, max_length=100, description="音色名称") name: str = Field(..., min_length=1, max_length=100, description="音色名称")
description: str = Field("", description="音色描述") description: str = Field("", description="音色描述")
source_audio_url: str = Field("", description="参考音频 URL(与 asset_id 二选一)") source_audio_url: str = Field("", description="参考音频 URL")
asset_id: str = Field("", description="参考音频素材 ID(配音素材库中的音频 asset,与 source_audio_url 二选一)")
voice_model: str = Field("", description="语音模型名称") voice_model: str = Field("", description="语音模型名称")
language: str = Field("zh-CN", description="语言") language: str = Field("zh-CN", description="语言")
gender: str = Field("unknown", description="性别") gender: str = Field("unknown", description="性别")
@@ -1,494 +0,0 @@
"""素材片段级使用记录追踪与受控复用.
在素材 metadataassets.classification_result JSON)中持久化已使用的片段时间区间,
供 from-assets 创建片段时避开历史区间,实现跨任务/跨调用的片段去重;
素材可用区间耗尽后进入受控复用:允许有限次数(MAX_RANGE_USE_COUNT)复用最久未用
的历史区间,配合调用方的成片复用占比控制(MAX_REUSE_RATIO = 15%),把任意两条
成片的画面重复率控制在阈值内。
metadata 中的记录字段 ``used_time_ranges``::
"used_time_ranges": [
{
"start": 12.5, "end": 20.3,
"plan_id": "plan-xxx",
"created_at": "2026-08-29T12:00:00+00:00",
"use_count": 1, # 该区间累计被使用次数(复用一次 +1)
"last_used_at": "2026-08-29T12:00:00+00:00" # 最近一次使用时间
},
...
]
注意:本模块所有函数都不自行 commit,由调用方控制事务边界
from-assets 与 replace_all_clips_transactional 同事务;异步任务各自 commit)。
历史记录永不自动清空(自动轮回重置已下线,reset_used_segments 仅保留给运维/测试)。
"""
from __future__ import annotations
import json
import logging
from datetime import datetime, timezone
from typing import Callable
from sqlalchemy.orm import Session
from packages.adapters.sqlalchemy_impl.models import AssetModel
logger = logging.getLogger(__name__)
USED_RANGES_KEY = "used_time_ranges"
# ── 受控复用配置常量 ─────────────────────────────────────────────────────────
MAX_RANGE_USE_COUNT = 3
"""单条历史区间最多被使用次数(含首次),达到后不再参与复用。"""
REUSE_RATIO_LIMIT = 0.15
"""单条成片中,单个素材的复用片段累计时长 / 该素材在成片中的总时长上限(15%)。
超过则该素材不再分配新片段(调用方在轮询分配时跳过)。"""
SEGMENT_EDGE_GAP = 0.3
"""冲突判定边缘间隙(秒):历史区间按 [start-gap, end+gap] 扩边后参与冲突检测,
避免两条片段首尾紧贴导致画面观感重复;记录仍存实际值。"""
# 判定"新片段与历史区间为同一次使用(复用)"的重叠率阈值:
# 重叠时长 / 新区间时长超过该比例视为复用该历史区间(累加 use_count)而非新增记录。
_REUSE_OVERLAP_RATIO = 0.6
def _now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
def _read_meta(model) -> dict:
"""读取素材 metadata dict。
兼容两种对象:
- ORM ``AssetModel``metadata 以 JSON 字符串存在 ``classification_result`` 列;
- 领域实体 ``Asset``(路由层 repository 返回):metadata 直接是 dict 属性
repository 与 classification_result 互转,见 asset_repository.py)。
"""
# 领域实体:metadata 已是 dict
meta = getattr(model, "metadata", None)
if isinstance(meta, dict):
return meta
raw = getattr(model, "classification_result", None)
if not raw:
return {}
try:
data = json.loads(raw) if isinstance(raw, str) else raw
return data if isinstance(data, dict) else {}
except Exception:
return {}
def _get_model(db: Session, asset_id: str, for_update: bool = False) -> AssetModel | None:
query = db.query(AssetModel).filter(AssetModel.id == asset_id)
if for_update:
# 行级锁(PostgreSQL SELECT ... FOR UPDATE):序列化同一素材的
# classification_result 读-改-写,避免并发事务丢失使用记录。
# SQLite 不支持时 SQLAlchemy 会忽略该子句(no-op)。
query = query.with_for_update()
return query.first()
def get_used_segments(db: Session, asset_ids: list[str]) -> dict[str, list[tuple[float, float]]]:
"""聚合多个素材的历史已用片段区间。
Returns:
``{asset_id: [(start, end), ...]}`` 格式,与 ``_calc_random_start_time`` 的
``used_segments`` 参数格式一致,可直接传入。
"""
if not asset_ids:
return {}
result: dict[str, list[tuple[float, float]]] = {}
models = db.query(AssetModel).filter(AssetModel.id.in_(list(set(asset_ids)))).all()
for model in models:
meta = _read_meta(model)
ranges = meta.get(USED_RANGES_KEY) or []
segments: list[tuple[float, float]] = []
for r in ranges:
try:
segments.append((float(r["start"]), float(r["end"])))
except (KeyError, TypeError, ValueError):
continue
if segments:
result[model.id] = segments
return result
def record_used_segments(
db: Session,
asset_id: str,
start: float,
end: float,
plan_id: str,
) -> None:
"""记录一次片段使用(不 commit.
若新区间与某条历史区间高度重叠(复用场景,如受控复用回调返回的区间、
MediaKit 挪到历史区间),则累加该记录的 ``use_count`` 并刷新 ``last_used_at``
不新增记录;否则追加一条新记录(use_count=1)。
"""
# 行级锁读取:与并发生成任务互斥,保证区间记录读-改-写一致
model = _get_model(db, asset_id, for_update=True)
if model is None:
logger.warning("[片段追踪] 素材不存在,跳过记录: asset_id=%s", asset_id)
return
meta = _read_meta(model)
ranges = list(meta.get(USED_RANGES_KEY) or [])
new_start = round(float(start), 3)
new_end = round(float(end), 3)
new_dur = max(new_end - new_start, 1e-6)
now = _now_iso()
for r in ranges:
try:
rs, re_ = float(r["start"]), float(r["end"])
except (KeyError, TypeError, ValueError):
continue
overlap = max(0.0, min(new_end, re_) - max(new_start, rs))
if overlap / new_dur >= _REUSE_OVERLAP_RATIO:
# 复用同一条历史区间:累加次数、刷新时间
r["use_count"] = int(r.get("use_count", 1)) + 1
r["last_used_at"] = now
r["plan_id"] = plan_id
meta[USED_RANGES_KEY] = ranges
model.classification_result = json.dumps(meta, ensure_ascii=False)
model.updated_at = datetime.now(timezone.utc)
return
ranges.append(
{
"start": new_start,
"end": new_end,
"plan_id": plan_id,
"created_at": now,
"use_count": 1,
"last_used_at": now,
}
)
meta[USED_RANGES_KEY] = ranges
model.classification_result = json.dumps(meta, ensure_ascii=False)
model.updated_at = datetime.now(timezone.utc)
def remove_used_segment(
db: Session,
asset_id: str,
start: float,
end: float,
plan_id: str | None = None,
tolerance: float = 0.5,
) -> bool:
"""删除素材 metadata 中匹配的一条使用记录(不 commit).
匹配规则:start/end 与记录值相差不超过 tolerance 秒;plan_id 非空时,
记录有 plan_id 则需相等,记录缺 plan_id(本功能上线前的旧数据)时按时间匹配。
Returns:
是否找到并删除了记录。
"""
model = _get_model(db, asset_id)
if model is None:
return False
meta = _read_meta(model)
ranges = list(meta.get(USED_RANGES_KEY) or [])
remaining: list[dict] = []
removed = False
for r in ranges:
try:
match = (
abs(float(r["start"]) - float(start)) <= tolerance and abs(float(r["end"]) - float(end)) <= tolerance
)
except (KeyError, TypeError, ValueError):
remaining.append(r)
continue
# plan_id 校验:传入 plan_id 时,记录有 plan_id 则必须相等;
# 记录本身缺 plan_id(旧数据)时退化为按时间匹配,避免旧区间永远删不掉
if plan_id is not None and r.get("plan_id") is not None and r.get("plan_id") != plan_id:
match = False
if match and not removed:
removed = True
continue
remaining.append(r)
if removed:
meta[USED_RANGES_KEY] = remaining
model.classification_result = json.dumps(meta, ensure_ascii=False)
model.updated_at = datetime.now(timezone.utc)
return removed
def reset_used_segments(db: Session, asset_id: str) -> None:
"""清空单个素材的历史片段使用记录(不 commit).
仅供运维/测试使用;正常生成流程中历史记录永不自动清空(受控复用取代自动轮回)。
"""
model = _get_model(db, asset_id)
if model is None:
return
meta = _read_meta(model)
if meta.get(USED_RANGES_KEY):
meta[USED_RANGES_KEY] = []
model.classification_result = json.dumps(meta, ensure_ascii=False)
model.updated_at = datetime.now(timezone.utc)
logger.info("[片段追踪] 素材区间记录手动清空: asset_id=%s", asset_id)
# ── 素材余量/可用性计算(Task H:素材库角标 + smart-match 过滤)──────────────
# 判定「是否还有空闲可切区间」时使用的最小片段时长(秒):空闲段长于此值才视为可切
_MIN_FREE_CLIP_DURATION = 3.0
def _merge_intervals(intervals: list[tuple[float, float]]) -> list[tuple[float, float]]:
"""合并重叠/相接的时间区间,返回升序不重叠区间列表。"""
if not intervals:
return []
ordered = sorted((float(a), float(b)) for a, b in intervals if b > a)
merged: list[tuple[float, float]] = [ordered[0]]
for start, end in ordered[1:]:
last_start, last_end = merged[-1]
if start <= last_end:
merged[-1] = (last_start, max(last_end, end))
else:
merged.append((start, end))
return merged
def _has_free_gap(used: list[tuple[float, float]], total: float, min_free: float = _MIN_FREE_CLIP_DURATION) -> bool:
"""素材 [0, total] 中是否存在长度 ≥ min_free 的空闲段(考虑边缘间隙)。"""
if total <= 0:
return False
# 历史区间按边缘间隙扩边后判定空闲(与选片冲突检测同一口径)
expanded = [(max(0.0, s - SEGMENT_EDGE_GAP), min(total, e + SEGMENT_EDGE_GAP)) for s, e in used]
merged = _merge_intervals(expanded)
cursor = 0.0
for start, end in merged:
if start - cursor >= min_free:
return True
cursor = max(cursor, end)
return total - cursor >= min_free
def compute_asset_availability(
model: "AssetModel | None",
min_free_clip_duration: float = _MIN_FREE_CLIP_DURATION,
) -> dict | None:
"""计算单个素材的余量与可用性(纯函数,不读写 DB)。
Returns:
视频素材返回 ``{"used_duration", "available_duration", "used_ratio", "usable"}``
非视频 / 无 model / 无时长信息返回 None(调用方按可用处理,零影响)。
usable=False 条件(与受控复用机制一致):
零重复可切区间已耗尽(不存在 ≥ min_free 的空闲段)且
所有历史区间 use_count 均达 MAX_RANGE_USE_COUNT 上限(无区间可复用)。
"""
if model is None:
return None
file_type = getattr(model, "file_type", None) or getattr(model, "mime_type", "") or ""
if file_type != "video" and not str(file_type).startswith("video/"):
return None
total = float(getattr(model, "duration", 0.0) or 0.0)
if total <= 0:
return None
meta = _read_meta(model)
raw_ranges = meta.get(USED_RANGES_KEY) or []
intervals: list[tuple[float, float]] = []
use_counts: list[int] = []
for r in raw_ranges:
try:
start = float(r["start"])
end = float(r["end"])
except (KeyError, TypeError, ValueError):
continue
if end <= start:
continue
intervals.append((start, end))
try:
use_counts.append(int(r.get("use_count", 1)))
except (TypeError, ValueError):
use_counts.append(1)
merged = _merge_intervals(intervals)
used_duration = round(sum(e - s for s, e in merged), 3)
used_duration = min(used_duration, total)
available_duration = round(max(total - used_duration, 0.0), 3)
used_ratio = round(min(used_duration / total, 1.0), 4)
has_free = _has_free_gap(intervals, total, min_free_clip_duration)
if has_free:
usable = True
else:
# 空闲段耗尽:仅当存在历史区间且全部达复用上限时才判定不可用;
# 无历史区间(理论上不会走到,因为 has_free=True)按可用处理
if not use_counts:
usable = True
else:
usable = any(uc < MAX_RANGE_USE_COUNT for uc in use_counts)
return {
"used_duration": used_duration,
"available_duration": available_duration,
"used_ratio": used_ratio,
"usable": usable,
}
def find_reusable_range(
db: Session,
asset_id: str,
clip_duration: float,
asset_total: float,
*,
max_use_count: int = MAX_RANGE_USE_COUNT,
) -> tuple[float, float] | None:
"""受控复用:在素材历史区间中选一条可复用区间返回 (start, end)。
选择规则:
1. 仅选 ``use_count < max_use_count`` 的历史区间;
2. 优先返回能完整容纳当前 clip_duration(起点后不越素材边界)的最久未用区间;
3. 没有能容纳的,则返回 last_used_at 最老(或缺失 last_used_at 的旧数据优先)
且 use_count 最低的区间起点(可能与其他历史区间重叠,属降级复用);
4. 无任何可复用区间(记录为空或全部达上限)返回 None。
本函数只读不写;复用次数的累加由后续 record_used_segments 完成。
"""
model = _get_model(db, asset_id)
if model is None:
return None
meta = _read_meta(model)
ranges = [r for r in (meta.get(USED_RANGES_KEY) or []) if int(r.get("use_count", 1)) < max_use_count]
if not ranges:
return None
def _last_used(r: dict) -> str:
return str(r.get("last_used_at") or r.get("created_at") or "")
max_start = max(0.0, asset_total - clip_duration)
# 2. 能完整容纳当前片段的候选:按 last_used_at 升序(最久未用优先)
fit = sorted(
[r for r in ranges if float(r["start"]) <= max_start + 1e-6],
key=_last_used,
)
if fit:
start = min(float(fit[0]["start"]), max_start)
return (start, start + clip_duration)
# 3. 降级:最久未用 + use_count 最低的区间起点
fallback = sorted(ranges, key=lambda r: (_last_used(r), int(r.get("use_count", 1))))[0]
start = min(float(fallback["start"]), max_start)
return (start, start + clip_duration)
def make_reuse_callback(
db: Session,
asset_durations: dict[str, float],
reused_tracker: dict[str, float] | None = None,
assigned_tracker: dict[str, float] | None = None,
ratio_limit: float = REUSE_RATIO_LIMIT,
) -> Callable[[str, float], tuple[float, float] | None]:
"""构造给 ``_calc_random_start_time`` 用的受控复用回调.
Args:
db: SQLAlchemy session
asset_durations: 素材 ID -> 总时长(回调需要素材总时长做边界约束)
reused_tracker: 可选的 ``{asset_id: 累计复用时长}``,回调成功返回复用区间时
会把本次片段时长累加进去,供调用方统计成片复用占比(15% 阈值)。
assigned_tracker: 可选的 ``{asset_id: 已分配片段总时长}``,配合 ratio_limit
在复用前预判:若复用本片段后占比 (reused + clip_duration) /
(assigned + clip_duration) 超过 ratio_limit,则拒绝复用、返回 None
(保证成片复用占比不超阈值)。
ratio_limit: 单条成片复用时长占比上限,默认 15%
Returns:
回调函数 ``(asset_id, clip_duration) -> (start, end) | None``。
回调内吞掉 DB 异常返回 None,不影响主生成流程。
"""
def _reuse(asset_id: str, clip_duration: float) -> tuple[float, float] | None:
try:
total = float(asset_durations.get(asset_id, 0.0) or 0.0)
if total <= 0:
return None
# 占比闸门:预判复用本片段后是否超限(仅当调用方提供了 assigned tracker
if assigned_tracker is not None:
assigned = float(assigned_tracker.get(asset_id, 0.0) or 0.0)
reused_amt = float((reused_tracker or {}).get(asset_id, 0.0) or 0.0)
if assigned > 0 and (reused_amt + clip_duration) / (assigned + clip_duration) > ratio_limit:
logger.info(
"[片段追踪] 复用占比预判超 %.0f%% 阈值,拒绝复用: asset_id=%s "
"reused=%.1f assigned=%.1f clip=%.1f",
ratio_limit * 100,
asset_id,
reused_amt,
assigned,
clip_duration,
)
return None
result = find_reusable_range(db, asset_id, clip_duration, total)
except Exception:
logger.warning("[片段追踪] 受控复用查询异常: asset_id=%s", asset_id, exc_info=True)
return None
if result is not None and reused_tracker is not None:
reused_tracker[asset_id] = reused_tracker.get(asset_id, 0.0) + clip_duration
return result
return _reuse
def get_asset_recent_use_counts(
db: Session,
asset_ids: list[str],
recent_video_count: int = 5,
) -> dict[str, int]:
"""统计每个素材在最近 N 个不同 plan_id 中的使用次数。
遍历素材 metadata 中的 used_time_ranges,统计有多少个不同的 plan_id(去重),
返回 {asset_id: count}。只统计最近 recent_video_count 个不同 plan_id 的使用次数。
Args:
db: 数据库会话
asset_ids: 素材 ID 列表
recent_video_count: 统计最近多少个不同 plan_id
Returns:
{asset_id: 在最近 recent_video_count 个 plan 中的使用次数}
"""
if not asset_ids:
return {}
result: dict[str, int] = {}
models = db.query(AssetModel).filter(AssetModel.id.in_(asset_ids)).all()
for model in models:
meta = _read_meta(model)
ranges = meta.get(USED_RANGES_KEY) or []
if not ranges:
result[model.id] = 0
continue
# 按 created_at 倒序收集不同 plan_id
sorted_ranges = sorted(
ranges,
key=lambda r: r.get("created_at") or "",
reverse=True,
)
recent_plan_ids: set[str] = set()
for r in sorted_ranges:
plan_id = r.get("plan_id")
if plan_id:
recent_plan_ids.add(plan_id)
if len(recent_plan_ids) >= recent_video_count:
break
result[model.id] = len(recent_plan_ids)
# 未找到的素材计为 0
for aid in asset_ids:
if aid not in result:
result[aid] = 0
return result
-198
View File
@@ -9,12 +9,6 @@ from __future__ import annotations
import logging import logging
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
from app.services.asset_segment_tracker import (
REUSE_RATIO_LIMIT,
get_used_segments,
make_reuse_callback,
record_used_segments,
)
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from packages.adapters.sqlalchemy_impl import ( from packages.adapters.sqlalchemy_impl import (
@@ -377,198 +371,6 @@ class EditPlanService:
logger.info("删除所有片段: plan_id=%s count=%d", plan_id, count) logger.info("删除所有片段: plan_id=%s count=%d", plan_id, count)
return count return count
def replace_all_clips_transactional(
self,
plan_id: str,
clips_data: list[dict],
) -> int:
"""事务性地替换所有片段:清空→创建→标记ready,单事务保证原子性。
Args:
plan_id: 计划 ID
clips_data: 片段数据列表,每项包含 asset_id/start_time/duration/order
Returns:
int: 创建的片段数量
Raises:
Exception: 任何步骤失败时自动回滚
"""
from packages.adapters.sqlalchemy_impl.models import EditPlanClipModel
db = self._clip_repo.session
try:
# 1. 清空现有 clips(不 commit
deleted_count = db.query(EditPlanClipModel).filter(EditPlanClipModel.plan_id == plan_id).delete()
# 2. 批量创建新 clips(不 commit
for i, clip_item in enumerate(clips_data):
order = clip_item.get("order") or i
clip = EditPlanClip.create(
plan_id=plan_id,
clip_type=clip_item.get("clip_type", "main"),
order=order,
asset_id=clip_item.get("asset_id", ""),
start_time=clip_item.get("start_time", 0.0),
duration=clip_item.get("duration", 0.0),
)
model = EditPlanClipModel(
id=clip.id,
plan_id=clip.plan_id,
clip_type=clip.clip_type,
order=clip.order,
asset_id=clip.asset_id,
text_content=clip.text_content,
start_time=clip.start_time,
duration=clip.duration,
transition_effect=clip.transition_effect,
transition_duration=clip.transition_duration,
playback_speed=clip.playback_speed,
status=clip.status.value,
config=clip.config,
)
db.add(model)
# flush 让新建 clip 写入当前事务(未 commit),后续查询才能找到它们
db.flush()
# 3. 标记有 asset_id 的 clips 为 ready(不 commit
pending_with_asset = (
db.query(EditPlanClipModel)
.filter(
EditPlanClipModel.plan_id == plan_id,
EditPlanClipModel.status == "pending",
EditPlanClipModel.asset_id != "",
)
.all()
)
for m in pending_with_asset:
m.status = "ready"
# 4. 一次性提交
db.commit()
logger.info(
"事务性替换片段: plan_id=%s deleted=%d created=%d",
plan_id,
deleted_count,
len(clips_data),
)
return len(clips_data)
except Exception:
db.rollback()
logger.exception("事务性替换片段失败: plan_id=%s", plan_id)
raise
def clone_plan_for_variant(
self,
source_plan_id: str,
*,
created_by_user_id: str = "",
name_suffix: str = "变体",
reuse_tracker: Optional[dict] = None,
) -> EditPlan:
"""为批量/多预览场景克隆一份独立 plan,片段起点全部重算(受控随机/复用)。
复制源 plan 的模板归属、config 与片段结构(asset_id / duration / clip_type /
order 不变),每个片段重新调用 ``_calc_random_start_time``:读取素材持久化的
历史已用区间避让,耗尽时受控复用(use_count<3、最久未用),从而保证 N 条
成片片段区间互不相同,且复用占比受控。
- 不替换/不修改源 plan,源 plan 保留用户手动编辑结果。
- 片段区间记录(record_used_segments)随新片段写入素材 metadata,与新 plan
同事务;复用历史区间时由 record 自动累加 use_count。
- 克隆的 clips 复用区间累计时长写入 reuse_tracker(可选),供调用方统计占比。
Raises:
ValueError: 源 plan 不存在或无可用片段。
"""
from packages.adapters.sqlalchemy_impl.models import AssetModel
from packages.domain.plan_generator_utils import _calc_random_start_time
source = self.get_plan_or_raise(source_plan_id)
# 分页读取源 plan 全部片段
clips: List[EditPlanClip] = []
skip, page = 0, 500
while True:
batch = self._clip_repo.list_by_plan(source_plan_id, skip=skip, limit=page)
if not batch:
break
clips.extend(batch)
if len(batch) < page:
break
skip += page
if not clips:
raise ValueError(f"源 plan 无片段,无法克隆变体: {source_plan_id}")
# 创建新 plan(复制模板归属与 config
new_plan = self.create_plan(
template_id=source.template_id,
name=f"{source.name or '剪辑计划'} · {name_suffix}",
config=dict(source.config or {}),
total_duration=source.total_duration,
project_id=source.project_id or "",
created_by_user_id=created_by_user_id or (source.created_by_user_id or ""),
)
# 素材时长映射(O(N) 单查)
asset_ids = list({c.asset_id for c in clips if c.asset_id})
db = self._clip_repo.session
durations: dict[str, float] = {}
if asset_ids:
for m in db.query(AssetModel).filter(AssetModel.id.in_(asset_ids)).all():
durations[m.id] = float(getattr(m, "duration", 0.0) or 0.0)
used_segments = get_used_segments(db, asset_ids)
reused: dict[str, float] = reuse_tracker if reuse_tracker is not None else {}
asset_assigned: dict[str, float] = {}
# 回调内部预判复用后占比超 15% 则拒绝复用(calc 返回 None → 保留原起点)
reuse_cb = make_reuse_callback(db, durations, reused, assigned_tracker=asset_assigned)
clips_data: list[dict] = []
for i, c in enumerate(clips):
aid = c.asset_id
dur = float(c.duration or 0.0)
total = durations.get(aid, 0.0)
if aid and total > 0 and dur > 0:
# 复用占比闸门:本片段尚未分配,检查当前已复用占比
# reused / assigned 是否超 15%,超则该素材不再分配(保留原起点);
# assigned=0(首个片段)放行,reused=0 时不误拦正常分配
assigned = asset_assigned.get(aid, 0.0)
eff_dur = min(dur, total)
reused_amt = reused.get(aid, 0.0)
ratio_blocked = assigned > 0 and reused_amt / assigned > REUSE_RATIO_LIMIT
start = None
if not ratio_blocked:
start = _calc_random_start_time(aid, eff_dur, durations, used_segments, on_exhausted=reuse_cb)
if start is None:
start = float(c.start_time or 0.0)
asset_assigned[aid] = assigned + eff_dur
used_segments.setdefault(aid, []).append((start, start + eff_dur))
record_used_segments(db, aid, start, start + eff_dur, new_plan.id)
else:
start = float(c.start_time or 0.0)
clips_data.append(
{
"order": c.order if c.order is not None else i,
"asset_id": aid,
"start_time": start,
"duration": dur,
"clip_type": c.clip_type,
}
)
# 事务性写入新 plan 的片段(内部统一 commit/rollback
self.replace_all_clips_transactional(new_plan.id, clips_data)
logger.info(
"克隆变体 plan: source=%s new=%s clips=%d",
source_plan_id,
new_plan.id,
len(clips_data),
)
return new_plan
# ── 片段分割与合并 ────────────────────────────────────────────────────── # ── 片段分割与合并 ──────────────────────────────────────────────────────
def split_clip(self, clip_id: str, split_time: float) -> Dict[str, Any]: def split_clip(self, clip_id: str, split_time: float) -> Dict[str, Any]:
@@ -118,9 +118,9 @@ class PlanGeneratorService:
# 4. 按 editing_mode 分配素材 # 4. 按 editing_mode 分配素材
if asset_ids: if asset_ids:
# 获取素材时长信息,用于随机起始时间 # 如果是随机预览模式,获取素材时长信息
asset_durations = None asset_durations = None
if self._asset_repo: if random_preview and self._asset_repo:
asset_durations = self._fetch_asset_durations(asset_ids) asset_durations = self._fetch_asset_durations(asset_ids)
self._distribute_assets( self._distribute_assets(
clips, clips,
+40 -53
View File
@@ -52,7 +52,7 @@ type AssetListResponse = {
test.describe("Core generation flow", () => { test.describe("Core generation flow", () => {
test.describe.configure({ timeout: 360_000 }) test.describe.configure({ timeout: 360_000 })
test("walks through 6-step wizard and starts generation", async ({ page, request }) => { test("walks through 7-step wizard and starts generation", async ({ page, request }) => {
test.setTimeout(360_000) test.setTimeout(360_000)
await routeBrowserApiToTestApi(page) await routeBrowserApiToTestApi(page)
@@ -185,17 +185,15 @@ test.describe("Core generation flow", () => {
await expect(page.locator(".xx-choice-item.selected")).toBeVisible() await expect(page.locator(".xx-choice-item.selected")).toBeVisible()
await page.getByRole("button", { name: "下一步" }).click() await page.getByRole("button", { name: "下一步" }).click()
// Step 2: select material (card grid UI) // Step 2: select material
await expect(page.getByRole("heading", { name: /选择素材/ })).toBeVisible() await expect(page.getByRole("heading", { name: /选择素材/ })).toBeVisible()
const librarySelect = page.locator("select").first() const librarySelect = page.locator("select").first()
await librarySelect.selectOption({ label: libraryName }) await librarySelect.selectOption({ label: libraryName })
// 新 UI: 素材以 9:16 竖屏卡片展示,点击卡片选中 const materialLabel = page.getByText(sourceFileName).locator("..")
// 注意:卡片中心是播放按钮(stopPropagation 会阻止选中),所以点击左上角避开 await expect(materialLabel.locator("input[type='checkbox']")).toBeVisible({
const materialCard = page.getByTestId("material-card").filter({ hasText: sourceFileName }) timeout: 10_000,
await expect(materialCard).toBeVisible({ timeout: 10_000 }) })
await materialCard.click({ position: { x: 15, y: 15 } }) await materialLabel.locator("input[type='checkbox']").check()
// 验证选中:卡片应出现勾选标记(用 testid 定位,避免 ✓ 字符文本匹配不稳定)
await expect(materialCard.getByTestId("material-card-check")).toBeVisible({ timeout: 5_000 })
await page.getByRole("button", { name: "下一步" }).click() await page.getByRole("button", { name: "下一步" }).click()
// Step 3: voice (可选步骤,新注册用户无配音素材,直接跳过) // Step 3: voice (可选步骤,新注册用户无配音素材,直接跳过)
@@ -214,30 +212,42 @@ test.describe("Core generation flow", () => {
const titleText = `E2E Test ${suffix}` const titleText = `E2E Test ${suffix}`
await titleInput.fill(titleText) await titleInput.fill(titleText)
await page.getByRole("button", { name: "下一步" }).click()
// Step 4(标题+实时预览):确认生成按钮已移到标题页,点击直接创建最终渲染任务 // Step 5: preview — 需要先生成预览视频,才能进入下一步
// 等待前端实时预览就绪:未就绪时右侧 FrontendPreviewPlayer 显示「准备预览素材...」占位, await expect(page.getByRole("heading", { name: /生成预览/ })).toBeVisible({ timeout: 15000 })
// 就绪(previewReady:素材已解析 + 模板已选中)后占位消失;否则按钮会被校验拦截弹 warning // 点击"生成预览"按钮触发预览生成
await page await page.locator(".xx-preview-generate-btn").click()
.getByText("准备预览素材") // 等待预览生成完成(后端渲染,可能需要较长时间)
.waitFor({ state: "detached", timeout: 30_000 }) await expect(page.getByText("预览生成成功")).toBeVisible({ timeout: 300_000 })
.catch(() => {}) await page.getByRole("button", { name: "下一步" }).click()
// Step 6: cover (默认 AI 智能选帧模式,直接下一步)
await expect(page.getByRole("heading", { name: /选择封面/ })).toBeVisible({ timeout: 15000 })
await page.getByRole("button", { name: "下一步" }).click()
// Step 7: confirm and generate
await expect(page.getByRole("heading", { name: /确认生成/ })).toBeVisible()
// Wait for generation API to be called // Wait for generation API to be called
// 前端直接创建生成任务:POST /generation/tasks // 确认生成走新流程:POST /tasks/{taskId}/confirm(复用预览产物)
// 或旧流程:POST /editor/generate(向后兼容)
const generatePromise = page.waitForResponse( const generatePromise = page.waitForResponse(
(response) => { (response) => {
const url = response.url() const url = response.url()
const path = new URL(url).pathname const path = new URL(url).pathname
return response.request().method() === "POST" && path.endsWith("/generation/tasks") return (
response.request().method() === "POST" &&
(path.endsWith("/confirm") || path.endsWith("/editor/generate"))
)
}, },
{ timeout: 30_000 }, { timeout: 30_000 },
) )
// 点击「确认生成视频」 // Click generate button
await page.locator(".xx-btn-primary").filter({ hasText: "确认生成视频" }).first().click() await page.locator(".xx-btn-primary").filter({ hasText: "确认生成" }).first().click()
// Verify generation was triggered // Verify generation was triggered successfully
const genResp = await generatePromise const genResp = await generatePromise
if (!genResp.ok()) { if (!genResp.ok()) {
const body = await genResp.text() const body = await genResp.text()
@@ -254,42 +264,19 @@ test.describe("Core generation flow", () => {
} }
expect(genData.items.length).toBeGreaterThan(0) expect(genData.items.length).toBeGreaterThan(0)
expect(genData.items[0].id).toBeTruthy() expect(genData.items[0].id).toBeTruthy()
// Step 5: 确认生成页 — 任务创建成功后自动跳转,展示渲染进度
await expect(page.getByRole("heading", { name: /确认生成/ })).toBeVisible({
timeout: 15_000,
})
// Step 5 → Step 6:等待渲染终态
// - 完成:页面出现「视频生成完成」,步骤5「下一步」按钮解锁,点击进入封面
// - 失败:出现「生成失败」,停在确认生成页也算向导流程走通
// - 超时未终态(测试环境 worker 可能不处理任务):进度仍在轮询,同样算走通
const renderSucceeded = await page
.getByText("视频生成完成", { exact: false })
.waitFor({ timeout: 180_000 })
.then(() => true)
.catch(() => false)
if (renderSucceeded) {
// 渲染完成:手动点「下一步」进入封面步骤(渲染完不自动跳转)
await page.getByRole("button", { name: "下一步" }).click()
// Step 6: 封面(最后一步,无主按钮),仅验证页面渲染
await expect(page.getByRole("heading", { name: /选择封面/ })).toBeVisible({
timeout: 15_000,
})
} else {
// 失败或超时:仍在确认生成页(进度展示或失败提示),向导流程已完整走通
await expect(page.getByRole("heading", { name: /确认生成/ })).toBeVisible()
console.log("[E2E] 渲染任务失败或未在 180s 内完成,冒烟测试仍通过(已达确认生成页)")
}
} else { } else {
console.log(`[E2E] Generate API returned ${genResp.status()}, wizard flow test still passes`) console.log(`[E2E] Generate API returned ${genResp.status()}, wizard flow test still passes`)
// 创建失败时停留在标题页并展示错误提示
await page
.getByText(/生成失败|重新生成/)
.isVisible({ timeout: 15_000 })
.catch(() => false)
} }
// Generation may fail in test env (no worker), that's OK
// Just verify the flow started - check page shows generation-related UI
await page
.getByText(/生成中|生成完成|生成失败/)
.isVisible({ timeout: 15_000 })
.catch(() => false)
// If we see progress or result, great; if not, flow still reached the end
// which is sufficient for an E2E smoke test
// Verify product library page loads (smoke: just verify page renders) // Verify product library page loads (smoke: just verify page renders)
await page.goto("/app/products") await page.goto("/app/products")
await expect(page).toHaveURL(/\/app\/products/) await expect(page).toHaveURL(/\/app\/products/)
+42 -53
View File
@@ -37,33 +37,14 @@ async function loginWithRetry(
}) })
} }
async function registerWithRetry(
request: APIRequestContext,
email: string,
username: string,
password: string,
displayName: string,
maxRetries = 2,
) {
for (let i = 0; i <= maxRetries; i++) {
const response = await request.post(`${apiBase}/auth/register`, {
data: { email, password, username, display_name: displayName },
})
if (response.status() !== 429) return response
console.log(`[register] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`)
await new Promise((r) => setTimeout(r, 65000))
}
return request.post(`${apiBase}/auth/register`, {
data: { email, password, username, display_name: displayName },
})
}
/** 注册并登录,返回 { headers, email, username, userId } */ /** 注册并登录,返回 { headers, email, username, userId } */
async function createAuthedUser(request: APIRequestContext, label: string) { async function createAuthedUser(request: APIRequestContext, label: string) {
const email = uniqueEmail(label) const email = uniqueEmail(label)
const username = uniqueUsername(label) const username = uniqueUsername(label)
const reg = await registerWithRetry(request, email, username, PASSWORD, `E2E ${label}`) const reg = await request.post(`${apiBase}/auth/register`, {
data: { email, password: PASSWORD, username, display_name: `E2E ${label}` },
})
expect(reg.ok(), `注册应成功: ${await reg.text()}`).toBeTruthy() expect(reg.ok(), `注册应成功: ${await reg.text()}`).toBeTruthy()
const regData = await reg.json() const regData = await reg.json()
@@ -197,7 +178,7 @@ test.describe("素材库流程", () => {
expect(kinds).toContain("image") expect(kinds).toContain("image")
}) })
test("创建素材记录 — POST /assets 已废弃返回 410", async ({ request }) => { test("创建素材记录", async ({ request }) => {
const { headers, userId } = await createAuthedUser(request, "asset-create") const { headers, userId } = await createAuthedUser(request, "asset-create")
const projectId = await createProject(request, headers, Date.now().toString()) const projectId = await createProject(request, headers, Date.now().toString())
@@ -213,7 +194,7 @@ test.describe("素材库流程", () => {
expect(lib.ok()).toBeTruthy() expect(lib.ok()).toBeTruthy()
const libData = await lib.json() const libData = await lib.json()
// POST /assets 已废弃,应返回 410 Gone // 创建素材记录
const response = await request.post(`${apiBase}/assets`, { const response = await request.post(`${apiBase}/assets`, {
headers, headers,
data: { data: {
@@ -229,9 +210,16 @@ test.describe("素材库流程", () => {
}, },
}) })
expect(response.status()).toBe(410) expect(
response.ok(),
`创建素材应返回 2xx,实际: ${response.status()} ${await response.text()}`,
).toBeTruthy()
const data = await response.json() const data = await response.json()
expect(data.error?.code).toBe("HTTP_410") expect(data.id, "应返回素材 ID").toBeTruthy()
expect(data.name).toContain("test_video")
expect(data.mime_type).toBe("video/mp4")
expect(data.library_id).toBe(libData.id)
}) })
test("列出素材", async ({ request }) => { test("列出素材", async ({ request }) => {
@@ -244,50 +232,51 @@ test.describe("素材库流程", () => {
data: { data: {
project_id: projectId, project_id: projectId,
name: `List Lib ${Date.now()}`, name: `List Lib ${Date.now()}`,
kind: "image", kind: "video",
}, },
}) })
expect(lib.ok(), `创建素材库应成功: ${await lib.text()}`).toBeTruthy() expect(lib.ok(), `创建素材库应成功: ${await lib.text()}`).toBeTruthy()
const libData = await lib.json() const libData = await lib.json()
// 通过 multipart upload 上传 2 个小图片作为测试素材 // 创建 2 个素材
// 创建一个 1x1 的 PNG buffer await request.post(`${apiBase}/assets`, {
const tinyPng = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
"base64",
)
await request.post(`${apiBase}/upload`, {
headers, headers,
multipart: { data: {
project_id: projectId, project_id: projectId,
library_id: libData.id, library_id: libData.id,
file: { name: "clip_a.png", mimeType: "image/png", buffer: tinyPng }, name: `clip_a_${Date.now()}.mp4`,
storage_key: `uploads/e2e/clip_a.mp4`,
mime_type: "video/mp4",
status: "ready",
uploaded_by_user_id: userId,
}, },
}) })
await request.post(`${apiBase}/upload`, { await request.post(`${apiBase}/assets`, {
headers, headers,
multipart: { data: {
project_id: projectId, project_id: projectId,
library_id: libData.id, library_id: libData.id,
file: { name: "clip_b.png", mimeType: "image/png", buffer: tinyPng }, name: `clip_b_${Date.now()}.mp4`,
storage_key: `uploads/e2e/clip_b.mp4`,
mime_type: "video/mp4",
status: "ready",
uploaded_by_user_id: userId,
}, },
}) })
// 列出素材(可能需要等待 ingest job 完成) // 列出素材
let items: any[] = [] const response = await request.get(`${apiBase}/assets`, {
for (let i = 0; i < 10; i++) { headers,
const response = await request.get(`${apiBase}/assets`, { params: { library_id: libData.id },
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) expect(items.length, "应至少有 2 个素材").toBeGreaterThanOrEqual(2)
}) })
-10
View File
@@ -12,7 +12,6 @@
"@tanstack/react-query": "^5.45.0", "@tanstack/react-query": "^5.45.0",
"antd": "^5.18.0", "antd": "^5.18.0",
"axios": "^1.7.2", "axios": "^1.7.2",
"mp4box": "^2.4.1",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-router-dom": "^6.24.0", "react-router-dom": "^6.24.0",
@@ -4624,15 +4623,6 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/mrmime": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz",
-1
View File
@@ -23,7 +23,6 @@
"@tanstack/react-query": "^5.45.0", "@tanstack/react-query": "^5.45.0",
"antd": "^5.18.0", "antd": "^5.18.0",
"axios": "^1.7.2", "axios": "^1.7.2",
"mp4box": "^2.4.1",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-router-dom": "^6.24.0", "react-router-dom": "^6.24.0",
+15 -31
View File
@@ -52,40 +52,24 @@ export const getAssetsByKind = async (
/** /**
* 智能匹配素材(后端 AI 选素材) * 智能匹配素材(后端 AI 选素材)
* 调用后端 smart-match 端点,由后端根据素材库内容智能选择素材 * 调用后端 smart-match 端点,由后端根据素材库内容智能选择素材
*
* 后端返回 items 元素兼容两种结构(过渡期):
* - 扁平结构:AssetItem 本身(id 在顶层)
* - 包装结构:{ asset: AssetItem, score, breakdown }id 需从 .asset 取)
* 这里统一归一化为 AssetItem[],调用方无需关心包装层。
*/ */
export interface SmartMatchResult { export const smartMatchAssets = async (libraryId: string): Promise<{ items: AssetItem[] }> => {
items: AssetItem[] const response = await apiClient.post("/assets/smart-match", {
library_id: libraryId,
})
return response.data
} }
interface SmartMatchWrappedItem { /** 创建素材(上传文件后调用,附带 metadata) */
asset?: AssetItem export const createAsset = async (data: {
id?: string library_id: string
score?: number name: string
breakdown?: unknown storage_key: string
} mime_type: string
metadata?: AssetMetadata
export const smartMatchAssets = async ( }): Promise<AssetItem> => {
libraryId: string, const response = await apiClient.post("/assets", data)
limit?: number, return response.data
): Promise<SmartMatchResult> => {
const payload: Record<string, unknown> = { library_id: libraryId }
if (limit && limit > 0) payload.limit = limit
const response = await apiClient.post("/assets/smart-match", payload)
const rawItems: SmartMatchWrappedItem[] = response.data?.items ?? []
const items = rawItems
.map((it) =>
// 包装结构 { asset: {...} } 优先解包;否则视其本身为扁平 AssetItem
it?.asset && typeof it.asset === "object" && "id" in it.asset
? it.asset
: (it as unknown as AssetItem),
)
.filter((it): it is AssetItem => !!it && typeof it.id === "string" && it.id.length > 0)
return { items }
} }
/** 更新素材(名称、metadata 等) */ /** 更新素材(名称、metadata 等) */
+2 -7
View File
@@ -2,17 +2,12 @@
* 素材诊断 API * 素材诊断 API
*/ */
import apiClient from "../client" import apiClient from "../client"
import { getOrCreateDefaultProject } from "../projects"
import type { AssetDiagnosis } from "./types" import type { AssetDiagnosis } from "./types"
/** 获取素材诊断信息(可选 asset_id 查单素材,否则全局诊断) */ /** 获取素材诊断信息(可选 asset_id 查单素材,否则全局诊断) */
export const getAssetDiagnosis = async ( export const getAssetDiagnosis = async (assetId?: string): Promise<AssetDiagnosis> => {
assetId?: string,
projectId?: string,
): Promise<AssetDiagnosis> => {
const pid = projectId ?? (await getOrCreateDefaultProject()).id
const params: Record<string, string> = {} const params: Record<string, string> = {}
if (assetId) params.asset_id = assetId if (assetId) params.asset_id = assetId
const response = await apiClient.get(`/projects/${pid}/asset-diagnosis`, { params }) const response = await apiClient.get("/asset-diagnosis", { params })
return response.data return response.data
} }
+3 -11
View File
@@ -13,6 +13,7 @@ export type {
ClassificationJob, ClassificationJob,
AssetDiagnosis, AssetDiagnosis,
BatchOperationResult, BatchOperationResult,
UploadResult,
DirectUploadPrepareResult, DirectUploadPrepareResult,
DirectUploadCompleteResult, DirectUploadCompleteResult,
} from "./types" } from "./types"
@@ -20,10 +21,6 @@ export type {
// 素材诊断 // 素材诊断
export { getAssetDiagnosis } from "./diagnosis" export { getAssetDiagnosis } from "./diagnosis"
// 素材余量/可用性判断
export { isAssetUsable } from "./usage"
export type { AssetUsageLike } from "./usage"
// 素材库 // 素材库
export { export {
getAssetLibraries, getAssetLibraries,
@@ -37,19 +34,14 @@ export {
getAssets, getAssets,
getAssetsByKind, getAssetsByKind,
smartMatchAssets, smartMatchAssets,
createAsset,
updateAsset, updateAsset,
updateAssetReviewStatus, updateAssetReviewStatus,
deleteAsset, deleteAsset,
} from "./assets" } from "./assets"
// 上传 // 上传
export { export { uploadAsset, prepareDirectUpload, completeDirectUpload, uploadAssetDirect } from "./upload"
prepareDirectUpload,
completeDirectUpload,
uploadAssetDirect,
prepareDirectUploadHandle,
type DirectUploadHandle,
} from "./upload"
// 任务 // 任务
export { getIngestJob, submitClassificationJob, getClassificationJob } from "./jobs" export { getIngestJob, submitClassificationJob, getClassificationJob } from "./jobs"
-15
View File
@@ -40,10 +40,6 @@ export interface AssetItem {
thumbnail_url?: string thumbnail_url?: string
/** 时长(秒),视频/音频素材由后端从 metadata 提取到顶层 */ /** 时长(秒),视频/音频素材由后端从 metadata 提取到顶层 */
duration?: number duration?: number
/** 已切片段占用时长占比(0~1,后端片段重复率控制机制返回;字段缺失视为未统计) */
used_ratio?: number | null
/** 是否已彻底用尽(无新区间且历史区间复用次数均达上限);false 的素材不参与生成选片 */
usable?: boolean | null
status?: string status?: string
classification_status?: AssetClassificationStatus | null classification_status?: AssetClassificationStatus | null
quality_score?: number | null quality_score?: number | null
@@ -133,21 +129,10 @@ export interface DirectUploadPrepareResult {
expires_at: string expires_at: string
fields: Record<string, string> fields: Record<string, string>
max_size_bytes: number max_size_bytes: number
/**
* prepare 阶段预创建的素材记录 id(后端改造后返回:status=uploading)。
* 前端拿到后立即刷新列表,卡片以「上传中」态出现在素材网格中。
* 旧后端不返回该字段,前端降级为无预建卡片的原有行为。
*/
asset_id?: string
} }
/** 直传完成确认返回 */ /** 直传完成确认返回 */
export interface DirectUploadCompleteResult { export interface DirectUploadCompleteResult {
storage_key: string storage_key: string
ingest_job_id: string ingest_job_id: string
url: string
/** 同库已存在相同 file_hash 的素材时为 trueingest_job_id 为空 */
duplicated?: boolean
/** duplicated 为 true 时返回已存在素材的 id */
asset_id?: string
} }
+35 -60
View File
@@ -3,7 +3,16 @@
*/ */
import apiClient from "../client" import apiClient from "../client"
import { getOrCreateDefaultProject } from "../projects" import { getOrCreateDefaultProject } from "../projects"
import type { DirectUploadPrepareResult, DirectUploadCompleteResult } from "./types" import type { UploadResult, DirectUploadPrepareResult, DirectUploadCompleteResult } from "./types"
/** 表单上传素材(小文件) */
export const uploadAsset = async (formData: FormData): Promise<UploadResult> => {
const response = await apiClient.post("/upload", formData, {
headers: { "Content-Type": "multipart/form-data" },
timeout: 30 * 60 * 1000,
})
return response.data
}
/** 预签名直传准备 */ /** 预签名直传准备 */
export const prepareDirectUpload = async (data: { export const prepareDirectUpload = async (data: {
@@ -27,18 +36,28 @@ export const completeDirectUpload = async (data: {
return response.data return response.data
} }
/** 直传 OSS 的底层传输(POST 表单到 OSS),带进度回调 */ /** 直传上传(大文件推荐),支持可选进度回调 */
const putToOSS = ( export const uploadAssetDirect = async (data: {
prepared: DirectUploadPrepareResult, file: File
file: File, library_id: string
onProgress?: (percent: number) => void, onProgress?: (percent: number) => void
): Promise<void> => }): Promise<DirectUploadCompleteResult> => {
new Promise<void>((resolve, reject) => { const project = await getOrCreateDefaultProject()
const directForm = new FormData()
Object.entries(prepared.fields).forEach(([key, value]) => directForm.append(key, value))
directForm.append("file", file)
// 使用 XMLHttpRequest 以获取上传进度 + 超时控制 + 详细错误诊断 const prepared = await prepareDirectUpload({
project_id: project.id,
library_id: data.library_id,
filename: data.file.name,
content_type: data.file.type || "application/octet-stream",
file_size: data.file.size,
})
const directForm = new FormData()
Object.entries(prepared.fields).forEach(([key, value]) => directForm.append(key, value))
directForm.append("file", data.file)
// 使用 XMLHttpRequest 以获取上传进度 + 超时控制 + 详细错误诊断
await new Promise<void>((resolve, reject) => {
const xhr = new XMLHttpRequest() const xhr = new XMLHttpRequest()
xhr.open(prepared.method, prepared.upload_url) xhr.open(prepared.method, prepared.upload_url)
@@ -46,8 +65,8 @@ const putToOSS = (
xhr.timeout = 10 * 60 * 1000 xhr.timeout = 10 * 60 * 1000
xhr.upload.onprogress = (e) => { xhr.upload.onprogress = (e) => {
if (e.lengthComputable && onProgress) { if (e.lengthComputable && data.onProgress) {
onProgress(Math.round((e.loaded / e.total) * 100)) data.onProgress(Math.round((e.loaded / e.total) * 100))
} }
} }
xhr.onload = () => { xhr.onload = () => {
@@ -92,53 +111,9 @@ const putToOSS = (
xhr.send(directForm) xhr.send(directForm)
}) })
/** 单个文件的上传阶段信息(供批量上传队列做状态绑定) */ return completeDirectUpload({
export interface DirectUploadHandle {
/** prepare 返回(含可能的预建 asset_id */
prepared: DirectUploadPrepareResult
/** 直传 OSS(可重复调用用于重试) */
transfer: (onProgress?: (percent: number) => void) => Promise<void>
/** 直传完成后调用 complete 确认入库 */
complete: () => Promise<DirectUploadCompleteResult>
}
/**
* 准备一次直传:调 prepare 拿到签名表单(后端可能同时预建 uploading 态 asset),
* 返回分段执行的 handle,调用方自行控制 transfer/complete 时机(便于队列并发与重试)。
*/
export const prepareDirectUploadHandle = async (data: {
file: File
library_id: string
}): Promise<DirectUploadHandle> => {
const project = await getOrCreateDefaultProject()
const prepared = await prepareDirectUpload({
project_id: project.id, project_id: project.id,
library_id: data.library_id, library_id: data.library_id,
filename: data.file.name, storage_key: prepared.storage_key,
content_type: data.file.type || "application/octet-stream",
file_size: data.file.size,
}) })
return {
prepared,
transfer: (onProgress) => putToOSS(prepared, data.file, onProgress),
complete: () =>
completeDirectUpload({
project_id: project.id,
library_id: data.library_id,
storage_key: prepared.storage_key,
}),
}
}
/** 直传上传(大文件推荐),支持可选进度回调;一次性完成 prepare→transfer→complete */
export const uploadAssetDirect = async (data: {
file: File
library_id: string
onProgress?: (percent: number) => void
}): Promise<DirectUploadCompleteResult> => {
const handle = await prepareDirectUploadHandle({ file: data.file, library_id: data.library_id })
await handle.transfer(data.onProgress)
return handle.complete()
} }
-18
View File
@@ -1,18 +0,0 @@
/**
* 素材余量/可用性判断
* 后端片段重复率控制机制(任意两条成片画面重复率 ≤15%)上线后,
* 素材列表会附加 usable / used_ratio 字段。字段未上线前一律按可用处理。
*/
/** 仅依赖素材余量相关字段的最小结构,api 层与 pages 层 AssetItem 均可传入 */
export interface AssetUsageLike {
usable?: boolean | null
used_ratio?: number | null
}
/**
* 素材是否仍可参与生成选片。
* usable === false 表示已彻底用尽(无新区间且复用次数全部达上限);
* 字段缺失(undefined/null)时降级为可用,保证后端字段上线前零影响。
*/
export const isAssetUsable = (asset: AssetUsageLike): boolean => asset.usable !== false
+21 -48
View File
@@ -9,9 +9,6 @@ import { refreshAccessToken } from "./login"
let refreshTimer: ReturnType<typeof setTimeout> | null = null let refreshTimer: ReturnType<typeof setTimeout> | null = null
/** 正在执行刷新操作的 Promise,防止主动刷新和 401 被动刷新并发竞争 */
let activeRefreshPromise: Promise<void> | null = null
/** 提前刷新的缓冲时间(秒) */ /** 提前刷新的缓冲时间(秒) */
const REFRESH_BUFFER_SECONDS = 60 const REFRESH_BUFFER_SECONDS = 60
@@ -42,55 +39,14 @@ export function cancelProactiveRefresh(): void {
} }
} }
/**
* 执行 token 刷新(带并发锁,供主动刷新和被动 401 共用)
* 返回当前刷新操作的 Promise;若已有刷新进行中则复用该 Promise。
*/
export function executeTokenRefresh(): Promise<void> | null {
// 已有刷新进行中 → 复用
if (activeRefreshPromise) {
return activeRefreshPromise
}
const { user, refreshToken: refreshTokenValue } = useAuthStore.getState()
// 安全检查:user 或 refreshToken 为空时跳过刷新
if (!user || !refreshTokenValue) {
return null
}
activeRefreshPromise = (async () => {
try {
const data = await refreshAccessToken(refreshTokenValue)
const newAccessToken = data.access_token
const newRefreshToken = data.refresh_token ?? refreshTokenValue
// 更新 Zustand store + localStorage
useAuthStore.getState().setAuth(user, newAccessToken, newRefreshToken)
// 递归调度下一次刷新
scheduleProactiveRefresh()
} catch {
// 刷新失败 → 清除认证状态,跳转登录页
cancelProactiveRefresh()
useAuthStore.getState().clearAuth()
window.location.href = "/login"
} finally {
activeRefreshPromise = null
}
})()
return activeRefreshPromise
}
/** /**
* 调度主动刷新:在 token 过期前 REFRESH_BUFFER_SECONDS 秒自动刷新 * 调度主动刷新:在 token 过期前 REFRESH_BUFFER_SECONDS 秒自动刷新
*/ */
export function scheduleProactiveRefresh(): void { export function scheduleProactiveRefresh(): void {
cancelProactiveRefresh() cancelProactiveRefresh()
// 统一从 Zustand store 读取(与 setAuth 写入保持一致) const accessToken = localStorage.getItem("access_token")
const { accessToken, refreshToken: refreshTokenValue } = useAuthStore.getState() const refreshTokenValue = useAuthStore.getState().refreshToken
if (!accessToken || !refreshTokenValue) return if (!accessToken || !refreshTokenValue) return
@@ -103,7 +59,24 @@ export function scheduleProactiveRefresh(): void {
// 如果 token 已经过期或即将在缓冲时间内过期,立即刷新 // 如果 token 已经过期或即将在缓冲时间内过期,立即刷新
const delaySeconds = Math.max(secondsUntilExpiry - REFRESH_BUFFER_SECONDS, 0) const delaySeconds = Math.max(secondsUntilExpiry - REFRESH_BUFFER_SECONDS, 0)
refreshTimer = setTimeout(() => { refreshTimer = setTimeout(async () => {
executeTokenRefresh() try {
const data = await refreshAccessToken(refreshTokenValue)
const newAccessToken = data.access_token
const newRefreshToken = data.refresh_token ?? refreshTokenValue
// 更新 Zustand store + localStorage
useAuthStore
.getState()
.setAuth(useAuthStore.getState().user!, newAccessToken, newRefreshToken)
// 递归调度下一次刷新
scheduleProactiveRefresh()
} catch {
// 刷新失败 → 清除认证状态,跳转登录页
cancelProactiveRefresh()
useAuthStore.getState().clearAuth()
window.location.href = "/login"
}
}, delaySeconds * 1000) }, delaySeconds * 1000)
} }
+3 -12
View File
@@ -4,20 +4,11 @@
import apiClient from "../client" import apiClient from "../client"
import type { BgmPreset, BgmPresetsQuery } from "./types" import type { BgmPreset, BgmPresetsQuery } from "./types"
/** /** 获取 BGM 预设列表 */
* 获取 BGM 预设列表 export const getBgmPresets = async (params?: BgmPresetsQuery): Promise<BgmPreset[]> => {
* @param templateId 模板/草稿 ID
* @param params 分类/关键词筛选
*/
export const getBgmPresets = async (
templateId: string,
params?: BgmPresetsQuery,
): Promise<BgmPreset[]> => {
const searchParams: Record<string, string> = {} const searchParams: Record<string, string> = {}
if (params?.category) searchParams.category = params.category if (params?.category) searchParams.category = params.category
if (params?.keyword) searchParams.keyword = params.keyword if (params?.keyword) searchParams.keyword = params.keyword
const res = await apiClient.get(`/templates/${templateId}/editor/bgm/presets`, { const res = await apiClient.get("/bgm/presets", { params: searchParams })
params: searchParams,
})
return res.data?.data ?? res.data ?? [] return res.data?.data ?? res.data ?? []
} }
+12 -17
View File
@@ -5,8 +5,8 @@
import axios, { AxiosError, InternalAxiosRequestConfig } from "axios" import axios, { AxiosError, InternalAxiosRequestConfig } from "axios"
import { message } from "antd" import { message } from "antd"
import { useAuthStore } from "@/store/authStore" import { useAuthStore } from "@/store/authStore"
import { refreshAccessToken } from "./auth"
import { cancelProactiveRefresh, executeTokenRefresh } from "./auth/tokenRefresh" import { scheduleProactiveRefresh, cancelProactiveRefresh } from "./auth/tokenRefresh"
// 创建 Axios 实例 // 创建 Axios 实例
const apiClient = axios.create({ const apiClient = axios.create({
@@ -98,26 +98,21 @@ apiClient.interceptors.response.use(
isRefreshing = true isRefreshing = true
try { try {
// 使用共享的刷新函数(带并发锁 + 安全检查) const data = await refreshAccessToken(refreshToken)
const refreshPromise = executeTokenRefresh() const newAccessToken = data.access_token
if (!refreshPromise) { const newRefreshToken = data.refresh_token ?? refreshToken
// user 或 refreshToken 为空,无法刷新
cancelProactiveRefresh()
useAuthStore.getState().clearAuth()
window.location.href = "/"
return Promise.reject(new Error("Unable to refresh: missing user or refresh token"))
}
await refreshPromise
// 获取刷新后的新 token // 更新 Zustand + localStorage
const newAccessToken = useAuthStore.getState().accessToken useAuthStore
if (!newAccessToken) { .getState()
return Promise.reject(new Error("Token refresh failed: no new access token")) .setAuth(useAuthStore.getState().user!, newAccessToken, newRefreshToken)
}
// 处理排队的请求 // 处理排队的请求
processQueue(null, newAccessToken) processQueue(null, newAccessToken)
// 重新调度主动刷新(基于新 token 的过期时间)
scheduleProactiveRefresh()
// 重试原始请求 // 重试原始请求
if (originalRequest.headers) { if (originalRequest.headers) {
originalRequest.headers.Authorization = `Bearer ${newAccessToken}` originalRequest.headers.Authorization = `Bearer ${newAccessToken}`
+10 -26
View File
@@ -1,29 +1,9 @@
import apiClient from "../client" import apiClient from "../client"
export interface GenerateCoverTitleConfig {
text?: string
font?: string
font_size?: number
font_color?: string
position?: string
bold?: boolean
stroke?: boolean
shadow?: boolean
}
export interface GenerateCoverRequest { export interface GenerateCoverRequest {
/** asset_ids: string[]
* 封面源视频标识(二选一):
* - generated_video_id:确认生成任务产出的最终视频 ID
* - video_url:最终视频 URL(兜底)
* 后端根据此标识定位最终成片文件并抽帧,MediaKit 选帧逻辑不变
*/
generated_video_id?: string
video_url?: string
cover_type?: "ai_frame" | "manual" | "upload" | "ai_regenerate" cover_type?: "ai_frame" | "manual" | "upload" | "ai_regenerate"
frame_time?: number frame_time?: number
/** 标题样式,用于在封面上叠加标题文字 */
title_config?: GenerateCoverTitleConfig
} }
export interface GenerateCoverResponse { export interface GenerateCoverResponse {
@@ -38,14 +18,18 @@ export interface GenerateCoverResponse {
} }
} }
/** AI 生成封面 — 从最终成片中抽帧(MediaKit 选帧) */ /** AI 生成封面 — 从预览视频中抽帧 */
export async function generateCover( export async function generateCover(
templateId: string, templateId: string,
data: GenerateCoverRequest, data: GenerateCoverRequest,
): Promise<GenerateCoverResponse> { ): Promise<GenerateCoverResponse> {
const response = await apiClient.post<GenerateCoverResponse>("/generation/generate-cover", data, { const response = await apiClient.post<GenerateCoverResponse>(
timeout: 300000, "/generation/generate-cover",
params: { template_id: templateId }, { ...data, template_id: templateId },
}) {
timeout: 300000,
params: { template_id: templateId },
},
)
return response.data return response.data
} }
+4
View File
@@ -3,9 +3,13 @@ export type {
CreatePreviewRequest, CreatePreviewRequest,
CreatePreviewResponse, CreatePreviewResponse,
PreviewTaskResponse, PreviewTaskResponse,
ConfirmGenerationRequest,
ConfirmGenerationResponse,
ConfirmGenerationTaskItem,
} from "./types" } from "./types"
export { createPreview, getPreviewStatus } from "./preview" export { createPreview, getPreviewStatus } from "./preview"
export { confirmGeneration } from "./confirm"
export { generateCover } from "./cover" export { generateCover } from "./cover"
export type { GenerateCoverRequest, GenerateCoverResponse } from "./cover" export type { GenerateCoverRequest, GenerateCoverResponse } from "./cover"
-6
View File
@@ -13,10 +13,6 @@ export interface CreatePreviewRequest {
video_title?: string video_title?: string
duration?: number duration?: number
video_ratio?: string video_ratio?: string
/** 输出视频宽度(与 video_ratio 匹配,如 9:16 → 1080 */
output_width?: number
/** 输出视频高度(与 video_ratio 匹配,如 9:16 → 1920 */
output_height?: number
/* 标题烧录配置(可选,传入后 ASS 渲染标题到预览视频中) */ /* 标题烧录配置(可选,传入后 ASS 渲染标题到预览视频中) */
title_config?: { title_config?: {
text?: string text?: string
@@ -42,8 +38,6 @@ export interface CreatePreviewResponse {
is_preview: boolean is_preview: boolean
resolution: string resolution: string
created_at: string created_at: string
/** 后端自动关联的编辑计划 ID(用于 fallback 路径传递 source_edit_plan_id */
source_edit_plan_id?: string
} }
/** 预览任务详情响应 */ /** 预览任务详情响应 */
-7
View File
@@ -6,7 +6,6 @@ import apiClient from "../client"
import type { import type {
CreateGenerationTaskRequest, CreateGenerationTaskRequest,
CreateGenerationTaskResponse, CreateGenerationTaskResponse,
GenerationTaskDetail,
TaskItem, TaskItem,
TaskListParams, TaskListParams,
TaskListResponse, TaskListResponse,
@@ -20,12 +19,6 @@ export const createGenerationTask = async (
return data return data
} }
/** 获取单个生成任务详情(轮询用) */
export const getGenerationTask = async (taskId: string): Promise<GenerationTaskDetail> => {
const { data } = await apiClient.get<GenerationTaskDetail>(`/generation/tasks/${taskId}`)
return data
}
/** 获取任务列表(支持分页和筛选) */ /** 获取任务列表(支持分页和筛选) */
export const getTasks = async (params?: TaskListParams): Promise<TaskListResponse> => { export const getTasks = async (params?: TaskListParams): Promise<TaskListResponse> => {
const { data } = await apiClient.get<TaskListResponse>("/tasks", { const { data } = await apiClient.get<TaskListResponse>("/tasks", {
+4 -47
View File
@@ -57,45 +57,12 @@ export interface TaskListResponse {
export interface CreateGenerationTaskRequest { export interface CreateGenerationTaskRequest {
template_id: string template_id: string
asset_ids: string[] asset_ids: string[]
title_ids?: string[] title_ids: string[]
voice_ids?: string[] voice_ids: string[]
/** 输出视频宽度 */
output_width?: number
/** 输出视频高度 */
output_height?: number
/** 自定义封面图片 URL */
cover_url?: string
/** 自定义视频标题 */
custom_title?: string
/** 视频时长(秒) */
duration?: number
/** 视频宽高比,如 "9:16" */
video_ratio?: string
/** 标题烧录配置 */
title_config?: {
text?: string
font?: string
font_size?: number
font_color?: string
position?: string
bold?: boolean
stroke?: boolean
shadow?: boolean
}
/** 关联的草稿 ID(编辑流程数据链路用) */
source_edit_plan_id?: string
/** 配音素材库 ID(用户上传的音频或 AI 配音素材) */
voice_library_id?: string
/** 自定义 BGM 配置,覆盖模板 BGM 设置 */
bgm_config?: {
enabled: boolean
preset_id?: string
volume?: number
}
} }
/** 单个生成任务详情(对齐后端 GenerationTaskResponse */ /** 创建生成任务响应(对齐后端 GenerationTaskResponse */
export interface GenerationTaskDetail { export interface CreateGenerationTaskResponse {
id: string id: string
project_id: string project_id: string
asset_library_id: string asset_library_id: string
@@ -105,18 +72,8 @@ export interface GenerationTaskDetail {
asset_ids: string[] asset_ids: string[]
title_ids: string[] title_ids: string[]
voice_ids: string[] voice_ids: string[]
source_edit_plan_id?: string
status: string status: string
progress: number progress: number
result_count: number result_count: number
error_message: string error_message: string
error_info?: TaskErrorInfo
created_at?: string | null
updated_at?: string | null
}
/** 创建生成任务响应(后端返回批量结构 {items, total} */
export interface CreateGenerationTaskResponse {
items: GenerationTaskDetail[]
total: number
} }
+1 -12
View File
@@ -90,21 +90,10 @@ export async function createClipsFromAssets(
templateId: string, templateId: string,
assetIds: string[], assetIds: string[],
clipType = "main", clipType = "main",
requiredClipsCount?: number,
opts?: { signal?: AbortSignal },
): Promise<ClipsFromAssetsResponse> { ): Promise<ClipsFromAssetsResponse> {
const body: Record<string, unknown> = {
asset_ids: assetIds,
clip_type: clipType,
}
if (requiredClipsCount !== undefined) {
body.required_clips_count = requiredClipsCount
}
// from-assets 后端会调用 MediaKit 智能选片(最长 60s),单独延长超时
const response = await apiClient.post<ClipsFromAssetsResponse>( const response = await apiClient.post<ClipsFromAssetsResponse>(
`/templates/${templateId}/editor/clips/from-assets`, `/templates/${templateId}/editor/clips/from-assets`,
body, { asset_ids: assetIds, clip_type: clipType },
{ timeout: 60000, signal: opts?.signal },
) )
return response.data return response.data
} }
+62 -24
View File
@@ -1,8 +1,27 @@
/** /**
* 模板草稿 CRUD API * 模板草稿 CRUD + 生成相关 API
*/ */
import apiClient from "../client" import apiClient from "../client"
import type { EditPlan, UpdateEditPlanRequest, GeneratedVideo } from "./types" import type {
EditPlan,
EditPlanListParams,
EditPlanListResponse,
CreateEditPlanRequest,
UpdateEditPlanRequest,
GenerateResponse,
GenerationStatusResponse,
EditPlanGeneration,
GeneratedVideo,
CopyEditPlanRequest,
} from "./types"
/** 获取模板草稿列表(支持分页和筛选) */
export async function getEditPlans(params?: EditPlanListParams): Promise<EditPlanListResponse> {
const response = await apiClient.get<EditPlanListResponse>("/templates/drafts", {
params,
})
return response.data
}
/** 获取单个模板草稿 */ /** 获取单个模板草稿 */
export async function getEditPlan(templateId: string): Promise<EditPlan> { export async function getEditPlan(templateId: string): Promise<EditPlan> {
@@ -10,44 +29,63 @@ export async function getEditPlan(templateId: string): Promise<EditPlan> {
return response.data return response.data
} }
/** 更新模板草稿(支持传入 AbortSignal 用于自动保存竞态取消) */ /** 创建模板草稿 */
export async function createEditPlan(data: CreateEditPlanRequest): Promise<EditPlan> {
const response = await apiClient.post("/templates/drafts", data)
return response.data
}
/** 更新模板草稿 */
export async function updateEditPlan( export async function updateEditPlan(
templateId: string, templateId: string,
data: UpdateEditPlanRequest, data: UpdateEditPlanRequest,
signal?: AbortSignal,
): Promise<EditPlan> { ): Promise<EditPlan> {
const response = await apiClient.put(`/templates/${templateId}/editor`, data, { signal }) const response = await apiClient.put(`/templates/${templateId}/editor`, data)
return response.data return response.data
} }
/** 删除模板草稿 */
export async function deleteEditPlan(templateId: string): Promise<void> {
await apiClient.delete(`/templates/${templateId}/editor`)
}
/** 触发生成 */
export async function generateEditPlan(templateId: string): Promise<GenerateResponse> {
const response = await apiClient.post(`/templates/${templateId}/editor/generate`)
return response.data
}
/** 获取生成状态(轮询用) */
export async function getGenerationStatus(templateId: string): Promise<GenerationStatusResponse> {
const response = await apiClient.get(`/templates/${templateId}/editor/generation-status`)
return response.data
}
/** 获取模板草稿关联的生成记录 */
export async function getEditPlanGenerations(templateId: string): Promise<EditPlanGeneration[]> {
const response = await apiClient.get(`/templates/${templateId}/editor/generations`)
return response.data.items || []
}
/** 获取生成任务的视频结果列表 */ /** 获取生成任务的视频结果列表 */
export async function getGenerationTaskResults(taskId: string): Promise<GeneratedVideo[]> { export async function getGenerationTaskResults(taskId: string): Promise<GeneratedVideo[]> {
const response = await apiClient.get(`/generation/tasks/${taskId}/results`) const response = await apiClient.get(`/generation/tasks/${taskId}/results`)
return response.data.items || response.data || [] return response.data.items || response.data || []
} }
/** ── 草稿 clips 批量更新 ── */ /** 取消生成任务 */
export async function cancelGeneration(templateId: string): Promise<void> {
export interface EditPlanClipInput { await apiClient.post(`/templates/${templateId}/editor/cancel`)
asset_id: string
start_time: number
duration: number
order: number
} }
/** /** 复制模板草稿(含所有片段配置) */
* 批量替换草稿的 clips(先全删再批量插入) export async function copyEditPlan(
* 后端路由:PUT /templates/{template_id}/editor/clips
*/
export async function updateEditPlanClips(
templateId: string, templateId: string,
clips: EditPlanClipInput[], data?: CopyEditPlanRequest,
signal?: AbortSignal, ): Promise<EditPlan> {
): Promise<{ count: number }> { const response = await apiClient.post<EditPlan>(
const response = await apiClient.put( `/templates/${templateId}/editor/copy`,
`/templates/${templateId}/editor/clips`, data || {},
{ clips },
{ signal },
) )
return response.data return response.data
} }
+15 -2
View File
@@ -15,8 +15,14 @@ export type {
EditPlanSegment, EditPlanSegment,
EditPlanConfig, EditPlanConfig,
EditPlan, EditPlan,
CreateEditPlanRequest,
UpdateEditPlanRequest, UpdateEditPlanRequest,
EditPlanListParams,
EditPlanListResponse,
GenerateResponse,
EditPlanGeneration,
ClipStatusItem, ClipStatusItem,
GenerationStatusResponse,
GeneratedVideo, GeneratedVideo,
AIRecommendRequest, AIRecommendRequest,
AIRecommendClipItem, AIRecommendClipItem,
@@ -31,6 +37,7 @@ export type {
ClipReorderResponse, ClipReorderResponse,
ClipBatchDeleteResponse, ClipBatchDeleteResponse,
ClipsFromAssetsResponse, ClipsFromAssetsResponse,
CopyEditPlanRequest,
TransitionEffect, TransitionEffect,
MediaAsset, MediaAsset,
} from "./types" } from "./types"
@@ -46,12 +53,18 @@ export {
// 模板草稿 CRUD + 生成 // 模板草稿 CRUD + 生成
export { export {
getEditPlans,
getEditPlan, getEditPlan,
createEditPlan,
updateEditPlan, updateEditPlan,
updateEditPlanClips, deleteEditPlan,
generateEditPlan,
getGenerationStatus,
getEditPlanGenerations,
getGenerationTaskResults, getGenerationTaskResults,
cancelGeneration,
copyEditPlan,
} from "./editPlans" } from "./editPlans"
export type { EditPlanClipInput } from "./editPlans"
// 片段 CRUD + 批量操作 // 片段 CRUD + 批量操作
export { export {
+38 -11
View File
@@ -114,19 +114,10 @@ export interface EditPlanConfig {
auto_subtitles?: boolean auto_subtitles?: boolean
/** 是否启用 BGM */ /** 是否启用 BGM */
bgm?: boolean bgm?: boolean
/** 生成数量 */
generate_count?: number
/** 素材模式 */ /** 素材模式 */
material_mode?: string material_mode?: string
/** 前端标题设置(Step4 自动保存,与 title_config 字段分离,不影响后端渲染) */
title?: {
text?: string
font?: string
font_size?: number
color?: string
position?: string
bold?: boolean
stroke?: boolean
shadow?: boolean
}
/** 预览视频 URL(封面生成用) */ /** 预览视频 URL(封面生成用) */
rendered_storage_key?: string rendered_storage_key?: string
/** 生成任务 ID */ /** 生成任务 ID */
@@ -185,6 +176,31 @@ export interface EditPlanListResponse {
/* ── 生成相关 ── */ /* ── 生成相关 ── */
/** 生成响应 */
export interface GenerateResponse {
plan_id: string
plan_status: EditPlanStatus
generation_task_id: string
clip_count: number
}
/** 模板草稿关联的生成记录 */
export interface EditPlanGeneration {
id: string
source_edit_plan_id: string
template_id: string
asset_ids: string[]
status: EditPlanStatus
progress: number
result_count: number
error_message: string
error_info: Record<string, unknown>
logs: Array<Record<string, unknown>>
retry_count: number
created_at?: string
updated_at?: string
}
/** 片段生成状态 */ /** 片段生成状态 */
export interface ClipStatusItem { export interface ClipStatusItem {
clip_id: string clip_id: string
@@ -197,6 +213,17 @@ export interface ClipStatusItem {
error_message?: string error_message?: string
} }
/** 生成状态轮询响应 */
export interface GenerationStatusResponse {
plan_id: string
plan_status: EditPlanStatus
generation_task_id?: string
error_message?: string
clips: ClipStatusItem[]
error?: string
message?: string
}
/** 生成视频详情 */ /** 生成视频详情 */
export interface GeneratedVideo { export interface GeneratedVideo {
id: string id: string
+3
View File
@@ -9,6 +9,8 @@ export type {
TemplateSegment, TemplateSegment,
TemplateListParams, TemplateListParams,
TemplateListResponse, TemplateListResponse,
GenerateFromTemplateRequest,
GenerateFromTemplateResponse,
CopyTemplateResponse, CopyTemplateResponse,
} from "./types" } from "./types"
@@ -22,4 +24,5 @@ export {
getTemplate, getTemplate,
toggleFavoriteTemplate, toggleFavoriteTemplate,
copyTemplate, copyTemplate,
generateFromTemplate,
} from "./templates" } from "./templates"
+14
View File
@@ -5,6 +5,8 @@
import apiClient from "../client" import apiClient from "../client"
import type { import type {
CopyTemplateResponse, CopyTemplateResponse,
GenerateFromTemplateRequest,
GenerateFromTemplateResponse,
TemplateItem, TemplateItem,
TemplateListParams, TemplateListParams,
TemplateListResponse, TemplateListResponse,
@@ -43,3 +45,15 @@ export const copyTemplate = async (templateId: string): Promise<CopyTemplateResp
const response = await apiClient.post<CopyTemplateResponse>(`/templates/${templateId}/copy`) const response = await apiClient.post<CopyTemplateResponse>(`/templates/${templateId}/copy`)
return response.data return response.data
} }
/** 从模板生成 */
export const generateFromTemplate = async (
templateId: string,
data?: GenerateFromTemplateRequest,
): Promise<GenerateFromTemplateResponse> => {
const response = await apiClient.post<GenerateFromTemplateResponse>(
`/templates/${templateId}/generate`,
data,
)
return response.data
}
+2 -7
View File
@@ -44,19 +44,14 @@ export const getVoiceCloneDetail = async (id: string): Promise<VoiceCloneProfile
return response.data return response.data
} }
/** 创建克隆音色audio_url 与 asset_id 二选一) */ /** 创建克隆音色 */
export const createVoiceClone = async ( export const createVoiceClone = async (
data: CreateVoiceCloneRequest, data: CreateVoiceCloneRequest,
): Promise<VoiceCloneProfile> => { ): Promise<VoiceCloneProfile> => {
const payload: CreateVoiceCloneRequestFull = { const payload: CreateVoiceCloneRequestFull = {
name: data.name, name: data.name,
description: data.description, description: data.description,
} source_audio_url: data.audio_url,
// 从配音素材选择克隆:直接传 asset_id,后端用素材 OSS 路径克隆
if (data.asset_id) {
payload.asset_id = data.asset_id
} else {
payload.source_audio_url = data.audio_url
} }
const response = await apiClient.post<VoiceCloneProfile>("/voice-clones", payload) const response = await apiClient.post<VoiceCloneProfile>("/voice-clones", payload)
return response.data return response.data
+4 -9
View File
@@ -22,13 +22,10 @@ export interface VoiceClone {
updated_at: string updated_at: string
} }
/** 创建克隆请求(前端简化版audio_url 与 asset_id 二选一 */ /** 创建克隆请求(前端简化版) */
export interface CreateVoiceCloneRequest { export interface CreateVoiceCloneRequest {
name: string name: string
/** 录音/文件上传后的音频 URL(与 asset_id 二选一) */ audio_url: string
audio_url?: string
/** 从配音素材选择时直接传素材 ID,后端用素材 OSS 路径克隆(与 audio_url 二选一) */
asset_id?: string
description?: string description?: string
} }
@@ -75,13 +72,11 @@ export interface VoiceCloneStatusResponse {
retry_count: number retry_count: number
} }
/** 后端创建克隆请求(完整版source_audio_url 与 asset_id 二选一 */ /** 后端创建克隆请求(完整版) */
export interface CreateVoiceCloneRequestFull { export interface CreateVoiceCloneRequestFull {
name: string name: string
description?: string description?: string
source_audio_url?: string source_audio_url: string
/** 从配音素材选择克隆时传素材 ID */
asset_id?: string
voice_model?: string voice_model?: string
language?: string language?: string
gender?: string gender?: string
@@ -59,23 +59,8 @@ const AssetCard: React.FC<AssetCardProps> = ({
> >
{/* 缩略图 */} {/* 缩略图 */}
<div className="as-card-thumb"> <div className="as-card-thumb">
{asset.thumbnail_url && !asset.thumbnail_url.match(/\.(mp4|mov|avi|webm|mkv)(\?|$)/i) ? ( {asset.thumbnail_url ? (
<img <img src={asset.thumbnail_url} alt={asset.name} loading="lazy" />
src={asset.thumbnail_url}
alt={asset.name}
loading="lazy"
onError={(e) => {
// 图片加载失败时降级显示类型图标
const parent = (e.target as HTMLElement).parentElement
if (parent) {
;(e.target as HTMLElement).style.display = "none"
const icon = document.createElement("span")
icon.className = "as-card-thumb-icon"
icon.textContent = MATERIAL_TYPE_ICONS[asset.type] || "🎬"
parent.appendChild(icon)
}
}}
/>
) : ( ) : (
<span className="as-card-thumb-icon">{MATERIAL_TYPE_ICONS[asset.type]}</span> <span className="as-card-thumb-icon">{MATERIAL_TYPE_ICONS[asset.type]}</span>
)} )}
@@ -149,26 +149,50 @@
/* ── 上传区域 ───────────────────────────────────────────── */ /* ── 上传区域 ───────────────────────────────────────────── */
/* ── 素材选择空态 ─────────────────────────────────────────── */ .xx-clonemodal-upload-zone {
border: 2px dashed var(--xx-color-border, #e5e7eb);
.xx-clonemodal-asset-empty {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 14px 16px;
border: 1px dashed var(--xx-color-border, #e5e7eb);
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
padding: 28px 20px;
text-align: center;
cursor: pointer;
transition: all 0.2s ease;
background: var(--xx-color-bg-secondary, #f9fafb); background: var(--xx-color-bg-secondary, #f9fafb);
} }
.xx-clonemodal-asset-empty-text { .xx-clonemodal-upload-zone:hover {
margin: 0; border-color: var(--xx-color-primary, #6366f1);
font-size: 13px; background: rgba(99, 102, 241, 0.03);
color: var(--xx-color-text-secondary, #6b7280);
} }
/* ── 错误提示 ───────────────────────────────────────────── */ .xx-clonemodal-upload-zone--active {
border-color: var(--xx-color-primary, #6366f1);
background: rgba(99, 102, 241, 0.06);
}
.xx-clonemodal-upload-zone--has-file {
border-style: solid;
border-color: var(--xx-color-primary, #6366f1);
background: rgba(99, 102, 241, 0.04);
}
.xx-clonemodal-upload-icon {
font-size: 32px;
margin-bottom: 8px;
}
.xx-clonemodal-upload-title {
font-size: 14px;
font-weight: 500;
color: var(--xx-color-text, #111827);
margin: 0 0 4px;
word-break: break-all;
}
.xx-clonemodal-upload-hint {
font-size: 12px;
color: var(--xx-color-text-secondary, #6b7280);
margin: 0;
}
/* ── 错误提示 ───────────────────────────────────────────── */ /* ── 错误提示 ───────────────────────────────────────────── */
@@ -7,5 +7,14 @@ export const PROGRESS_STEPS: ProgressStep[] = [
{ key: "done", label: "完成", icon: "✅" }, { key: "done", label: "完成", icon: "✅" },
] ]
/** 支持的音频扩展名 */
export const ACCEPTED_EXTENSIONS = ["mp3", "wav", "m4a", "webm"]
/** 文件选择器 accept 属性 */
export const ACCEPTED_MIME = ".mp3,.wav,.m4a,.webm,audio/mpeg,audio/wav,audio/mp4,audio/webm"
/** 最大文件大小:10MB */
export const MAX_FILE_SIZE = 10 * 1024 * 1024
/** 最长录制时长:5 分钟(秒) */ /** 最长录制时长:5 分钟(秒) */
export const MAX_RECORD_SECONDS = 5 * 60 export const MAX_RECORD_SECONDS = 5 * 60
+108 -122
View File
@@ -1,12 +1,9 @@
import React, { useState, useCallback, useRef, useEffect } from "react" import React, { useState, useCallback, useRef, useEffect } from "react"
import { useNavigate } from "react-router-dom"
import { useQuery } from "@tanstack/react-query"
import { Modal, Button } from "@/components/ui" import { Modal, Button } from "@/components/ui"
import { createVoiceClone, toVoiceClone } from "@/api/voice-clone" import { createVoiceClone, toVoiceClone } from "@/api/voice-clone"
import { uploadAssetDirect, ensureDefaultLibrary, getAssetsByKind } from "@/api/assets" import { uploadAsset } from "@/api/assets"
import { getOrCreateDefaultProject } from "@/api/projects" import { PROGRESS_STEPS, ACCEPTED_MIME } from "./constants"
import { PROGRESS_STEPS } from "./constants" import { validateFile } from "./utils"
import { formatRecordTime } from "./utils"
import { useAudioRecorder } from "./hooks/useAudioRecorder" import { useAudioRecorder } from "./hooks/useAudioRecorder"
import type { CloneModalProps, ModalPhase } from "./types" import type { CloneModalProps, ModalPhase } from "./types"
import "./clone-modal.css" import "./clone-modal.css"
@@ -20,21 +17,15 @@ const getExtensionFromMime = (mime: string): string => {
return "webm" return "webm"
} }
/** 格式化素材时长(秒 → mm:ss) */
const formatAssetDuration = (seconds?: number): string => {
if (!seconds || seconds <= 0) return "--:--"
return formatRecordTime(Math.round(seconds))
}
const CloneModal: React.FC<CloneModalProps> = ({ open, onClose, onSuccess }) => { const CloneModal: React.FC<CloneModalProps> = ({ open, onClose, onSuccess }) => {
const navigate = useNavigate()
const [phase, setPhase] = useState<ModalPhase>("input") const [phase, setPhase] = useState<ModalPhase>("input")
const [voiceName, setVoiceName] = useState("") const [voiceName, setVoiceName] = useState("")
const [voiceDescription, setVoiceDescription] = useState("") const [voiceDescription, setVoiceDescription] = useState("")
/** 从配音素材选择的素材 ID */ const [selectedFile, setSelectedFile] = useState<File | null>(null)
const [selectedAssetId, setSelectedAssetId] = useState<string>("") const [dragActive, setDragActive] = useState(false)
const [errorMessage, setErrorMessage] = useState("") const [errorMessage, setErrorMessage] = useState("")
const fileInputRef = useRef<HTMLInputElement>(null)
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null) const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
/** 默认音色名称计数器(组件级 ref,避免多实例串号) */ /** 默认音色名称计数器(组件级 ref,避免多实例串号) */
const cloneCounterRef = useRef(1) const cloneCounterRef = useRef(1)
@@ -42,14 +33,6 @@ const CloneModal: React.FC<CloneModalProps> = ({ open, onClose, onSuccess }) =>
const isMountedRef = useRef(true) const isMountedRef = useRef(true)
const isSubmittingRef = useRef(false) const isSubmittingRef = useRef(false)
/* ── 配音素材列表(「从配音素材选择」;弹窗打开时才发请求) ────── */
const { data: voiceAssets, isLoading: assetsLoading } = useQuery({
queryKey: ["assets", "voice", "clone-modal"],
queryFn: () => getAssetsByKind("voice", { limit: 100 }),
enabled: open,
staleTime: 30_000,
})
/* ── 录音 Hook ──────────────────────────────────── */ /* ── 录音 Hook ──────────────────────────────────── */
const { const {
isRecording, isRecording,
@@ -71,9 +54,10 @@ const CloneModal: React.FC<CloneModalProps> = ({ open, onClose, onSuccess }) =>
setPhase("input") setPhase("input")
setVoiceName(getNextDefaultName()) setVoiceName(getNextDefaultName())
setVoiceDescription("") setVoiceDescription("")
setSelectedAssetId("") setSelectedFile(null)
// 注意:resetState 不得触碰 isSubmittingRef——提交锁仅属于 handleSubmit setDragActive(false)
// 此前在此上锁且无复位路径,弹窗打开即死锁 if (isSubmittingRef.current) return
isSubmittingRef.current = true
setErrorMessage("") setErrorMessage("")
resetRecorder() resetRecorder()
}, [getNextDefaultName, resetRecorder]) }, [getNextDefaultName, resetRecorder])
@@ -100,26 +84,65 @@ const CloneModal: React.FC<CloneModalProps> = ({ open, onClose, onSuccess }) =>
} }
}, []) }, [])
/* ── 素材/录音互斥:选择素材时清掉录音,开始录音时清掉素材选择 ── */ /* ── 文件上传 ──────────────────────────────────── */
const handleSelectAsset = (assetId: string) => { const handleUploadClick = () => {
setSelectedAssetId(assetId) fileInputRef.current?.click()
if (assetId) { }
resetRecorder()
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (file) {
const error = validateFile(file)
if (error) {
setErrorMessage(error)
setSelectedFile(null)
} else {
if (isSubmittingRef.current) return
isSubmittingRef.current = true
setErrorMessage("")
setSelectedFile(file)
resetRecorder()
}
}
e.target.value = ""
}
/* ── 拖拽 ──────────────────────────────────────── */
const handleDrag = (e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
if (e.type === "dragenter" || e.type === "dragover") {
setDragActive(true)
} else if (e.type === "dragleave") {
setDragActive(false)
} }
} }
const handleToggleRecord = () => { const handleDrop = (e: React.DragEvent) => {
// 开始录音会清掉已选素材;停止录音保留录音结果 e.preventDefault()
if (!isRecording) { e.stopPropagation()
setSelectedAssetId("") setDragActive(false)
const file = e.dataTransfer.files?.[0]
if (file) {
const error = validateFile(file)
if (error) {
setErrorMessage(error)
setSelectedFile(null)
} else {
if (isSubmittingRef.current) return
isSubmittingRef.current = true
setErrorMessage("")
setSelectedFile(file)
resetRecorder()
}
} }
toggleRecord()
} }
/* ── 计算属性 ──────────────────────────────────── */ /* ── 计算属性 ──────────────────────────────────── */
const hasAudio = selectedAssetId !== "" || recordedBlob !== null const hasAudio = selectedFile !== null || recordedBlob !== null
const isProcessing = phase === "uploading" || phase === "cloning" const isProcessing = phase === "uploading" || phase === "cloning"
const canSubmit = hasAudio && !isProcessing const canSubmit = hasAudio && !isProcessing
@@ -134,7 +157,7 @@ const CloneModal: React.FC<CloneModalProps> = ({ open, onClose, onSuccess }) =>
return return
} }
if (!hasAudio) { if (!hasAudio) {
setErrorMessage("请从配音素材选择一段音频,或直接录制声音") setErrorMessage("请上传音频文件或录制一段声音")
return return
} }
@@ -143,47 +166,24 @@ const CloneModal: React.FC<CloneModalProps> = ({ open, onClose, onSuccess }) =>
setErrorMessage("") setErrorMessage("")
try { try {
// 路径 A:从配音素材选择 → 无需上传,直接克隆 // 阶段 1:上传音频
if (selectedAssetId) {
setPhase("cloning")
const result = await createVoiceClone({
name,
description: voiceDescription.trim() || undefined,
asset_id: selectedAssetId,
})
if (!isMountedRef.current) return
isSubmittingRef.current = false
setPhase("done")
timerRef.current = setTimeout(() => {
if (isMountedRef.current) {
onSuccess?.(toVoiceClone(result))
handleClose()
}
}, 2000)
return
}
// 路径 B:录音 → 先上传为配音素材,再克隆
setPhase("uploading") setPhase("uploading")
// 使用浏览器实际生成的 MIME 类型,避免跨浏览器格式不匹配 let fileToUpload: File
const mimeType = recordedBlob?.type || "audio/webm" if (selectedFile) {
const ext = getExtensionFromMime(mimeType) fileToUpload = selectedFile
const fileToUpload = new File([recordedBlob!], `recorded-${Date.now()}.${ext}`, { } else {
type: mimeType, // 使用浏览器实际生成的 MIME 类型,避免跨浏览器格式不匹配
}) const mimeType = recordedBlob?.type || "audio/webm"
const ext = getExtensionFromMime(mimeType)
fileToUpload = new File([recordedBlob!], `recorded-${Date.now()}.${ext}`, {
type: mimeType,
})
}
// 获取默认项目和素材库 const formData = new FormData()
const project = await getOrCreateDefaultProject() formData.append("file", fileToUpload)
const library = await ensureDefaultLibrary({ project_id: project.id, kind: "voice" }) const uploadResult = await uploadAsset(formData)
// 直传到 OSS
const uploadResult = await uploadAssetDirect({
file: fileToUpload,
library_id: library.id,
})
// 组件已卸载则中止后续操作 // 组件已卸载则中止后续操作
if (!isMountedRef.current) return if (!isMountedRef.current) return
@@ -219,8 +219,6 @@ const CloneModal: React.FC<CloneModalProps> = ({ open, onClose, onSuccess }) =>
} }
} }
const hasAssets = (voiceAssets?.length ?? 0) > 0
return ( return (
<Modal <Modal
open={open} open={open}
@@ -239,7 +237,7 @@ const CloneModal: React.FC<CloneModalProps> = ({ open, onClose, onSuccess }) =>
<div className="xx-clonemodal-steps"> <div className="xx-clonemodal-steps">
<div className="xx-clonemodal-step xx-clonemodal-step--active"> <div className="xx-clonemodal-step xx-clonemodal-step--active">
<div className="xx-clonemodal-step-number">1</div> <div className="xx-clonemodal-step-number">1</div>
<span className="xx-clonemodal-step-label">/</span> <span className="xx-clonemodal-step-label">/</span>
</div> </div>
<div className="xx-clonemodal-step-connector" /> <div className="xx-clonemodal-step-connector" />
<div className="xx-clonemodal-step"> <div className="xx-clonemodal-step">
@@ -269,42 +267,30 @@ const CloneModal: React.FC<CloneModalProps> = ({ open, onClose, onSuccess }) =>
<div className="xx-clonemodal-char-count">{voiceName.length}/20</div> <div className="xx-clonemodal-char-count">{voiceName.length}/20</div>
</div> </div>
{/* 从配音素材选择 */} {/* 上传区域 */}
<div className="xx-clonemodal-field"> <div className="xx-clonemodal-field">
<label className="xx-clonemodal-label"></label> <label className="xx-clonemodal-label"></label>
{hasAssets ? ( <div
<select className={`xx-clonemodal-upload-zone${dragActive ? " xx-clonemodal-upload-zone--active" : ""}${selectedFile ? " xx-clonemodal-upload-zone--has-file" : ""}`}
className="xx-clonemodal-input" onClick={handleUploadClick}
value={selectedAssetId} onDragEnter={handleDrag}
onChange={(e) => handleSelectAsset(e.target.value)} onDragOver={handleDrag}
disabled={assetsLoading} onDragLeave={handleDrag}
> onDrop={handleDrop}
<option value="">{assetsLoading ? "素材加载中…" : "请选择已上传的配音素材"}</option> >
{voiceAssets!.map((asset) => ( <div className="xx-clonemodal-upload-icon">{selectedFile ? "📄" : "🎵"}</div>
<option key={asset.id} value={asset.id}> <p className="xx-clonemodal-upload-title">
{asset.name}{formatAssetDuration(asset.duration)} {selectedFile ? selectedFile.name : "拖拽音频文件到此处,或点击上传"}
</option> </p>
))} <p className="xx-clonemodal-upload-hint"> MP3WAVM4A 10MB</p>
</select> <input
) : ( ref={fileInputRef}
<div className="xx-clonemodal-asset-empty"> type="file"
<p className="xx-clonemodal-asset-empty-text"> accept={ACCEPTED_MIME}
{assetsLoading ? "素材加载中…" : "请先在配音库上传素材"} style={{ display: "none" }}
</p> onChange={handleFileChange}
{!assetsLoading && ( />
<Button </div>
buttonType="ghost"
buttonSize="sm"
onClick={() => {
handleClose()
navigate("/app/voice-materials")
}}
>
</Button>
)}
</div>
)}
</div> </div>
{/* 或分隔 */} {/* 或分隔 */}
@@ -339,7 +325,7 @@ const CloneModal: React.FC<CloneModalProps> = ({ open, onClose, onSuccess }) =>
<button <button
type="button" type="button"
className={`xx-clonemodal-record-btn${isRecording ? " xx-clonemodal-record-btn--recording" : ""}`} className={`xx-clonemodal-record-btn${isRecording ? " xx-clonemodal-record-btn--recording" : ""}`}
onClick={handleToggleRecord} onClick={toggleRecord}
title={isRecording ? "停止录制" : "开始录制"} title={isRecording ? "停止录制" : "开始录制"}
> >
{isRecording ? "⏹" : "🎙️"} {isRecording ? "⏹" : "🎙️"}
@@ -372,7 +358,7 @@ const CloneModal: React.FC<CloneModalProps> = ({ open, onClose, onSuccess }) =>
{/* 提示 */} {/* 提示 */}
<div className="xx-clonemodal-tip"> <div className="xx-clonemodal-tip">
<span className="xx-clonemodal-tip-icon">💡</span> <span className="xx-clonemodal-tip-icon">💡</span>
<span>使 10 ~ 3 </span> <span> 10 ~ 3 </span>
</div> </div>
{/* 底部按钮 */} {/* 底部按钮 */}
@@ -422,18 +408,18 @@ const CloneModal: React.FC<CloneModalProps> = ({ open, onClose, onSuccess }) =>
{/* 当前阶段描述 */} {/* 当前阶段描述 */}
<div className="xx-clonemodal-progress-info"> <div className="xx-clonemodal-progress-info">
{phase === "uploading" && ( {phase === "uploading" && (
<div> <>
<div className="xx-clonemodal-progress-spinner" /> <div className="xx-clonemodal-progress-spinner" />
<p className="xx-clonemodal-progress-text"></p> <p className="xx-clonemodal-progress-text"></p>
<p className="xx-clonemodal-progress-sub"></p> <p className="xx-clonemodal-progress-sub"></p>
</div> </>
)} )}
{phase === "cloning" && ( {phase === "cloning" && (
<div> <>
<div className="xx-clonemodal-progress-spinner xx-clonemodal-progress-spinner--cloning" /> <div className="xx-clonemodal-progress-spinner xx-clonemodal-progress-spinner--cloning" />
<p className="xx-clonemodal-progress-text">AI </p> <p className="xx-clonemodal-progress-text">AI </p>
<p className="xx-clonemodal-progress-sub"></p> <p className="xx-clonemodal-progress-sub"></p>
</div> </>
)} )}
</div> </div>
</div> </div>
@@ -1,3 +1,20 @@
import { ACCEPTED_EXTENSIONS, MAX_FILE_SIZE } from "./constants"
/**
* 验证音频文件
* @returns 错误信息,null 表示验证通过
*/
export const validateFile = (file: File): string | null => {
const ext = file.name.split(".").pop()?.toLowerCase()
if (!ext || !ACCEPTED_EXTENSIONS.includes(ext)) {
return "不支持的音频格式,请上传 MP3、WAV 或 M4A 文件"
}
if (file.size > MAX_FILE_SIZE) {
return "文件大小超过 10MB,请压缩后重试"
}
return null
}
/** 格式化录制时间 mm:ss */ /** 格式化录制时间 mm:ss */
export const formatRecordTime = (seconds: number): string => { export const formatRecordTime = (seconds: number): string => {
const m = Math.floor(seconds / 60) const m = Math.floor(seconds / 60)
@@ -0,0 +1,142 @@
import React from "react"
import { Button } from "@/components/ui"
import UploadZone from "./UploadZone"
import RecordArea from "./RecordArea"
import StepIndicator from "./StepIndicator"
import { MAX_VOICE_NAME_LENGTH, MAX_VOICE_DESC_LENGTH } from "../constants/cloneModal"
interface InputViewProps {
voiceName: string
voiceDescription: string
selectedFile: File | null
dragActive: boolean
isRecording: boolean
recordTime: number
recordedBlob: Blob | null
errorMessage: string
canSubmit: boolean
onVoiceNameChange: (value: string) => void
onVoiceDescChange: (value: string) => void
onDragActiveChange: (active: boolean) => void
onFileSelect: (file: File | null, error: string) => void
onRecordToggle: () => void
onClose: () => void
onSubmit: () => void
}
const INPUT_STEPS = ["上传/录制音频", "填写信息", "提交克隆"]
const InputView: React.FC<InputViewProps> = ({
voiceName,
voiceDescription,
selectedFile,
dragActive,
isRecording,
recordTime,
recordedBlob,
errorMessage,
canSubmit,
onVoiceNameChange,
onVoiceDescChange,
onDragActiveChange,
onFileSelect,
onRecordToggle,
onClose,
onSubmit,
}) => {
return (
<div className="xx-clonemodal-body">
{/* 步骤引导 */}
<StepIndicator currentStep={0} steps={INPUT_STEPS} />
{/* 音色名称 */}
<div className="xx-clonemodal-field">
<label className="xx-clonemodal-label">
<span className="xx-clonemodal-required">*</span>
</label>
<input
type="text"
className="xx-clonemodal-input"
value={voiceName}
onChange={(e) => onVoiceNameChange(e.target.value)}
placeholder="输入音色名称(2-20字符)"
maxLength={MAX_VOICE_NAME_LENGTH}
/>
<div className="xx-clonemodal-char-count">
{voiceName.length}/{MAX_VOICE_NAME_LENGTH}
</div>
</div>
{/* 上传区域 */}
<div className="xx-clonemodal-field">
<label className="xx-clonemodal-label"></label>
<UploadZone
selectedFile={selectedFile}
dragActive={dragActive}
onDragActiveChange={onDragActiveChange}
onFileSelect={onFileSelect}
/>
</div>
{/* 或分隔 */}
<div className="xx-clonemodal-divider">
<div className="xx-clonemodal-divider-line" />
<span className="xx-clonemodal-divider-text"></span>
<div className="xx-clonemodal-divider-line" />
</div>
{/* 录制区域 */}
<div className="xx-clonemodal-field">
<label className="xx-clonemodal-label"></label>
<RecordArea
isRecording={isRecording}
recordTime={recordTime}
recordedBlob={recordedBlob}
onRecordToggle={onRecordToggle}
/>
</div>
{/* 音色描述 */}
<div className="xx-clonemodal-field">
<label className="xx-clonemodal-label"></label>
<textarea
className="xx-clonemodal-textarea"
value={voiceDescription}
onChange={(e) => onVoiceDescChange(e.target.value)}
placeholder="可选,描述这个音色的特点(最多100字符)"
maxLength={MAX_VOICE_DESC_LENGTH}
rows={3}
/>
<div className="xx-clonemodal-char-count">
{voiceDescription.length}/{MAX_VOICE_DESC_LENGTH}
</div>
</div>
{/* 错误提示 */}
{errorMessage && (
<div className="xx-clonemodal-error">
<span className="xx-clonemodal-error-icon"></span>
<span>{errorMessage}</span>
</div>
)}
{/* 提示 */}
<div className="xx-clonemodal-tip">
<span className="xx-clonemodal-tip-icon">💡</span>
<span> 10 ~ 3 </span>
</div>
{/* 底部按钮 */}
<div className="xx-clonemodal-footer">
<Button buttonType="ghost" onClick={onClose}>
</Button>
<Button buttonType="primary" disabled={!canSubmit} onClick={onSubmit}>
🎤
</Button>
</div>
</div>
)
}
export default InputView
@@ -0,0 +1,92 @@
import React from "react"
import { PROGRESS_STEPS } from "../constants/cloneModal"
import type { ProgressStep } from "../types/cloneModal"
import type { ModalPhase } from "../types/cloneModal"
interface ProgressViewProps {
phase: ModalPhase
}
const getProgressIndex = (phase: ModalPhase): number => {
switch (phase) {
case "uploading":
return 0
case "cloning":
return 1
case "done":
return 2
default:
return -1
}
}
const ProgressView: React.FC<ProgressViewProps> = ({ phase }) => {
const progressIndex = getProgressIndex(phase)
const isDone = phase === "done"
return (
<div className="xx-clonemodal-progress-body">
{/* 步骤指示器 */}
<div className="xx-clonemodal-steps-progress">
{PROGRESS_STEPS.map((step: ProgressStep, idx: number) => {
const isActive = idx === progressIndex && !isDone
const stepDone = idx < progressIndex || isDone
const stepClass = [
"xx-clonemodal-step-progress",
isActive ? "xx-clonemodal-step-progress--active" : "",
stepDone ? "xx-clonemodal-step-progress--done" : "",
]
.filter(Boolean)
.join(" ")
return (
<React.Fragment key={step.key}>
{idx > 0 && (
<div
className={`xx-clonemodal-step-connector${stepDone ? " xx-clonemodal-step-connector--done" : ""}`}
/>
)}
<div className={stepClass}>
<div className="xx-clonemodal-step-icon">{stepDone ? "✓" : step.icon}</div>
<span className="xx-clonemodal-step-label">{step.label}</span>
</div>
</React.Fragment>
)
})}
</div>
{/* 完成阶段 */}
{isDone && (
<div className="xx-clonemodal-success">
<div className="xx-clonemodal-success-icon">🎉</div>
<h3 className="xx-clonemodal-success-title"></h3>
<p className="xx-clonemodal-success-desc">
</p>
</div>
)}
{/* 进行中阶段 */}
{!isDone && (
<div className="xx-clonemodal-progress-info">
{phase === "uploading" && (
<>
<div className="xx-clonemodal-progress-spinner" />
<p className="xx-clonemodal-progress-text"></p>
<p className="xx-clonemodal-progress-sub"></p>
</>
)}
{phase === "cloning" && (
<>
<div className="xx-clonemodal-progress-spinner xx-clonemodal-progress-spinner--cloning" />
<p className="xx-clonemodal-progress-text">AI </p>
<p className="xx-clonemodal-progress-sub"></p>
</>
)}
</div>
)}
</div>
)
}
export default ProgressView
@@ -0,0 +1,49 @@
import React from "react"
import { formatRecordTime } from "../utils/cloneModal"
interface RecordAreaProps {
isRecording: boolean
recordTime: number
recordedBlob: Blob | null
onRecordToggle: () => void
}
const RecordArea: React.FC<RecordAreaProps> = ({
isRecording,
recordTime,
recordedBlob,
onRecordToggle,
}) => {
const getHintText = () => {
if (isRecording) return `录制中 ${formatRecordTime(recordTime)}`
if (recordedBlob) return `已录制 ${formatRecordTime(recordTime)}`
return "点击按钮开始录制(最长 5 分钟)"
}
return (
<div className="xx-clonemodal-record-area">
<div className="xx-clonemodal-record-info">
<p className="xx-clonemodal-record-hint">{getHintText()}</p>
{isRecording && (
<div className="xx-clonemodal-record-wave">
<span className="xx-clonemodal-record-wave-bar" />
<span className="xx-clonemodal-record-wave-bar" />
<span className="xx-clonemodal-record-wave-bar" />
<span className="xx-clonemodal-record-wave-bar" />
<span className="xx-clonemodal-record-wave-bar" />
</div>
)}
</div>
<button
type="button"
className={`xx-clonemodal-record-btn${isRecording ? " xx-clonemodal-record-btn--recording" : ""}`}
onClick={onRecordToggle}
title={isRecording ? "停止录制" : "开始录制"}
>
{isRecording ? "⏹" : "🎙️"}
</button>
</div>
)
}
export default RecordArea
@@ -0,0 +1,30 @@
import React from "react"
interface StepIndicatorProps {
currentStep: number
steps: string[]
}
/**
* 输入阶段顶部的步骤引导(数字步骤)
*/
const StepIndicator: React.FC<StepIndicatorProps> = ({ currentStep, steps }) => {
return (
<div className="xx-clonemodal-steps">
{steps.map((label, idx) => {
const isActive = idx <= currentStep
return (
<React.Fragment key={idx}>
{idx > 0 && <div className="xx-clonemodal-step-connector" />}
<div className={`xx-clonemodal-step${isActive ? " xx-clonemodal-step--active" : ""}`}>
<div className="xx-clonemodal-step-number">{idx + 1}</div>
<span className="xx-clonemodal-step-label">{label}</span>
</div>
</React.Fragment>
)
})}
</div>
)
}
export default StepIndicator
@@ -0,0 +1,79 @@
import React, { useRef } from "react"
import { ACCEPTED_MIME } from "../constants/cloneModal"
import { validateFile } from "../utils/cloneModal"
interface UploadZoneProps {
selectedFile: File | null
dragActive: boolean
onDragActiveChange: (active: boolean) => void
onFileSelect: (file: File | null, error: string) => void
}
const UploadZone: React.FC<UploadZoneProps> = ({
selectedFile,
dragActive,
onDragActiveChange,
onFileSelect,
}) => {
const fileInputRef = useRef<HTMLInputElement>(null)
const handleUploadClick = () => {
fileInputRef.current?.click()
}
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (file) {
const error = validateFile(file)
onFileSelect(error ? null : file, error || "")
}
e.target.value = ""
}
const handleDrag = (e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
if (e.type === "dragenter" || e.type === "dragover") {
onDragActiveChange(true)
} else if (e.type === "dragleave") {
onDragActiveChange(false)
}
}
const handleDrop = (e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
onDragActiveChange(false)
const file = e.dataTransfer.files?.[0]
if (file) {
const error = validateFile(file)
onFileSelect(error ? null : file, error || "")
}
}
return (
<div
className={`xx-clonemodal-upload-zone${dragActive ? " xx-clonemodal-upload-zone--active" : ""}${selectedFile ? " xx-clonemodal-upload-zone--has-file" : ""}`}
onClick={handleUploadClick}
onDragEnter={handleDrag}
onDragOver={handleDrag}
onDragLeave={handleDrag}
onDrop={handleDrop}
>
<div className="xx-clonemodal-upload-icon">{selectedFile ? "📄" : "🎵"}</div>
<p className="xx-clonemodal-upload-title">
{selectedFile ? selectedFile.name : "拖拽音频文件到此处,或点击上传"}
</p>
<p className="xx-clonemodal-upload-hint"> MP3WAVM4A 10MB</p>
<input
ref={fileInputRef}
type="file"
accept={ACCEPTED_MIME}
style={{ display: "none" }}
onChange={handleFileChange}
/>
</div>
)
}
export default UploadZone
@@ -0,0 +1,29 @@
import type { ProgressStep } from "../types/cloneModal"
/** 进度阶段配置 */
export const PROGRESS_STEPS: ProgressStep[] = [
{ key: "uploading", label: "上传中", icon: "📤" },
{ key: "cloning", label: "克隆中", icon: "🧬" },
{ key: "done", label: "完成", icon: "✅" },
]
/** 支持的音频扩展名 */
export const ACCEPTED_EXTENSIONS = ["mp3", "wav", "m4a"]
/** input accept 属性值 */
export const ACCEPTED_MIME = ".mp3,.wav,.m4a,audio/mpeg,audio/wav,audio/mp4"
/** 最大文件大小:10MB */
export const MAX_FILE_SIZE = 10 * 1024 * 1024
/** 最长录制时长(秒):5 分钟 */
export const MAX_RECORD_SECONDS = 5 * 60
/** 音色名称最小长度 */
export const MIN_VOICE_NAME_LENGTH = 2
/** 音色名称最大长度 */
export const MAX_VOICE_NAME_LENGTH = 20
/** 音色描述最大长度 */
export const MAX_VOICE_DESC_LENGTH = 100
@@ -0,0 +1,119 @@
import { useState, useRef, useCallback, useEffect } from "react"
import { MAX_RECORD_SECONDS } from "../constants/cloneModal"
interface UseAudioRecorderReturn {
isRecording: boolean
recordTime: number
recordedBlob: Blob | null
toggleRecording: () => void
resetRecording: () => void
}
/**
* 录音 Hook —— 封装 MediaRecorder 录音逻辑
*/
const useAudioRecorder = (): UseAudioRecorderReturn => {
const [isRecording, setIsRecording] = useState(false)
const [recordTime, setRecordTime] = useState(0)
const [recordedBlob, setRecordedBlob] = useState<Blob | null>(null)
const recordTimerRef = useRef<ReturnType<typeof setInterval> | null>(null)
const mediaRecorderRef = useRef<MediaRecorder | null>(null)
const audioChunksRef = useRef<Blob[]>([])
const stopRecording = useCallback(() => {
setIsRecording(false)
if (recordTimerRef.current) {
clearInterval(recordTimerRef.current)
recordTimerRef.current = null
}
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== "inactive") {
mediaRecorderRef.current.stop()
}
}, [])
const startRecording = useCallback(async () => {
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
const mediaRecorder = new MediaRecorder(stream)
mediaRecorderRef.current = mediaRecorder
audioChunksRef.current = []
mediaRecorder.ondataavailable = (event) => {
if (event.data.size > 0) {
audioChunksRef.current.push(event.data)
}
}
mediaRecorder.onstop = () => {
const blob = new Blob(audioChunksRef.current, { type: "audio/webm" })
setRecordedBlob(blob)
stream.getTracks().forEach((track) => track.stop())
}
mediaRecorder.start()
setIsRecording(true)
setRecordTime(0)
setRecordedBlob(null)
recordTimerRef.current = setInterval(() => {
setRecordTime((prev) => {
const next = prev + 1
if (next >= MAX_RECORD_SECONDS) {
setTimeout(() => {
stopRecording()
}, 0)
return MAX_RECORD_SECONDS
}
return next
})
}, 1000)
} catch {
// 错误由调用方通过其他机制提示
setIsRecording(false)
}
}, [stopRecording])
const toggleRecording = useCallback(() => {
if (isRecording) {
stopRecording()
} else {
startRecording()
}
}, [isRecording, startRecording, stopRecording])
const resetRecording = useCallback(() => {
setIsRecording(false)
setRecordTime(0)
setRecordedBlob(null)
audioChunksRef.current = []
if (recordTimerRef.current) {
clearInterval(recordTimerRef.current)
recordTimerRef.current = null
}
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== "inactive") {
mediaRecorderRef.current.stop()
}
mediaRecorderRef.current = null
}, [])
// 卸载时清理
useEffect(() => {
return () => {
if (recordTimerRef.current) clearInterval(recordTimerRef.current)
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== "inactive") {
mediaRecorderRef.current.stop()
}
}
}, [])
return {
isRecording,
recordTime,
recordedBlob,
toggleRecording,
resetRecording,
}
}
export default useAudioRecorder
@@ -0,0 +1,134 @@
import { useState, useRef, useCallback, useEffect } from "react"
import type { ModalPhase } from "../types/cloneModal"
import { MIN_VOICE_NAME_LENGTH, MAX_VOICE_NAME_LENGTH } from "../constants/cloneModal"
import useAudioRecorder from "./useAudioRecorder"
/**
* 克隆弹窗表单状态 Hook
* 管理表单字段、录音、文件选择、验证逻辑
*/
export function useCloneFormState({ open, onClose }: { open: boolean; onClose: () => void }) {
const [phase, setPhase] = useState<ModalPhase>("input")
const [voiceName, setVoiceName] = useState("")
const [voiceDescription, setVoiceDescription] = useState("")
const [selectedFile, setSelectedFile] = useState<File | null>(null)
const [dragActive, setDragActive] = useState(false)
const [errorMessage, setErrorMessage] = useState("")
const { isRecording, recordTime, recordedBlob, toggleRecording, resetRecording } =
useAudioRecorder()
/** 默认音色名称计数器 */
const cloneCounterRef = useRef(1)
const getNextDefaultName = useCallback((): string => {
const name = `我的声音 ${cloneCounterRef.current}`
cloneCounterRef.current += 1
return name
}, [])
const hasAudio = selectedFile !== null || recordedBlob !== null
const canSubmit =
voiceName.trim().length >= MIN_VOICE_NAME_LENGTH &&
voiceName.trim().length <= MAX_VOICE_NAME_LENGTH &&
hasAudio
const isProcessing = phase === "uploading" || phase === "cloning"
/** 重置弹窗状态 */
const resetState = useCallback(() => {
setPhase("input")
setVoiceName(getNextDefaultName())
setVoiceDescription("")
setSelectedFile(null)
setDragActive(false)
setErrorMessage("")
resetRecording()
}, [getNextDefaultName, resetRecording])
/** 关闭弹窗 */
const handleClose = useCallback(() => {
resetState()
onClose()
}, [resetState, onClose])
/** 弹窗打开时重置状态 */
useEffect(() => {
if (open) {
resetState()
}
}, [open, resetState])
/** 选择文件(来自上传或拖拽) */
const handleFileSelect = useCallback(
(file: File | null, error: string) => {
if (error) {
setErrorMessage(error)
setSelectedFile(null)
} else {
setErrorMessage("")
setSelectedFile(file)
// 清除录音
resetRecording()
}
},
[resetRecording],
)
/** 录音切换 */
const handleRecordToggle = useCallback(() => {
setErrorMessage("")
if (isRecording) {
toggleRecording()
} else {
// 开始录制前清除已选文件
setSelectedFile(null)
toggleRecording()
}
}, [isRecording, toggleRecording])
/** 表单验证 */
const validateForm = useCallback((): string | null => {
const name = voiceName.trim()
if (!name) {
return "请输入音色名称"
}
if (name.length < MIN_VOICE_NAME_LENGTH || name.length > MAX_VOICE_NAME_LENGTH) {
return `音色名称需在 ${MIN_VOICE_NAME_LENGTH}-${MAX_VOICE_NAME_LENGTH} 个字符之间`
}
if (!hasAudio) {
return "请上传音频文件或录制一段声音"
}
return null
}, [voiceName, hasAudio])
return {
// 状态
phase,
setPhase,
voiceName,
setVoiceName,
voiceDescription,
setVoiceDescription,
selectedFile,
dragActive,
setDragActive,
errorMessage,
setErrorMessage,
// 录音
isRecording,
recordTime,
recordedBlob,
// 计算属性
hasAudio,
canSubmit,
isProcessing,
// handlers
handleFileSelect,
handleRecordToggle,
handleClose,
validateForm,
resetState,
}
}
+46
View File
@@ -0,0 +1,46 @@
import type { CloneModalProps } from "../types/cloneModal"
import { useCloneFormState } from "./useCloneFormState"
import { useCloneSubmit } from "./useCloneSubmit"
/**
* 音色克隆弹窗主业务 Hook
* 组合表单状态 + 提交流程两个子 Hook
*/
const useCloneModal = ({ open, onClose, onSuccess }: CloneModalProps) => {
const formState = useCloneFormState({ open, onClose })
const { handleSubmit } = useCloneSubmit({
voiceName: formState.voiceName,
voiceDescription: formState.voiceDescription,
selectedFile: formState.selectedFile,
recordedBlob: formState.recordedBlob,
setPhase: formState.setPhase,
setErrorMessage: formState.setErrorMessage,
validateForm: formState.validateForm,
onSuccess,
onClose: formState.handleClose,
})
return {
phase: formState.phase,
voiceName: formState.voiceName,
voiceDescription: formState.voiceDescription,
selectedFile: formState.selectedFile,
dragActive: formState.dragActive,
errorMessage: formState.errorMessage,
isRecording: formState.isRecording,
recordTime: formState.recordTime,
recordedBlob: formState.recordedBlob,
canSubmit: formState.canSubmit,
isProcessing: formState.isProcessing,
setVoiceName: formState.setVoiceName,
setVoiceDescription: formState.setVoiceDescription,
setDragActive: formState.setDragActive,
handleFileSelect: formState.handleFileSelect,
handleRecordToggle: formState.handleRecordToggle,
handleClose: formState.handleClose,
handleSubmit,
}
}
export default useCloneModal
+108
View File
@@ -0,0 +1,108 @@
import { useRef, useCallback, useEffect } from "react"
import { createVoiceClone, toVoiceClone } from "@/api/voice-clone"
import { uploadAsset, ensureDefaultLibrary } from "@/api/assets"
import { getOrCreateDefaultProject } from "@/api/projects"
import type { VoiceClone } from "@/api/voice-clone"
interface UseCloneSubmitOptions {
voiceName: string
voiceDescription: string
selectedFile: File | null
recordedBlob: Blob | null
setPhase: (phase: "input" | "uploading" | "cloning" | "done") => void
setErrorMessage: (msg: string) => void
validateForm: () => string | null
onSuccess?: (clone: VoiceClone) => void
onClose: () => void
}
/**
* 克隆提交流程 Hook
* 封装上传 + 克隆 + 完成的三阶段流程
*/
export function useCloneSubmit({
voiceName,
voiceDescription,
selectedFile,
recordedBlob,
setPhase,
setErrorMessage,
validateForm,
onSuccess,
onClose,
}: UseCloneSubmitOptions) {
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
/** 组件卸载时清理定时器 */
useEffect(() => {
return () => {
if (timerRef.current) clearTimeout(timerRef.current)
}
}, [])
const handleSubmit = useCallback(async () => {
const formError = validateForm()
if (formError) {
setErrorMessage(formError)
return
}
setErrorMessage("")
try {
// 阶段 1:上传音频
setPhase("uploading")
let fileToUpload: File
if (selectedFile) {
fileToUpload = selectedFile
} else {
fileToUpload = new File([recordedBlob!], `recorded-${Date.now()}.webm`, {
type: "audio/webm",
})
}
// 获取默认项目和素材库(后端 /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)
// 阶段 2:克隆
setPhase("cloning")
const result = await createVoiceClone({
name: voiceName.trim(),
description: voiceDescription.trim() || undefined,
audio_url: uploadResult.url,
})
// 阶段 3:完成
setPhase("done")
// 2秒后自动关闭
timerRef.current = setTimeout(() => {
onSuccess?.(toVoiceClone(result))
onClose()
}, 2000)
} catch (err) {
setPhase("input")
setErrorMessage(err instanceof Error ? err.message : "克隆失败,请重试")
}
}, [
validateForm,
selectedFile,
recordedBlob,
voiceName,
voiceDescription,
setPhase,
setErrorMessage,
onSuccess,
onClose,
])
return { handleSubmit }
}
@@ -0,0 +1,20 @@
import type { VoiceClone } from "@/api/voice-clone"
/** 弹窗阶段 */
export type ModalPhase = "input" | "uploading" | "cloning" | "done"
export interface CloneModalProps {
/** 弹窗是否可见 */
open: boolean
/** 关闭弹窗回调 */
onClose: () => void
/** 克隆成功回调(返回新创建的音色) */
onSuccess?: (voice: VoiceClone) => void
}
/** 进度步骤项 */
export interface ProgressStep {
key: string
label: string
icon: string
}
@@ -0,0 +1,25 @@
import { ACCEPTED_EXTENSIONS, MAX_FILE_SIZE } from "../constants/cloneModal"
/**
* 格式化录制时间 mm:ss
*/
export const formatRecordTime = (seconds: number): string => {
const m = Math.floor(seconds / 60)
const s = seconds % 60
return `${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}`
}
/**
* 验证上传的音频文件
* @returns 错误信息,null 表示验证通过
*/
export const validateFile = (file: File): string | null => {
const ext = file.name.split(".").pop()?.toLowerCase()
if (!ext || !ACCEPTED_EXTENSIONS.includes(ext)) {
return "不支持的音频格式,请上传 MP3、WAV 或 M4A 文件"
}
if (file.size > MAX_FILE_SIZE) {
return "文件大小超过 10MB,请压缩后重试"
}
return null
}
+2 -2
View File
@@ -71,7 +71,7 @@ export const NAV_ITEMS: NavItem[] = [
}, },
{ {
key: "editing-planner", key: "editing-planner",
label: "剪辑模板", label: "剪辑编辑器",
path: "/app/editing-planner", path: "/app/editing-planner",
icon: React.createElement(EditOutlined), icon: React.createElement(EditOutlined),
}, },
@@ -133,7 +133,7 @@ export const NAV_GROUPS: NavGroup[] = [
}, },
{ {
key: "editing-planner", key: "editing-planner",
label: "剪辑模板", label: "剪辑编辑器",
path: "/app/editing-planner", path: "/app/editing-planner",
icon: React.createElement(EditOutlined), icon: React.createElement(EditOutlined),
}, },
+5 -37
View File
@@ -20,7 +20,6 @@ import LibrarySidebar from "@/pages/assets/components/LibrarySidebar"
import AssetFilterBar from "@/pages/assets/components/AssetFilterBar" import AssetFilterBar from "@/pages/assets/components/AssetFilterBar"
import BatchOperationBar from "@/pages/assets/components/BatchOperationBar" import BatchOperationBar from "@/pages/assets/components/BatchOperationBar"
import AssetUploadZone from "@/pages/assets/components/AssetUploadZone" import AssetUploadZone from "@/pages/assets/components/AssetUploadZone"
import UploadQueuePanel from "@/pages/assets/components/UploadQueuePanel"
import AssetGridSection from "@/pages/assets/components/AssetGridSection" import AssetGridSection from "@/pages/assets/components/AssetGridSection"
import AssetModals from "@/pages/assets/components/AssetModals" import AssetModals from "@/pages/assets/components/AssetModals"
import { useAssetsData } from "@/pages/assets/hooks/useAssetsData" import { useAssetsData } from "@/pages/assets/hooks/useAssetsData"
@@ -70,30 +69,7 @@ const AssetLibrary: React.FC = () => {
}) })
/* ── 上传 ── */ /* ── 上传 ── */
const { const { uploading, uploadProgress, handleUpload } = useAssetUpload({ effectiveLibId })
uploadItems,
enqueueUploads,
retryUpload,
removeUpload,
clearFinished,
uploading,
activeCount,
pendingCount,
} = useAssetUpload({ effectiveLibId })
/* ── 上传中 asset_id → 进度/状态映射,合并进网格卡片展示真实进度 ── */
const uploadProgressMap = React.useMemo(() => {
const map = new Map<string, { progress: number; uploading: boolean }>()
for (const it of uploadItems) {
if (it.assetId && (it.status === "uploading" || it.status === "ingesting")) {
map.set(it.assetId, {
progress: it.status === "ingesting" ? 100 : it.progress,
uploading: it.status === "uploading",
})
}
}
return map
}, [uploadItems])
/* ── 选中态管理 ── */ /* ── 选中态管理 ── */
const { selectedIds, setSelectedIds, toggleSelect, selectAll, deselectAll } = useAssetSelection({ const { selectedIds, setSelectedIds, toggleSelect, selectAll, deselectAll } = useAssetSelection({
@@ -168,17 +144,8 @@ const AssetLibrary: React.FC = () => {
{/* 上传区域 */} {/* 上传区域 */}
<AssetUploadZone <AssetUploadZone
uploading={uploading} uploading={uploading}
activeCount={activeCount} uploadProgress={uploadProgress}
pendingCount={pendingCount} onUpload={handleUpload}
onUpload={enqueueUploads}
/>
{/* 上传队列:独立进度 + 失败重试/移除 */}
<UploadQueuePanel
items={uploadItems}
onRetry={retryUpload}
onRemove={removeUpload}
onClearFinished={clearFinished}
/> />
{/* 筛选栏 */} {/* 筛选栏 */}
@@ -213,7 +180,6 @@ const AssetLibrary: React.FC = () => {
assets={filteredAssets} assets={filteredAssets}
selectedIds={selectedIds} selectedIds={selectedIds}
diagnosingId={diagnosingId} diagnosingId={diagnosingId}
uploadProgressMap={uploadProgressMap}
onRetry={refetchAssets} onRetry={refetchAssets}
onToggleSelect={toggleSelect} onToggleSelect={toggleSelect}
onDiagnose={handleDiagnose} onDiagnose={handleDiagnose}
@@ -225,6 +191,8 @@ const AssetLibrary: React.FC = () => {
{/* ─── 弹窗集合 ─── */} {/* ─── 弹窗集合 ─── */}
<AssetModals <AssetModals
uploading={uploading}
uploadProgress={uploadProgress}
createModalOpen={createModalOpen} createModalOpen={createModalOpen}
onCreateModalCancel={() => setCreateModalOpen(false)} onCreateModalCancel={() => setCreateModalOpen(false)}
onCreateModalOk={handleCreateLibrary} onCreateModalOk={handleCreateLibrary}
+72 -243
View File
@@ -147,52 +147,44 @@
/* ============================================================ /* ============================================================
上传区域 上传区域
============================================================ */ ============================================================ */
.xx-asset-upload-entry { .xx-asset-upload-zone {
display: flex; border: 2px dashed var(--border-color);
align-items: center; border-radius: var(--radius-lg);
gap: var(--space-md); padding: var(--space-2xl) var(--space-xl);
flex-wrap: wrap; text-align: center;
padding: var(--space-sm) var(--space-md); background: var(--bg-secondary);
border: 1px dashed transparent; cursor: pointer;
border-radius: var(--radius-md);
transition: var(--transition-all); transition: var(--transition-all);
} }
.xx-asset-upload-entry-dragover { .xx-asset-upload-zone:hover {
border-color: var(--primary-color); border-color: var(--primary-color);
background: var(--primary-soft); background: var(--primary-soft);
} }
.xx-asset-upload-btn { .xx-asset-upload-zone:active {
display: inline-flex; border-style: solid;
align-items: center; transform: scale(0.99);
gap: var(--space-xs); box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.06);
padding: 6px 16px; }
font-size: var(--font-size-sm);
.xx-asset-upload-icon {
font-size: 40px;
margin-bottom: var(--space-sm);
color: var(--primary-color);
}
.xx-asset-upload-text {
font-size: var(--font-size-base) !important;
color: var(--text-primary) !important;
margin: 0 0 var(--space-xs) !important;
font-weight: var(--font-weight-medium); font-weight: var(--font-weight-medium);
color: var(--text-inverse);
background: var(--primary-color);
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
transition: var(--transition-all);
white-space: nowrap;
} }
.xx-asset-upload-btn:hover { .xx-asset-upload-hint {
opacity: 0.9; font-size: var(--font-size-sm) !important;
} color: var(--text-tertiary) !important;
margin: 0 !important;
.xx-asset-upload-btn:active {
transform: scale(0.98);
}
.xx-asset-upload-status {
display: inline-flex;
align-items: center;
gap: var(--space-xs);
font-size: var(--font-size-xs);
color: var(--text-tertiary);
} }
/* ============================================================ /* ============================================================
@@ -223,7 +215,7 @@
============================================================ */ ============================================================ */
.xx-asset-grid { .xx-asset-grid {
display: grid; display: grid;
grid-template-columns: repeat(6, 1fr); grid-template-columns: repeat(4, 1fr);
gap: 14px; gap: 14px;
} }
@@ -242,7 +234,7 @@
.xx-asset-card:hover { .xx-asset-card:hover {
border-color: var(--primary-color); border-color: var(--primary-color);
box-shadow: var(--shadow-sm); box-shadow: var(--shadow-sm);
transform: translateY(-1px); transform: translateY(-2px);
} }
.xx-asset-card:active { .xx-asset-card:active {
@@ -252,7 +244,7 @@
/* 缩略图 */ /* 缩略图 */
.xx-asset-thumb { .xx-asset-thumb {
aspect-ratio: 3 / 4; aspect-ratio: 9 / 16;
position: relative; position: relative;
overflow: hidden; overflow: hidden;
display: grid; display: grid;
@@ -268,22 +260,22 @@
} }
.xx-asset-thumb-placeholder { .xx-asset-thumb-placeholder {
font-size: var(--font-size-xl); font-size: var(--font-size-3xl);
opacity: 0.7; opacity: 0.7;
} }
/* 播放按钮 */ /* 播放按钮 */
.xx-asset-play { .xx-asset-play {
position: absolute; position: absolute;
width: 32px; width: 40px;
height: 32px; height: 40px;
border-radius: var(--radius-full); border-radius: var(--radius-full);
background: rgba(255, 255, 255, 0.25); background: rgba(255, 255, 255, 0.25);
backdrop-filter: blur(4px); backdrop-filter: blur(4px);
display: grid; display: grid;
place-items: center; place-items: center;
color: var(--text-inverse); color: var(--text-inverse);
font-size: var(--font-size-sm); font-size: var(--font-size-md);
transition: var(--transition-all); transition: var(--transition-all);
} }
@@ -340,8 +332,8 @@
position: absolute; position: absolute;
bottom: var(--space-sm, 8px); bottom: var(--space-sm, 8px);
right: var(--space-sm, 8px); right: var(--space-sm, 8px);
width: 24px; width: 28px;
height: 24px; height: 28px;
border-radius: var(--radius-full, 999px); border-radius: var(--radius-full, 999px);
background: rgba(0, 0, 0, 0.45); background: rgba(0, 0, 0, 0.45);
backdrop-filter: blur(4px); backdrop-filter: blur(4px);
@@ -388,12 +380,12 @@
/* 卡片信息 */ /* 卡片信息 */
.xx-asset-info { .xx-asset-info {
padding: 8px; padding: 12px;
} }
.xx-asset-name { .xx-asset-name {
margin: 0 0 4px; margin: 0 0 6px;
font-size: var(--font-size-xs); font-size: var(--font-size-sm);
font-weight: var(--font-weight-semibold); font-weight: var(--font-weight-semibold);
color: var(--text-primary); color: var(--text-primary);
white-space: nowrap; white-space: nowrap;
@@ -405,67 +397,9 @@
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
gap: var(--space-xs);
font-size: var(--font-size-xs); font-size: var(--font-size-xs);
color: var(--text-secondary); color: var(--text-secondary);
margin-bottom: 4px; margin-bottom: var(--space-sm);
min-width: 0;
}
/* 状态标签行:标签过长省略 */
.xx-asset-meta-status {
display: inline-flex;
align-items: center;
min-width: 0;
}
.xx-asset-meta-status .xx-status-pill {
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.xx-asset-meta-duration {
flex-shrink: 0;
color: var(--text-tertiary);
font-variant-numeric: tabular-nums;
}
/* 余量标签独占一行 */
.xx-asset-meta-usage {
justify-content: flex-start;
margin-bottom: var(--space-xs);
}
/* 视频素材余量角标(仅状态展示,不影响卡片操作) */
.xx-asset-usage-badge {
display: inline-flex;
align-items: center;
padding: 1px 6px;
border-radius: var(--radius-full);
font-size: 10px;
font-weight: var(--font-weight-medium);
line-height: 1.5;
white-space: nowrap;
}
/* 已用尽:红色实心 */
.xx-asset-usage-badge-exhausted {
background: var(--error-color);
color: var(--text-inverse);
}
/* 即将用尽:红色软底 */
.xx-asset-usage-badge-warning {
background: var(--error-soft);
color: var(--error-color);
}
/* 已用 xx%:橙色软底 */
.xx-asset-usage-badge-ratio {
background: var(--warning-soft);
color: var(--warning-color);
} }
/* 诊断按钮 */ /* 诊断按钮 */
@@ -509,16 +443,12 @@
.xx-status-pill { .xx-status-pill {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 2px; gap: var(--space-xs);
padding: 1px 6px; padding: var(--space-xxs) var(--space-sm);
border-radius: var(--radius-full); border-radius: var(--radius-full);
font-size: 10px; font-size: var(--font-size-xs);
font-weight: var(--font-weight-medium); font-weight: var(--font-weight-medium);
line-height: 1.5; line-height: 1.5;
white-space: nowrap;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
} }
.xx-status-pill-ok { .xx-status-pill-ok {
@@ -662,7 +592,7 @@
============================================================ */ ============================================================ */
.xx-assets-skeleton-grid { .xx-assets-skeleton-grid {
display: grid; display: grid;
grid-template-columns: repeat(6, 1fr); grid-template-columns: repeat(4, 1fr);
gap: 14px; gap: 14px;
} }
@@ -677,10 +607,6 @@
.xx-asset-grid { .xx-asset-grid {
grid-template-columns: repeat(3, 1fr); grid-template-columns: repeat(3, 1fr);
} }
.xx-assets-skeleton-grid {
grid-template-columns: repeat(3, 1fr);
}
} }
@media (max-width: 1200px) { @media (max-width: 1200px) {
@@ -704,10 +630,6 @@
.xx-asset-grid { .xx-asset-grid {
grid-template-columns: repeat(3, 1fr); grid-template-columns: repeat(3, 1fr);
} }
.xx-assets-skeleton-grid {
grid-template-columns: repeat(3, 1fr);
}
} }
@media (max-width: 768px) { @media (max-width: 768px) {
@@ -719,10 +641,6 @@
grid-template-columns: repeat(2, 1fr); grid-template-columns: repeat(2, 1fr);
} }
.xx-assets-skeleton-grid {
grid-template-columns: repeat(2, 1fr);
}
.xx-assets-filters { .xx-assets-filters {
flex-direction: column; flex-direction: column;
align-items: stretch; align-items: stretch;
@@ -742,132 +660,43 @@
.xx-asset-grid { .xx-asset-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.xx-assets-skeleton-grid {
grid-template-columns: 1fr;
}
} }
/* ─── 上传队列面板 ─── */ /* ─── 上传进度弹窗 ─── */
.xx-upload-queue { .xx-upload-progress-modal .ant-modal-content {
margin-top: 12px; padding: 24px 16px 20px;
border: 1px solid var(--border-primary, #e5e7eb); border-radius: 16px;
border-radius: 12px;
background: #fff;
overflow: hidden;
} }
.xx-upload-queue-header { .xx-upload-progress-body {
display: flex; display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 14px;
border-bottom: 1px solid var(--border-primary, #eef2f7);
background: #f8fafc;
}
.xx-upload-queue-title {
font-size: 13px;
font-weight: 600;
color: var(--text-primary, #1e293b);
}
.xx-upload-queue-list {
max-height: 240px;
overflow-y: auto;
}
.xx-upload-queue-item {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 10px 14px;
}
.xx-upload-queue-item + .xx-upload-queue-item {
border-top: 1px solid var(--border-primary, #f1f5f9);
}
.xx-upload-queue-icon {
padding-top: 2px;
font-size: 15px;
}
.xx-upload-queue-body {
flex: 1;
min-width: 0;
}
.xx-upload-queue-name {
font-size: 13px;
color: var(--text-primary, #1e293b);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.xx-upload-queue-progress {
margin-top: 6px;
height: 4px;
border-radius: 2px;
background: var(--border-primary, #e5e7eb);
overflow: hidden;
}
.xx-upload-queue-progress-bar {
height: 100%;
border-radius: 2px;
background: var(--primary-color, #6366f1);
transition: width 0.25s ease;
}
.xx-upload-queue-status {
margin-top: 4px;
font-size: 12px;
color: var(--text-tertiary, #94a3b8);
}
.xx-upload-queue-error .xx-upload-queue-status {
color: #ef4444;
}
.xx-upload-queue-actions {
display: flex;
gap: 6px;
}
.xx-upload-queue-btn {
border: none;
background: none;
cursor: pointer;
font-size: 13px;
color: var(--text-tertiary, #94a3b8);
padding: 2px 4px;
}
.xx-upload-queue-btn:hover {
color: var(--primary-color, #6366f1);
}
/* ─── 素材卡片上传中遮罩进度条 ─── */
.xx-asset-thumb-uploading {
flex-direction: column; flex-direction: column;
gap: 8px; align-items: center;
gap: 16px;
padding: 8px 0;
} }
.xx-asset-upload-bar { .xx-upload-progress-ring {
width: 70%; display: block;
height: 4px;
border-radius: 2px;
background: rgba(255, 255, 255, 0.35);
overflow: hidden;
} }
.xx-asset-upload-bar-inner { .xx-upload-progress-text {
height: 100%; display: flex;
border-radius: 2px; flex-direction: column;
background: #fff; align-items: center;
transition: width 0.25s ease; gap: 4px;
}
.xx-upload-progress-pct {
font-size: 22px;
font-weight: 700;
color: var(--primary-color, #6366f1);
line-height: 1;
}
.xx-upload-progress-label {
font-size: 13px;
color: var(--text-secondary, #6b7280);
} }
/* ─── 批量打标签弹窗 ─── */ /* ─── 批量打标签弹窗 ─── */
@@ -8,7 +8,7 @@ import {
CloseCircleOutlined, CloseCircleOutlined,
} from "@ant-design/icons" } from "@ant-design/icons"
import { Popconfirm } from "antd" import { Popconfirm } from "antd"
import { getUsageBadge, type AssetItem } from "@/pages/assets/types" import type { AssetItem } from "@/pages/assets/types"
import { thumbGradient } from "@/pages/assets/utils/asset" import { thumbGradient } from "@/pages/assets/utils/asset"
import { kindIcon } from "@/pages/assets/utils/kindIcon" import { kindIcon } from "@/pages/assets/utils/kindIcon"
import { StatusPill } from "./AssetSkeleton" import { StatusPill } from "./AssetSkeleton"
@@ -20,8 +20,6 @@ export interface AssetCardProps {
asset: AssetItem asset: AssetItem
selected: boolean selected: boolean
diagnosing?: boolean diagnosing?: boolean
/** 上传中实时进度(仅 uploading 态有值;ingesting 后由后端状态接管) */
uploadProgress?: { progress: number; uploading: boolean }
onToggle: () => void onToggle: () => void
onDiagnose: () => void onDiagnose: () => void
onPlay: () => void onPlay: () => void
@@ -32,128 +30,99 @@ const AssetCard: React.FC<AssetCardProps> = ({
asset, asset,
selected, selected,
diagnosing, diagnosing,
uploadProgress,
onToggle, onToggle,
onDiagnose, onDiagnose,
onPlay, onPlay,
onDelete, onDelete,
}) => { }) => (
const isUploading = !!uploadProgress?.uploading <div className={`xx-asset-card${selected ? " xx-asset-card-selected" : ""}`} onClick={onToggle}>
// 视频素材余量角标(已用尽/即将用尽/已用 xx%);非视频或字段缺失返回 null {/* 缩略图区 */}
const usageBadge = getUsageBadge(asset) <div className="xx-asset-thumb" style={{ background: thumbGradient(asset.kind) }}>
return ( {asset.thumbUrl ? (
<div className={`xx-asset-card${selected ? " xx-asset-card-selected" : ""}`} onClick={onToggle}> <img src={asset.thumbUrl} alt={asset.name} />
{/* 缩略图区 */} ) : (
<div className="xx-asset-thumb" style={{ background: thumbGradient(asset.kind) }}> <span className="xx-asset-thumb-placeholder">
{asset.thumbUrl ? ( {asset.loading ? <LoadingOutlined /> : kindIcon(asset.kind)}
<img src={asset.thumbUrl} alt={asset.name} /> </span>
) : ( )}
<span className="xx-asset-thumb-placeholder">
{asset.loading ? <LoadingOutlined /> : kindIcon(asset.kind)}
</span>
)}
{/* 上传中遮罩:真实进度百分比 + 进度条 */} {/* 处理中遮罩 */}
{isUploading && ( {asset.loading && (
<div className="xx-asset-thumb-overlay xx-asset-thumb-uploading"> <div className="xx-asset-thumb-overlay xx-asset-thumb-processing">
<LoadingOutlined /> <LoadingOutlined />
<span> {uploadProgress?.progress ?? 0}%</span> <span></span>
<div className="xx-asset-upload-bar">
<div
className="xx-asset-upload-bar-inner"
style={{ width: `${uploadProgress?.progress ?? 0}%` }}
/>
</div>
</div>
)}
{/* 转码/处理中遮罩 */}
{asset.loading && !isUploading && (
<div className="xx-asset-thumb-overlay xx-asset-thumb-processing">
<LoadingOutlined />
<span></span>
</div>
)}
{/* 失败状态标识 */}
{asset.status === "bad" && asset.statusLabel === "处理失败" && (
<div className="xx-asset-thumb-overlay xx-asset-thumb-failed">
<CloseCircleOutlined />
<span></span>
</div>
)}
{/* 视频/配音类显示播放按钮(处理中/失败不显示) */}
{asset.kind === "video" && !asset.loading && asset.status !== "bad" && (
<span
className="xx-asset-play"
onClick={(e) => {
e.stopPropagation()
onPlay()
}}
>
<PlayCircleOutlined />
</span>
)}
{/* 删除按钮 */}
<Popconfirm
title="确认删除"
description="删除后不可恢复,确定要删除这个素材吗?"
onConfirm={(e) => {
e?.stopPropagation()
onDelete()
}}
onCancel={(e) => e?.stopPropagation()}
okText="删除"
cancelText="取消"
okButtonProps={{ danger: true }}
>
<span className="xx-asset-delete" onClick={(e) => e.stopPropagation()}>
<DeleteOutlined />
</span>
</Popconfirm>
{/* 选中态勾选 */}
{selected && (
<span className="xx-asset-check">
<CheckOutlined />
</span>
)}
</div>
{/* 信息区 */}
<div className="xx-asset-info">
<p className="xx-asset-name" title={asset.name}>
{asset.name}
</p>
<div className="xx-asset-meta">
<span className="xx-asset-meta-status">
<StatusPill status={asset.status} label={asset.statusLabel} />
</span>
{asset.duration && <span className="xx-asset-meta-duration">{asset.duration}</span>}
</div> </div>
{usageBadge && ( )}
<div className="xx-asset-meta xx-asset-meta-usage">
<span className={`xx-asset-usage-badge xx-asset-usage-badge-${usageBadge.variant}`}> {/* 失败状态标识 */}
{usageBadge.label} {asset.status === "bad" && asset.statusLabel === "处理失败" && (
</span> <div className="xx-asset-thumb-overlay xx-asset-thumb-failed">
</div> <CloseCircleOutlined />
)} <span></span>
<button </div>
className={`xx-asset-diagnose-btn${diagnosing ? " xx-asset-diagnose-btn-loading" : ""}`} )}
disabled={diagnosing || asset.loading || asset.status === "bad"}
{/* 视频/配音类显示播放按钮(处理中/失败不显示) */}
{asset.kind === "video" && !asset.loading && asset.status !== "bad" && (
<span
className="xx-asset-play"
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation()
onDiagnose() onPlay()
}} }}
> >
{diagnosing ? <LoadingOutlined /> : <ExperimentOutlined />} <PlayCircleOutlined />
{diagnosing ? "诊断中..." : "诊断"} </span>
</button> )}
</div>
{/* 删除按钮 */}
<Popconfirm
title="确认删除"
description="删除后不可恢复,确定要删除这个素材吗?"
onConfirm={(e) => {
e?.stopPropagation()
onDelete()
}}
onCancel={(e) => e?.stopPropagation()}
okText="删除"
cancelText="取消"
okButtonProps={{ danger: true }}
>
<span className="xx-asset-delete" onClick={(e) => e.stopPropagation()}>
<DeleteOutlined />
</span>
</Popconfirm>
{/* 选中态勾选 */}
{selected && (
<span className="xx-asset-check">
<CheckOutlined />
</span>
)}
</div> </div>
)
} {/* 信息区 */}
<div className="xx-asset-info">
<p className="xx-asset-name" title={asset.name}>
{asset.name}
</p>
<div className="xx-asset-meta">
<StatusPill status={asset.status} label={asset.statusLabel} />
{asset.duration && <span>{asset.duration}</span>}
</div>
<button
className={`xx-asset-diagnose-btn${diagnosing ? " xx-asset-diagnose-btn-loading" : ""}`}
disabled={diagnosing || asset.loading || asset.status === "bad"}
onClick={(e) => {
e.stopPropagation()
onDiagnose()
}}
>
{diagnosing ? <LoadingOutlined /> : <ExperimentOutlined />}
{diagnosing ? "诊断中..." : "诊断"}
</button>
</div>
</div>
)
export default AssetCard export default AssetCard
@@ -8,9 +8,6 @@ import type { AssetItem } from "../types"
import AssetCard from "./AssetCard" import AssetCard from "./AssetCard"
import { SkeletonCard } from "./AssetSkeleton" import { SkeletonCard } from "./AssetSkeleton"
/** 上传中素材的实时进度(asset_id → 进度信息),由上传队列合并到卡片 */
export type UploadProgressMap = Map<string, { progress: number; uploading: boolean }>
export interface AssetGridSectionProps { export interface AssetGridSectionProps {
loading: boolean loading: boolean
error: boolean error: boolean
@@ -18,7 +15,6 @@ export interface AssetGridSectionProps {
assets: AssetItem[] assets: AssetItem[]
selectedIds: Set<string> selectedIds: Set<string>
diagnosingId: string | null diagnosingId: string | null
uploadProgressMap?: UploadProgressMap
onRetry?: () => void onRetry?: () => void
onToggleSelect: (id: string) => void onToggleSelect: (id: string) => void
onDiagnose: (asset: AssetItem) => void onDiagnose: (asset: AssetItem) => void
@@ -33,7 +29,6 @@ export const AssetGridSection: React.FC<AssetGridSectionProps> = ({
assets, assets,
selectedIds, selectedIds,
diagnosingId, diagnosingId,
uploadProgressMap,
onRetry, onRetry,
onToggleSelect, onToggleSelect,
onDiagnose, onDiagnose,
@@ -75,7 +70,6 @@ export const AssetGridSection: React.FC<AssetGridSectionProps> = ({
asset={asset} asset={asset}
selected={selectedIds.has(asset.id)} selected={selectedIds.has(asset.id)}
diagnosing={diagnosingId === asset.id} diagnosing={diagnosingId === asset.id}
uploadProgress={uploadProgressMap?.get(asset.id)}
onToggle={() => onToggleSelect(asset.id)} onToggle={() => onToggleSelect(asset.id)}
onDiagnose={() => onDiagnose(asset)} onDiagnose={() => onDiagnose(asset)}
onPlay={() => onPlay(asset)} onPlay={() => onPlay(asset)}
@@ -11,9 +11,12 @@ import BatchTagModal from "./BatchTagModal"
import BatchClassifyModal from "./BatchClassifyModal" import BatchClassifyModal from "./BatchClassifyModal"
import BatchMarkModal from "./BatchMarkModal" import BatchMarkModal from "./BatchMarkModal"
import ResultDrawer from "./ResultDrawer" import ResultDrawer from "./ResultDrawer"
import UploadProgressModal from "./UploadProgressModal"
export interface AssetModalsProps { export interface AssetModalsProps {
/* 上传进度 */ /* 上传进度 */
uploading: boolean
uploadProgress: number
/* 新建视频库 */ /* 新建视频库 */
createModalOpen: boolean createModalOpen: boolean
@@ -65,6 +68,8 @@ export interface AssetModalsProps {
} }
export const AssetModals: React.FC<AssetModalsProps> = ({ export const AssetModals: React.FC<AssetModalsProps> = ({
uploading,
uploadProgress,
createModalOpen, createModalOpen,
onCreateModalCancel, onCreateModalCancel,
onCreateModalOk, onCreateModalOk,
@@ -104,6 +109,9 @@ export const AssetModals: React.FC<AssetModalsProps> = ({
}) => { }) => {
return ( return (
<> <>
{/* 上传进度弹窗 */}
<UploadProgressModal open={uploading} progress={uploadProgress} />
{/* 新建视频库弹窗 */} {/* 新建视频库弹窗 */}
<CreateLibraryModal <CreateLibraryModal
open={createModalOpen} open={createModalOpen}
@@ -1,91 +1,37 @@
/** /**
* AssetLibrary 上传入口(紧凑按钮模式) * AssetLibrary 上传拖拽区域
* - 点击按钮打开文件选择(多选),多文件入队由 useAssetUpload 队列控制(最多 3 路直传)
* - 拖拽文件到内容区任意位置同样触发上传(不再占用大面积虚线框)
*/ */
import React, { useRef, useState } from "react" import React from "react"
import { PlusOutlined, CloudUploadOutlined } from "@ant-design/icons" import { Upload } from "antd"
import { InboxOutlined } from "@ant-design/icons"
export interface AssetUploadZoneProps { export interface AssetUploadZoneProps {
uploading: boolean uploading: boolean
activeCount: number uploadProgress: number
pendingCount: number onUpload: (file: File) => void
onUpload: (files: File[]) => void
} }
export const AssetUploadZone: React.FC<AssetUploadZoneProps> = ({ export const AssetUploadZone: React.FC<AssetUploadZoneProps> = ({ uploading, onUpload }) => {
uploading,
activeCount,
pendingCount,
onUpload,
}) => {
const inputRef = useRef<HTMLInputElement>(null)
// dragenter/dragleave 在经过子元素时会成对触发,用计数器避免高亮闪烁;
// 计数器归零(拖拽真正离开容器)才取消高亮
const dragDepthRef = useRef(0)
const [dragOver, setDragOver] = useState(false)
const pickFiles = (list: FileList | null) => {
if (!list || list.length === 0) return
onUpload(Array.from(list))
}
return ( return (
<div <Upload.Dragger
className={`xx-asset-upload-entry${dragOver ? " xx-asset-upload-entry-dragover" : ""}`} beforeUpload={(file) => {
onDragEnter={(e) => { onUpload(file as File)
e.preventDefault() return false
dragDepthRef.current += 1
setDragOver(true)
}}
onDragOver={(e) => {
e.preventDefault()
}}
onDragLeave={(e) => {
e.preventDefault()
dragDepthRef.current = Math.max(0, dragDepthRef.current - 1)
if (dragDepthRef.current === 0) {
setDragOver(false)
}
}}
onDrop={(e) => {
e.preventDefault()
dragDepthRef.current = 0
setDragOver(false)
pickFiles(e.dataTransfer.files)
}} }}
showUploadList={false}
multiple
accept="video/*,image/*"
> >
<button <div className="xx-asset-upload-zone">
type="button" <p className="xx-asset-upload-icon">
className="xx-asset-upload-btn" <InboxOutlined />
onClick={() => inputRef.current?.click()} </p>
> <p className="xx-asset-upload-text">
<PlusOutlined /> {uploading ? "上传中..." : "点击或拖拽文件到此区域上传"}
</p>
</button> <p className="xx-asset-upload-hint"> 2GB</p>
<span className="xx-asset-upload-status"> </div>
{uploading ? ( </Upload.Dragger>
<>
<CloudUploadOutlined />
{activeCount} {pendingCount > 0 ? `,排队 ${pendingCount}` : ""}
</>
) : (
"视频、图片均可,单文件不超过 2GB;也可直接拖拽文件到此区域"
)}
</span>
<input
ref={inputRef}
type="file"
multiple
accept="video/*,image/*"
style={{ display: "none" }}
onChange={(e) => {
pickFiles(e.target.files)
// 允许连续选择同一文件
e.target.value = ""
}}
/>
</div>
) )
} }
@@ -0,0 +1,56 @@
import React from "react"
import { Modal as AntModal } from "antd"
/* ============================================================
* UploadProgressModal — 上传进度弹窗(圆形动画 + 百分比)
* ============================================================ */
export interface UploadProgressModalProps {
open: boolean
progress: number
}
const UploadProgressModal: React.FC<UploadProgressModalProps> = ({ open, progress }) => (
<AntModal
open={open}
footer={null}
closable={false}
centered
width={260}
maskClosable={false}
className="xx-upload-progress-modal"
>
<div className="xx-upload-progress-body">
<svg className="xx-upload-progress-ring" viewBox="0 0 120 120" width={120} height={120}>
{/* 背景圆环 */}
<circle
cx="60"
cy="60"
r="52"
fill="none"
stroke="var(--border-primary, #e5e7eb)"
strokeWidth="8"
/>
{/* 进度圆弧 */}
<circle
cx="60"
cy="60"
r="52"
fill="none"
stroke="var(--primary-color, #6366f1)"
strokeWidth="8"
strokeLinecap="round"
strokeDasharray={`${2 * Math.PI * 52}`}
strokeDashoffset={`${2 * Math.PI * 52 * (1 - progress / 100)}`}
transform="rotate(-90 60 60)"
style={{ transition: "stroke-dashoffset 0.3s ease" }}
/>
</svg>
<div className="xx-upload-progress-text">
<span className="xx-upload-progress-pct">{progress}%</span>
<span className="xx-upload-progress-label"></span>
</div>
</div>
</AntModal>
)
export default UploadProgressModal
@@ -1,117 +0,0 @@
/**
* 上传队列面板
* 展示批量上传中每个文件的独立状态/进度;失败可重试、可移除、可清空已完成。
* 上传中的素材卡片同时也会出现在素材网格(后端 prepare 预建 asset),
* 此面板用于展示真实传输进度与失败重试入口。
*/
import React from "react"
import {
LoadingOutlined,
CheckCircleFilled,
CloseCircleFilled,
ReloadOutlined,
CloseOutlined,
} from "@ant-design/icons"
import type { UploadItem } from "../hooks/useAssetUpload"
export interface UploadQueuePanelProps {
items: UploadItem[]
onRetry: (tempId: string) => void
onRemove: (tempId: string) => void
onClearFinished: () => void
}
const STATUS_TEXT: Record<UploadItem["status"], string> = {
preparing: "排队中…",
uploading: "上传中",
ingesting: "转码中…",
done: "已完成",
error: "上传失败",
}
const UploadQueuePanel: React.FC<UploadQueuePanelProps> = ({
items,
onRetry,
onRemove,
onClearFinished,
}) => {
if (items.length === 0) return null
const finishedCount = items.filter((it) => it.status === "done").length
return (
<div className="xx-upload-queue">
<div className="xx-upload-queue-header">
<span className="xx-upload-queue-title">
{items.length}
{finishedCount > 0 ? `,已完成 ${finishedCount}` : ""}
</span>
{finishedCount > 0 && (
<button type="button" className="xx-link-btn" onClick={onClearFinished}>
</button>
)}
</div>
<div className="xx-upload-queue-list">
{items.map((it) => {
const isActive = it.status === "preparing" || it.status === "uploading"
const showProgress = it.status === "uploading" || it.status === "ingesting"
return (
<div key={it.tempId} className={`xx-upload-queue-item xx-upload-queue-${it.status}`}>
<span className="xx-upload-queue-icon">
{it.status === "done" || it.duplicated ? (
<CheckCircleFilled style={{ color: "#22c55e" }} />
) : it.status === "error" ? (
<CloseCircleFilled style={{ color: "#ef4444" }} />
) : (
<LoadingOutlined style={{ color: "var(--primary-color)" }} />
)}
</span>
<div className="xx-upload-queue-body">
<div className="xx-upload-queue-name" title={it.fileName}>
{it.fileName}
</div>
{showProgress ? (
<div className="xx-upload-queue-progress">
<div
className="xx-upload-queue-progress-bar"
style={{ width: `${it.status === "ingesting" ? 100 : it.progress}%` }}
/>
</div>
) : null}
<div className="xx-upload-queue-status">
{it.duplicated ? "素材已存在,已跳过" : STATUS_TEXT[it.status]}
{it.status === "uploading" ? ` ${it.progress}%` : ""}
{it.status === "error" && it.error ? `${it.error}` : ""}
</div>
</div>
<span className="xx-upload-queue-actions">
{it.status === "error" && (
<button
type="button"
className="xx-upload-queue-btn"
title="重试"
onClick={() => onRetry(it.tempId)}
>
<ReloadOutlined />
</button>
)}
{(it.status === "error" || it.status === "done") && !isActive && (
<button
type="button"
className="xx-upload-queue-btn"
title="移除"
onClick={() => onRemove(it.tempId)}
>
<CloseOutlined />
</button>
)}
</span>
</div>
)
})}
</div>
</div>
)
}
export default UploadQueuePanel
+44 -175
View File
@@ -1,196 +1,65 @@
import { useState, useCallback, useRef, useEffect } from "react" import { useState, useCallback } from "react"
import { useQueryClient } from "@tanstack/react-query" import { useQueryClient } from "@tanstack/react-query"
import { message } from "antd" import { message } from "antd"
import { prepareDirectUploadHandle, type DirectUploadHandle } from "@/api/assets" import { uploadAssetDirect } from "@/api/assets"
import { MAX_FILE_SIZE } from "../constants" import { MAX_FILE_SIZE, LARGE_FILE_THRESHOLD } from "../constants"
/** 单文件上传状态机 */
export type UploadItemStatus = "preparing" | "uploading" | "ingesting" | "done" | "error"
export interface UploadItem {
/** 前端临时 idprepare 前无 asset_id 时用) */
tempId: string
file: File
fileName: string
/** 进度 0~100(仅直传阶段有真实进度) */
progress: number
status: UploadItemStatus
/** 后端 prepare 预建的 asset id(旧后端可能为空) */
assetId?: string
/** 去重命中:complete 返回 duplicated,标记完成但不产生新素材 */
duplicated?: boolean
error?: string
}
/** 批量直传最大并发数,避免多文件瓜分上行带宽 */
const MAX_CONCURRENT = 3
/** /**
* 素材批量上传 Hook * 素材上传 Hook
* - prepare 阶段后端预建 status=uploading 的 asset,前端拿到 asset_id 立即刷新列表 * 封装上传状态、进度管理和上传逻辑
* - OSS 直传并发限制为 3,其余排队;每个文件独立进度/状态
* - complete 后素材进入转码(ingesting/processing),由列表轮询反映
* - 失败卡片支持重试/移除
*/ */
export function useAssetUpload({ effectiveLibId }: { effectiveLibId: string }) { interface UseAssetUploadProps {
effectiveLibId: string
}
export function useAssetUpload({ effectiveLibId }: UseAssetUploadProps) {
const queryClient = useQueryClient() const queryClient = useQueryClient()
const [items, setItems] = useState<UploadItem[]>([]) const [uploading, setUploading] = useState(false)
const itemsRef = useRef<UploadItem[]>([]) const [uploadProgress, setUploadProgress] = useState(0)
itemsRef.current = items
const updateItem = useCallback((tempId: string, patch: Partial<UploadItem>) => { const handleUpload = useCallback(
setItems((prev) => prev.map((it) => (it.tempId === tempId ? { ...it, ...patch } : it))) async (file: File) => {
}, []) if (file.size > MAX_FILE_SIZE) {
message.error(`文件 "${file.name}" 超过 2GB 限制`)
/** 刷新素材列表(prepare 后/complete 后调用,让卡片即时出现/流转) */ return
const refreshList = useCallback(() => {
// 使用 refetchQueries 强制立即重新获取,避免 staleTime 导致延迟
if (effectiveLibId) {
queryClient.refetchQueries({ queryKey: ["assets", effectiveLibId] })
}
queryClient.invalidateQueries({ queryKey: ["asset-libraries"] })
}, [queryClient, effectiveLibId])
/** 执行单个文件的完整上传流程(prepare→transfer→complete */
const runUpload = useCallback(
async (item: UploadItem, handle?: DirectUploadHandle) => {
try {
// 1. prepare(重试时复用已准备的 handle 也行,但签名可能过期,重新 prepare 最稳)
const h =
handle ??
(await prepareDirectUploadHandle({ file: item.file, library_id: effectiveLibId }))
if (h.prepared.asset_id) {
updateItem(item.tempId, {
status: "uploading",
assetId: h.prepared.asset_id,
progress: 0,
})
// 预建 asset 已入库,立即刷新让「上传中」卡片出现在网格
refreshList()
} else {
updateItem(item.tempId, { status: "uploading", progress: 0 })
}
// 2. OSS 直传(真实进度)
await h.transfer((pct) => updateItem(item.tempId, { progress: pct }))
// 3. complete:后端创建 ingest job,素材进入转码
updateItem(item.tempId, { status: "ingesting", progress: 100 })
const result = await h.complete()
refreshList()
if (result.duplicated) {
updateItem(item.tempId, { status: "done", duplicated: true, assetId: result.asset_id })
message.info(`"${item.fileName}" 与素材库已有内容相同,已跳过`)
} else {
updateItem(item.tempId, { status: "done" })
message.success(`"${item.fileName}" 上传完成,正在转码处理`)
}
} catch (err: unknown) {
const detail = err instanceof Error ? err.message : "上传失败"
console.error("[useAssetUpload] 上传失败:", item.fileName, err)
updateItem(item.tempId, { status: "error", error: detail })
message.error(`"${item.fileName}" 上传失败:${detail}`)
} }
},
[effectiveLibId, refreshList, updateItem],
)
/**
* 队列调度:把并发槽塞满(同时在途的 prepare+transfer 不超过 MAX_CONCURRENT)。
* runUpload 在 await prepare 期间 state 仍是 preparing,多个并发 pump 若只看 state
* 会重复认领同一项,因此用 claimedRef 记录已被认领的 tempId。
*/
const inFlightRef = useRef(0)
const claimedRef = useRef<Set<string>>(new Set())
const pumpRef = useRef<() => void>(() => {})
pumpRef.current = () => {
while (inFlightRef.current < MAX_CONCURRENT) {
const next = itemsRef.current.find(
(it) => it.status === "preparing" && !claimedRef.current.has(it.tempId),
)
if (!next) return
claimedRef.current.add(next.tempId)
inFlightRef.current += 1
void runUpload(next).finally(() => {
inFlightRef.current -= 1
claimedRef.current.delete(next.tempId)
// 一个任务结束(成功/失败)后继续拉起排队任务
setTimeout(() => pumpRef.current(), 0)
})
}
}
useEffect(() => {
pumpRef.current()
}, [items])
/** 入队一个或多个文件 */
const enqueueUploads = useCallback(
(files: File[]) => {
if (!effectiveLibId) { if (!effectiveLibId) {
message.warning("请先选择或创建一个视频库") message.warning("请先选择或创建一个视频库")
return return
} }
const valid: File[] = []
for (const file of files) { setUploading(true)
if (file.size > MAX_FILE_SIZE) { setUploadProgress(0)
message.error(`文件 "${file.name}" 超过 2GB 限制`) try {
continue if (file.size > LARGE_FILE_THRESHOLD) {
message.info(`大文件 "${file.name}" 将使用直传上传`)
} }
valid.push(file) await uploadAssetDirect({
file,
library_id: effectiveLibId,
onProgress: (pct) => setUploadProgress(pct),
})
message.success(`"${file.name}" 上传成功`)
queryClient.invalidateQueries({ queryKey: ["assets"] })
queryClient.invalidateQueries({ queryKey: ["asset-libraries"] })
} catch (err: unknown) {
const detail = err instanceof Error ? err.message : ""
console.error("[handleUpload] 上传失败:", err)
message.error(`"${file.name}" 上传失败${detail ? `${detail}` : ""}`)
// 错误时延迟关闭弹窗,让用户能看到错误提示
await new Promise((r) => setTimeout(r, 1500))
} finally {
setUploading(false)
setUploadProgress(0)
} }
if (valid.length === 0) return
const newItems: UploadItem[] = valid.map((file, idx) => ({
tempId: `${Date.now()}-${idx}-${Math.random().toString(36).slice(2, 8)}`,
file,
fileName: file.name,
progress: 0,
status: "preparing",
}))
setItems((prev) => [...prev, ...newItems])
}, },
[effectiveLibId], [effectiveLibId, queryClient],
) )
/** 重试失败任务 */
const retryUpload = useCallback(
(tempId: string) => {
const target = itemsRef.current.find((it) => it.tempId === tempId)
if (!target) return
updateItem(tempId, { status: "preparing", progress: 0, error: undefined })
// 状态更新后由 useEffect 触发 pump
},
[updateItem],
)
/** 从上传列表移除(已进入转码的由素材网格管理;这里只移除上传面板记录) */
const removeUpload = useCallback((tempId: string) => {
setItems((prev) => prev.filter((it) => it.tempId !== tempId))
}, [])
/** 清空已完成/去重记录 */
const clearFinished = useCallback(() => {
setItems((prev) => prev.filter((it) => it.status !== "done"))
}, [])
const activeCount = items.filter(
(it) => it.status === "preparing" || it.status === "uploading",
).length
const pendingCount = items.filter((it) => it.status === "preparing").length
const hasActive = activeCount > 0 || items.some((it) => it.status === "ingesting")
return { return {
uploadItems: items, uploading,
enqueueUploads, uploadProgress,
retryUpload, handleUpload,
removeUpload,
clearFinished,
/** 是否有进行中的上传(用于上传区文案) */
uploading: hasActive,
activeCount,
pendingCount,
} }
} }
@@ -45,21 +45,11 @@ export function useAssetsData() {
queryKey: ["assets", effectiveLibId], queryKey: ["assets", effectiveLibId],
queryFn: () => queryFn: () =>
getAssets(effectiveLibId, { getAssets(effectiveLibId, {
// 拉取所有非删除状态的素材,让用户上传后立刻能看到"上传中/处理中"的素材 // 拉取所有非删除状态的素材,让用户上传后立刻能看到"处理中"的素材
status: "ready,uploading,ingesting,processing,pending,error,failed", status: "ready,uploading,ingesting,processing,pending,error,failed",
}), }),
enabled: !!effectiveLibId, enabled: !!effectiveLibId,
staleTime: 30_000, staleTime: 30_000,
// 列表中存在上传中/转码中素材时每 3s 轮询;全部就绪后自动停止
refetchInterval: (query) => {
const data = query.state.data as { items: ApiAssetItem[] } | undefined
const items = data?.items ?? []
const processing = items.some((a) => {
const st = a.status ?? ""
return st === "uploading" || st === "ingesting" || st === "processing" || st === "pending"
})
return processing ? 3000 : false
},
}) })
const assets: AssetItem[] = useMemo( const assets: AssetItem[] = useMemo(
-32
View File
@@ -27,36 +27,6 @@ export interface AssetItem {
duration?: string duration?: string
size: number size: number
createdAt: string createdAt: string
/** 已切片段占用时长占比(0~1),后端字段缺失时为 undefined */
usedRatio?: number
/** 是否已彻底用尽(false 的素材不参与生成选片),字段缺失时视为可用 */
usable?: boolean
}
/** 素材余量角标状态(仅视频素材) */
export interface UsageBadge {
/** 角标文案 */
label: string
/** 样式变体:exhausted=红色实心,warning=红色软底,ratio=橙色软底 */
variant: "exhausted" | "warning" | "ratio"
}
/**
* 根据后端余量字段计算视频素材的余量角标;
* 非视频、字段缺失或已用占比 <50% 时不显示(返回 null)。
*/
export const getUsageBadge = (asset: {
kind?: AssetKind
usable?: boolean
usedRatio?: number
}): UsageBadge | null => {
if (asset.kind && asset.kind !== "video") return null
if (asset.usable === false) return { label: "已用尽", variant: "exhausted" }
const ratio = asset.usedRatio
if (ratio == null) return null
if (ratio >= 0.85) return { label: "即将用尽", variant: "warning" }
if (ratio >= 0.5) return { label: `已用 ${Math.round(ratio * 100)}%`, variant: "ratio" }
return null
} }
/** 根据 mime_type 推断前端 AssetKind */ /** 根据 mime_type 推断前端 AssetKind */
@@ -141,7 +111,5 @@ export const mapAsset = (item: ApiAssetItem): AssetItem => {
duration: metadata.duration != null ? formatDuration(metadata.duration as number) : undefined, duration: metadata.duration != null ? formatDuration(metadata.duration as number) : undefined,
size: item.file_size ? +(item.file_size / (1024 * 1024)).toFixed(1) : 0, size: item.file_size ? +(item.file_size / (1024 * 1024)).toFixed(1) : 0,
createdAt: item.created_at ? new Date(item.created_at).toISOString().slice(0, 10) : "—", createdAt: item.created_at ? new Date(item.created_at).toISOString().slice(0, 10) : "—",
usedRatio: item.used_ratio ?? undefined,
usable: item.usable ?? undefined,
} }
} }
@@ -15,6 +15,8 @@ import React, { useState } from "react"
import { useSearchParams } from "react-router-dom" import { useSearchParams } from "react-router-dom"
import { MODE_LABELS } from "@/api/editing-planner" import { MODE_LABELS } from "@/api/editing-planner"
import { MODE_LIST } from "./constants" import { MODE_LIST } from "./constants"
import type { MediaAsset } from "@/api/template-editor"
import MediaPanel from "./components/MediaPanel" import MediaPanel from "./components/MediaPanel"
import PreviewPlayer from "./components/PreviewPlayer" import PreviewPlayer from "./components/PreviewPlayer"
import TimelinePanel from "./components/TimelinePanel" import TimelinePanel from "./components/TimelinePanel"
@@ -77,6 +79,14 @@ const EditingPlanner: React.FC = () => {
/* ── 右侧栏 Tab ── */ /* ── 右侧栏 Tab ── */
const [rightTab, setRightTab] = useState<"properties" | "clips">("properties") const [rightTab, setRightTab] = useState<"properties" | "clips">("properties")
/* ── 素材库 ── */
const [mediaAssets, setMediaAssets] = useState<MediaAsset[]>([])
const [selectedAssetIds, setSelectedAssetIds] = useState<string[]>([])
const handleAssetSelect = (ids: string[]) => {
setSelectedAssetIds(ids)
}
/* ── 配音素材 ── */ /* ── 配音素材 ── */
const { const {
voiceMaterials, voiceMaterials,
@@ -103,6 +113,7 @@ const EditingPlanner: React.FC = () => {
resetClips, resetClips,
setClips, setClips,
setSelectedClipId: clipOps.setSelectedClipId, setSelectedClipId: clipOps.setSelectedClipId,
setMediaAssets,
setTitleConfig, setTitleConfig,
setSubtitleSettings, setSubtitleSettings,
setBgmSettings, setBgmSettings,
@@ -153,6 +164,9 @@ const EditingPlanner: React.FC = () => {
onLoadTemplate={tpl.handleLoadTemplate} onLoadTemplate={tpl.handleLoadTemplate}
onSearchChange={tpl.setSearchQuery} onSearchChange={tpl.setSearchQuery}
onFilterChange={tpl.setCurrentFilter} onFilterChange={tpl.setCurrentFilter}
mediaAssets={mediaAssets}
onAssetSelect={handleAssetSelect}
selectedAssetIds={selectedAssetIds}
/> />
{/* 中栏 flex-1 */} {/* 中栏 flex-1 */}
@@ -0,0 +1,116 @@
/**
* 生成历史弹窗 — 展示当前模板草稿的生成任务记录
* 从 EditingPlanner 拆分,避免主文件过大
*/
import React from "react"
import { CloseOutlined, InboxOutlined } from "@ant-design/icons"
import type { EditPlanGeneration } from "@/api/template-editor"
import { PLAN_STATUS_LABELS } from "@/api/template-editor"
interface GenerationHistoryModalProps {
open: boolean
loading: boolean
history: EditPlanGeneration[]
onClose: () => void
onCancel?: (taskId: string) => void
cancelLoading?: boolean
}
const GenerationHistoryModal: React.FC<GenerationHistoryModalProps> = ({
open,
loading,
history,
onClose,
onCancel,
cancelLoading,
}) => {
if (!open) return null
return (
<div className="ep-modal-overlay" onClick={onClose}>
<div className="ep-modal ep-gh-modal" onClick={(e) => e.stopPropagation()}>
<div className="ep-modal-header">
<h3></h3>
<button className="ep-modal-close" onClick={onClose}>
<CloseOutlined />
</button>
</div>
<div className="ep-modal-body ep-gh-body">
{loading ? (
<div className="ep-gh-empty">
<div className="ep-skeleton">
<div className="ep-skeleton-item ep-skeleton-item--header" />
<div className="ep-skeleton-item" />
<div className="ep-skeleton-item" />
<div className="ep-skeleton-item" />
</div>
</div>
) : history.length === 0 ? (
<div className="ep-gh-empty">
<InboxOutlined style={{ fontSize: 32, opacity: 0.4 }} />
<span></span>
</div>
) : (
<table className="ep-gh-table">
<thead>
<tr className="ep-gh-table-header-row">
<th className="ep-gh-th">ID</th>
<th className="ep-gh-th"></th>
<th className="ep-gh-th"></th>
<th className="ep-gh-th"></th>
{onCancel && <th className="ep-gh-th"></th>}
</tr>
</thead>
<tbody>
{history.map((gen) => {
const statusClass = `ep-gh-status-tag--${gen.status}`
const canCancel = gen.status === "rendering" || gen.status === "editing"
return (
<tr key={gen.id} className="ep-gh-table-row">
<td className="ep-gh-td ep-gh-td-id">
{gen.id ? `${gen.id.slice(0, 8)}...` : "—"}
</td>
<td className="ep-gh-td">
<span className={`ep-gh-status-tag ${statusClass}`}>
{PLAN_STATUS_LABELS[gen.status] || gen.status}
</span>
</td>
<td className="ep-gh-td ep-gh-td-time">
{gen.created_at ? new Date(gen.created_at).toLocaleString("zh-CN") : "—"}
</td>
<td className="ep-gh-td ep-gh-td-time">
{gen.updated_at ? new Date(gen.updated_at).toLocaleString("zh-CN") : "—"}
</td>
{onCancel && (
<td className="ep-gh-td ep-gh-td-action">
{canCancel ? (
<button
className="ep-gh-cancel-btn"
onClick={() => onCancel(gen.id)}
disabled={cancelLoading}
>
</button>
) : (
<span className="ep-gh-action-placeholder"></span>
)}
</td>
)}
</tr>
)
})}
</tbody>
</table>
)}
</div>
<div className="ep-modal-footer">
<button className="ep-btn ep-btn-secondary" onClick={onClose}>
</button>
</div>
</div>
</div>
)
}
export default GenerationHistoryModal
@@ -0,0 +1,6 @@
/**
* 生成进度弹窗 — 入口文件(向后兼容)
* 实际实现已移至 ./generation-progress-modal/ 目录
*/
export { default } from "./generation-progress-modal"
export type { GenPhase, GenerationProgressModalProps } from "./generation-progress-modal"
@@ -1,10 +1,12 @@
/** /**
* 左侧面板 — 模板列表 * 左侧面板 — V8 原型 1:1 还原
* 模板编辑器只负责定义模板规则(片段数量、时长范围),不承载素材管理。 * Tab 切换:模板列表 + 素材库
*/ */
import React from "react" import React, { useState } from "react"
import type { EditingTemplate } from "@/api/editing-planner" import type { EditingTemplate } from "@/api/editing-planner"
import { MODE_LABELS } from "@/api/editing-planner" import { MODE_LABELS } from "@/api/editing-planner"
import type { MediaAsset } from "@/api/template-editor"
import AssetSelector from "@/components/asset-selector/AssetSelector"
interface MediaPanelProps { interface MediaPanelProps {
templates: EditingTemplate[] templates: EditingTemplate[]
@@ -16,6 +18,10 @@ interface MediaPanelProps {
onLoadTemplate: (id: string) => void onLoadTemplate: (id: string) => void
onSearchChange: (q: string) => void onSearchChange: (q: string) => void
onFilterChange: (f: string) => void onFilterChange: (f: string) => void
// 素材相关
mediaAssets?: MediaAsset[]
onAssetSelect?: (ids: string[]) => void
selectedAssetIds?: string[]
} }
const MediaPanel: React.FC<MediaPanelProps> = ({ const MediaPanel: React.FC<MediaPanelProps> = ({
@@ -28,73 +34,113 @@ const MediaPanel: React.FC<MediaPanelProps> = ({
onLoadTemplate, onLoadTemplate,
onSearchChange, onSearchChange,
onFilterChange, onFilterChange,
mediaAssets = [],
onAssetSelect,
selectedAssetIds = [],
}) => { }) => {
const [activeTab, setActiveTab] = useState<"templates" | "assets">("templates")
return ( return (
<div className="ep-left-panel"> <div className="ep-left-panel">
{/* 搜索 */} {/* Tab 切换 */}
<div className="ep-search-wrap ep-media-panel-inner"> <div className="ep-left-tabs">
<span className="ep-search-icon">🔍</span> <button
<input className={`ep-left-tab ${activeTab === "templates" ? "active" : ""}`}
className="ep-search-input" onClick={() => setActiveTab("templates")}
placeholder="搜索模板..." >
value={searchQuery} 📋
onChange={(e) => onSearchChange(e.target.value)} </button>
/> <button
className={`ep-left-tab ${activeTab === "assets" ? "active" : ""}`}
onClick={() => setActiveTab("assets")}
>
📁
</button>
</div> </div>
{/* Chip 分类筛选 */} {/* 模板 Tab */}
<div className="ep-filter-chips"> {activeTab === "templates" && (
{filterCategories.map((cat) => ( <>
<button {/* 搜索 */}
key={cat} <div className="ep-search-wrap ep-media-panel-inner">
className={`ep-filter-chip ${currentFilter === cat ? "active" : ""}`} <span className="ep-search-icon">🔍</span>
onClick={() => onFilterChange(cat)} <input
> className="ep-search-input"
{cat} placeholder="搜索模板..."
</button> value={searchQuery}
))} onChange={(e) => onSearchChange(e.target.value)}
</div> />
</div>
{/* 模板列表 */} {/* Chip 分类筛选 */}
<div className="ep-template-list"> <div className="ep-filter-chips">
{loading ? ( {filterCategories.map((cat) => (
<div className="ep-loading"> <button
<span></span> key={cat}
<span>...</span> className={`ep-filter-chip ${currentFilter === cat ? "active" : ""}`}
onClick={() => onFilterChange(cat)}
>
{cat}
</button>
))}
</div> </div>
) : templates.length === 0 ? (
<div className="ep-empty"> {/* 模板列表 */}
<span>📭</span> <div className="ep-template-list">
<span></span> {loading ? (
</div> <div className="ep-loading">
) : ( <span></span>
templates.map((tpl) => ( <span>...</span>
<div
key={tpl.id}
className={`ep-template-card ${loadedTemplateId === tpl.id ? "active" : ""}`}
onClick={() => onLoadTemplate(tpl.id)}
>
<div className="ep-template-card-header">
<span className="ep-template-card-name">{tpl.name}</span>
<span className="ep-template-card-mode">{MODE_LABELS[tpl.mode]}</span>
</div> </div>
<div className="ep-template-card-meta"> ) : templates.length === 0 ? (
<span> {tpl.estimated_duration}s</span> <div className="ep-empty">
<span>📐 {tpl.segments.length}</span> <span>📭</span>
<span></span>
</div> </div>
{tpl.tags.length > 0 && ( ) : (
<div className="ep-template-card-tags"> templates.map((tpl) => (
{tpl.tags.map((tag) => ( <div
<span key={tag} className="ep-template-tag"> key={tpl.id}
{tag} className={`ep-template-card ${loadedTemplateId === tpl.id ? "active" : ""}`}
</span> onClick={() => onLoadTemplate(tpl.id)}
))} >
<div className="ep-template-card-header">
<span className="ep-template-card-name">{tpl.name}</span>
<span className="ep-template-card-mode">{MODE_LABELS[tpl.mode]}</span>
</div>
<div className="ep-template-card-meta">
<span> {tpl.estimated_duration}s</span>
<span>📐 {tpl.segments.length}</span>
</div>
{tpl.tags.length > 0 && (
<div className="ep-template-card-tags">
{tpl.tags.map((tag) => (
<span key={tag} className="ep-template-tag">
{tag}
</span>
))}
</div>
)}
</div> </div>
)} ))
</div> )}
)) </div>
)} </>
</div> )}
{/* 素材 Tab */}
{activeTab === "assets" && (
<div className="ep-assets-tab">
<AssetSelector
assets={mediaAssets}
selectedIds={selectedAssetIds}
onSelectionChange={onAssetSelect}
showQualityFilter={false}
showBatchSelect={false}
compact
/>
</div>
)}
</div> </div>
) )
} }
@@ -16,17 +16,9 @@ interface BgmSelectorProps {
onClose: () => void onClose: () => void
config: BgmMixConfig config: BgmMixConfig
onChange: (config: BgmMixConfig) => void onChange: (config: BgmMixConfig) => void
/** 模板/草稿 ID,用于请求 BGM 预设 */
templateId?: string
} }
const BgmSelector: React.FC<BgmSelectorProps> = ({ const BgmSelector: React.FC<BgmSelectorProps> = ({ open, onClose, config, onChange }) => {
open,
onClose,
config,
onChange,
templateId,
}) => {
const { const {
presets, presets,
loading, loading,
@@ -38,7 +30,7 @@ const BgmSelector: React.FC<BgmSelectorProps> = ({
loadPresets, loadPresets,
handlePreview, handlePreview,
stopPreview, stopPreview,
} = useBgmSelector(open, templateId) } = useBgmSelector(open)
/* ── 选中 BGM ── */ /* ── 选中 BGM ── */
const handleSelect = useCallback( const handleSelect = useCallback(
@@ -19,7 +19,7 @@ export const CATEGORY_LIST: {
* BGM 选择器数据与交互 Hook * BGM 选择器数据与交互 Hook
* 封装列表加载、搜索、分类筛选、试听播放逻辑 * 封装列表加载、搜索、分类筛选、试听播放逻辑
*/ */
export function useBgmSelector(open: boolean, templateId?: string) { export function useBgmSelector(open: boolean) {
const [presets, setPresets] = useState<BgmPreset[]>([]) const [presets, setPresets] = useState<BgmPreset[]>([])
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [activeCategory, setActiveCategory] = useState<BgmCategory | "all">("all") const [activeCategory, setActiveCategory] = useState<BgmCategory | "all">("all")
@@ -30,23 +30,19 @@ export function useBgmSelector(open: boolean, templateId?: string) {
/* ── 加载 BGM 列表 ── */ /* ── 加载 BGM 列表 ── */
const loadPresets = useCallback(async () => { const loadPresets = useCallback(async () => {
if (!templateId) {
setPresets([])
return
}
setLoading(true) setLoading(true)
try { try {
const params: { category?: string; keyword?: string } = {} const params: { category?: string; keyword?: string } = {}
if (activeCategory !== "all") params.category = activeCategory if (activeCategory !== "all") params.category = activeCategory
if (keyword.trim()) params.keyword = keyword.trim() if (keyword.trim()) params.keyword = keyword.trim()
const data = await getBgmPresets(templateId, params) const data = await getBgmPresets(params)
setPresets(data) setPresets(data)
} catch { } catch {
message.error("加载 BGM 列表失败") message.error("加载 BGM 列表失败")
} finally { } finally {
setLoading(false) setLoading(false)
} }
}, [activeCategory, keyword, templateId]) }, [activeCategory, keyword])
useEffect(() => { useEffect(() => {
if (open) loadPresets() if (open) loadPresets()
@@ -0,0 +1,65 @@
import React from "react"
import { Modal } from "@/components/ui"
import type { TaskItem } from "@/api/tasks"
import { getStepLabel, getStatusColor } from "./constants"
interface ProgressPhaseProps {
open: boolean
task: TaskItem | null
onCancel: () => void
}
/** progress(进度轮询)阶段弹窗 */
export const ProgressPhase: React.FC<ProgressPhaseProps> = ({ open, task, onCancel }) => {
const progress = task?.progress ?? 0
const status = task?.status ?? ""
const currentStep = task?.current_step ?? ""
const userMessage = task?.user_message ?? ""
const stepColor = getStatusColor(status, currentStep)
return (
<Modal open={open} title="视频生成中" footer={null} onCancel={onCancel} closable width={480}>
<div className="ep-gen-progress">
{/* 进度环 */}
<div className="ep-gen-progress-ring-wrap">
<svg className="ep-gen-progress-ring" viewBox="0 0 120 120">
<circle className="ep-gen-progress-ring-bg" cx="60" cy="60" r="52" />
<circle
className="ep-gen-progress-ring-fill"
cx="60"
cy="60"
r="52"
style={{
strokeDasharray: `${2 * Math.PI * 52}`,
strokeDashoffset: `${2 * Math.PI * 52 * (1 - progress / 100)}`,
stroke: stepColor,
}}
/>
</svg>
<span className="ep-gen-progress-pct" style={{ color: stepColor }}>
{progress}%
</span>
</div>
{/* 当前步骤 */}
<div className="ep-gen-step-text">
{userMessage || getStepLabel(currentStep) || "处理中…"}
</div>
{/* 进度条 */}
<div className="ep-gen-progress-bar">
<div
className="ep-gen-progress-bar-fill"
style={{
width: `${progress}%`,
backgroundColor: stepColor,
}}
/>
</div>
{/* 任务 ID */}
{task?.id && <div className="ep-gen-task-id"> ID: {task.id}</div>}
</div>
</Modal>
)
}
@@ -0,0 +1,82 @@
import React from "react"
import { Modal, Button } from "@/components/ui"
import type { TaskItem } from "@/api/tasks"
interface ResultPhaseProps {
open: boolean
phase: "completed" | "failed"
task: TaskItem | null
onCancel: () => void
onRetry?: () => void
onClose?: () => void
}
/** completed / failed(结果)阶段弹窗 */
export const ResultPhase: React.FC<ResultPhaseProps> = ({
open,
phase,
task,
onCancel,
onRetry,
onClose,
}) => {
const userMessage = task?.user_message ?? ""
const errorMessage = task?.error_message ?? ""
const retryable = task?.retryable ?? false
const handleClose = onClose || onCancel
if (phase === "completed") {
return (
<Modal
open={open}
title="✅ 生成完成"
footer={null}
onCancel={handleClose}
closable
width={440}
>
<div className="ep-gen-result">
<div className="ep-gen-result-icon">🎉</div>
<div className="ep-gen-result-title"></div>
{userMessage && <div className="ep-gen-result-msg">{userMessage}</div>}
<div className="ep-gen-result-actions">
<Button buttonType="primary" onClick={handleClose}>
</Button>
</div>
</div>
</Modal>
)
}
return (
<Modal
open={open}
title="❌ 生成失败"
footer={null}
onCancel={handleClose}
closable
width={440}
>
<div className="ep-gen-result ep-gen-result--error">
<div className="ep-gen-result-icon">😥</div>
<div className="ep-gen-result-title"></div>
{(errorMessage || userMessage) && (
<div className="ep-gen-result-msg ep-gen-result-msg--error">
{errorMessage || userMessage}
</div>
)}
<div className="ep-gen-result-actions">
{retryable && onRetry && (
<Button buttonType="primary" onClick={onRetry}>
🔄
</Button>
)}
<Button buttonType="secondary" onClick={handleClose}>
</Button>
</div>
</div>
</Modal>
)
}

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