Compare commits

..

2 Commits

Author SHA1 Message Date
xiaoxia 08179d1369 fix(ci): strip ANSI codes before grep in vitest_incremental.sh
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 / Validate - Type Check (mypy) (pull_request) Successful in 47s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 54s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 53s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 56s
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 Staging (Watchtower auto-deploy) (pull_request) Has been skipped
Preview Deploy / Deploy Preview Environment (pull_request) Failing after 1m2s
CI/CD Pipeline / Deploy Production (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 / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Successful in 1m8s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 2m11s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 2m22s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 4m6s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 4m0s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 5m5s
AI Code Review / AI Code Review (pull_request) Successful in 5m11s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 1m50s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 5m47s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 6m22s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 12s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 41s
vitest outputs ANSI color codes that interfere with grep matching.
'No test files found' skip logic was not triggered, causing CI failure.
Added sed to strip ANSI codes before grep check.

Closes #1221
2026-08-03 13:57:10 +08:00
SaaS Frontend 7594caaf02 fix(auth): 401拦截器排除auth端点 + login默认跳转/app/dashboard
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 32s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 50s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 48s
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 / Frontend Lint (pull_request) Successful in 55s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 1m28s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 2m10s
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
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 Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 56s
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 4m0s
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (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 4m14s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 5m37s
Preview Deploy / Deploy Preview Environment (pull_request) Failing after 6m36s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 11m1s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 14m42s
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 47m48s
- client.ts: 401响应拦截器增加isAuthEndpoint判断,排除/auth/login、
  /auth/register、/auth/forgot-password、/auth/reset-password,
  避免登录密码错误时触发clearAuth+硬跳转,让401正常走错误提示逻辑
- useAuth.ts: login()默认跳转路径从'/'改为'/app/dashboard',
  与Login.tsx onFinish保持一致,消除双重navigate竞态

Closes #1221
2026-08-03 12:40:39 +08:00
506 changed files with 23285 additions and 57300 deletions
+6 -5
View File
@@ -36,11 +36,12 @@ jobs:
GITEA_REPO: xiaoxia/xiaoxia-saas
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
uses: actions/checkout@v4
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: '3.12'
# ====== Cron模式:获取staging运行中镜像作为白名单 ======
- name: Get staging running images (whitelist)
+1 -1
View File
@@ -8,7 +8,7 @@ permissions:
jobs:
ci-health-report:
name: CI健康度每日巡检
runs-on: ci-l2
runs-on: saas
timeout-minutes: 15
steps:
- name: Checkout code
+96 -215
View File
@@ -24,6 +24,17 @@ concurrency:
group: ci-pipeline-${{ gitea.event_name }}-${{ gitea.ref }}
# PR事件取消进行中的旧run,push事件不取消(确保完整CI跑完)
cancel-in-progress: ${{ gitea.event_name == 'pull_request' }}
env:
CI_PG_HOST: host.docker.internal
CI_LOCAL_PG_PORT: "5432"
CI_PG_USER: postgres
CI_PG_PASSWORD: postgres
CI_PG_DB: xiaoxia_saas
CI_SHARED_PG_PORT: "5433"
CI_SHARED_PG_USER: postgres
CI_SHARED_PG_PASSWORD: ci_pg_2026!
CI_DEFAULT_DB: xiaoxia_saas
jobs:
check-frontend-only:
name: Check if frontend-only change
@@ -251,7 +262,7 @@ jobs:
permissions:
contents: read
env:
DATABASE_URL: postgresql+psycopg://postgres:postgres@host.docker.internal:5432/xiaoxia_saas
DATABASE_URL: postgresql+psycopg://${{ env.CI_PG_USER }}:${{ env.CI_PG_PASSWORD }}@${{ env.CI_PG_HOST }}:${{ env.CI_LOCAL_PG_PORT }}/${{ env.CI_PG_DB }}
USE_IN_MEMORY_DB: 'false'
CI_USE_SHARED_PG: 'true'
steps:
@@ -335,6 +346,7 @@ jobs:
OSS_ACCESS_KEY_SECRET: placeholder
OSS_BUCKET_NAME: xiaoxia-autocut
OSS_ENDPOINT: oss-cn-hangzhou.aliyuncs.com
JWT_SECRET_KEY: test-jwt-secret-for-ci-only-2026
steps:
- name: Checkout code
shell: sh
@@ -399,13 +411,14 @@ jobs:
- validate-type-check
- validate-migration
env:
DATABASE_URL: postgresql+psycopg://postgres:postgres@host.docker.internal:5432/xiaoxia_saas
DATABASE_URL: postgresql+psycopg://${{ env.CI_PG_USER }}:${{ env.CI_PG_PASSWORD }}@${{ env.CI_PG_HOST }}:${{ env.CI_LOCAL_PG_PORT }}/${{ env.CI_PG_DB }}
USE_IN_MEMORY_DB: 'false'
CI_USE_SHARED_PG: 'true'
OSS_ACCESS_KEY_ID: placeholder
OSS_ACCESS_KEY_SECRET: placeholder
OSS_BUCKET_NAME: xiaoxia-autocut
OSS_ENDPOINT: oss-cn-hangzhou.aliyuncs.com
JWT_SECRET_KEY: test-jwt-secret-for-ci-only-2026
steps:
- name: Checkout code
shell: sh
@@ -627,60 +640,83 @@ jobs:
echo "Docker login failed ($i/3), retrying in 5s..."
sleep 5
done
- name: Pre-build worker base images (fallback if not exist)
- name: Pre-build worker base images (3-level cache)
if: matrix.service == 'worker'
id: prebuild
shell: sh
shell: bash
run: |
set -eu
REGISTRY="git.xiaoxiajianji.com/xiaoxia-saas"
BASE_BUILDER="${REGISTRY}/worker-base-builder:latest"
BASE_RUNTIME="${REGISTRY}/worker-base-runtime:latest"
# 尝试拉取基础镜像
echo "检查基础镜像..."
if docker pull "$BASE_BUILDER" 2>/dev/null && docker pull "$BASE_RUNTIME" 2>/dev/null; then
echo "基础镜像已存在,使用远程镜像"
echo "fallback=false" >> $GITHUB_OUTPUT
else
echo "基础镜像不存在,本地构建(fallback模式)..."
# 尝试用buildx构建,失败则回退到普通docker buildDooD模式下buildx builder偶发崩溃)
BUILDER_NAME="ci-pr-builder-${GITHUB_RUN_ID:-local}"
BUILDX_AVAILABLE=true
if ! docker buildx create --use --name "$BUILDER_NAME" --driver docker-container > /dev/null 2>&1; then
BUILDX_AVAILABLE=false
fi
if [ "$BUILDX_AVAILABLE" = true ] && ! docker buildx inspect --bootstrap > /dev/null 2>&1; then
BUILDX_AVAILABLE=false
docker buildx rm "$BUILDER_NAME" > /dev/null 2>&1 || true
fi
build_base() {
local df="$1"
local tag="$2"
local name="$3"
if [ "$BUILDX_AVAILABLE" = true ]; then
echo "构建 $namebuildx..."
if docker buildx build --load -f "$df" -t "$tag" . > /dev/null 2>&1; then
echo "$name 构建成功"
return 0
fi
echo "buildx失败,回退到普通docker build"
BUILDX_AVAILABLE=false
docker buildx rm "$BUILDER_NAME" > /dev/null 2>&1 || true
fi
echo "构建 $namedocker build..."
docker build -f "$df" -t "$tag" .
}
build_base infra/docker/worker-base-builder.Dockerfile "$BASE_BUILDER" "worker-base-builder"
build_base infra/docker/worker-base-runtime.Dockerfile "$BASE_RUNTIME" "worker-base-runtime"
echo "fallback=true" >> $GITHUB_OUTPUT
echo "基础镜像本地构建完成"
fi
GITEA_REGISTRY="git.xiaoxiajianji.com/xiaoxia-saas"
ACR_REGISTRY="xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com/xiaoxiakeji"
GITEA_BUILDER="${GITEA_REGISTRY}/worker-base-builder:latest"
GITEA_RUNTIME="${GITEA_REGISTRY}/worker-base-runtime:latest"
ACR_BUILDER="${ACR_REGISTRY}/worker-base-builder:latest"
ACR_RUNTIME="${ACR_REGISTRY}/worker-base-runtime:latest"
# L1: 本地daemon缓存(DooD模式8runner共享宿主机daemon
echo "=== L1 本地缓存 ==="
if docker image inspect "$ACR_BUILDER" > /dev/null 2>&1 \
&& docker image inspect "$ACR_RUNTIME" > /dev/null 2>&1; then
echo "本地缓存命中"
echo "has_local_base=true" >> $GITHUB_OUTPUT
exit 0
fi
echo "本地无缓存"
# L2: Gitea registry缓存(内网快)
echo "=== L2 Registry拉取 ==="
if docker pull "$GITEA_BUILDER" 2>/dev/null && docker pull "$GITEA_RUNTIME" 2>/dev/null; then
echo "Registry拉取成功,重tag供Dockerfile使用"
docker tag "$GITEA_BUILDER" "$ACR_BUILDER"
docker tag "$GITEA_RUNTIME" "$ACR_RUNTIME"
echo "has_local_base=true" >> $GITHUB_OUTPUT
exit 0
fi
echo "Registry无缓存,需本地构建"
# L3: 本地构建
echo "=== L3 本地构建 ==="
BUILDER_NAME="ci-pr-builder-${GITHUB_RUN_ID:-local}"
if ! docker buildx inspect "$BUILDER_NAME" > /dev/null 2>&1; then
docker buildx create --use --name "$BUILDER_NAME" --driver docker-container
else
docker buildx use "$BUILDER_NAME"
fi
docker buildx inspect --bootstrap > /dev/null 2>&1
echo "构建 worker-base-builder..."
for attempt in 1 2 3; do
if docker buildx build --load -f infra/docker/worker-base-builder.Dockerfile -t "$ACR_BUILDER" .; then
echo "worker-base-builder 构建成功"
break
fi
echo "worker-base-builder 失败,重试 $attempt/3..."
docker buildx rm "$BUILDER_NAME" 2>/dev/null || true
docker buildx create --use --name "$BUILDER_NAME" --driver docker-container
sleep 3
done
echo "构建 worker-base-runtime..."
for attempt in 1 2 3; do
if docker buildx build --load -f infra/docker/worker-base-runtime.Dockerfile -t "$ACR_RUNTIME" .; then
echo "worker-base-runtime 构建成功"
break
fi
echo "worker-base-runtime 失败,重试 $attempt/3..."
docker buildx rm "$BUILDER_NAME" 2>/dev/null || true
docker buildx create --use --name "$BUILDER_NAME" --driver docker-container
sleep 3
done
# 推送到Gitea registry供后续复用
echo "=== 推送缓存到Registry ==="
docker tag "$ACR_BUILDER" "$GITEA_BUILDER"
docker tag "$ACR_RUNTIME" "$GITEA_RUNTIME"
docker push "$GITEA_BUILDER" 2>/dev/null || echo "push builder失败(不影响)"
docker push "$GITEA_RUNTIME" 2>/dev/null || echo "push runtime失败(不影响)"
echo "has_local_base=true" >> $GITHUB_OUTPUT
echo "基础镜像构建完成"
- name: Build PR image (verify only, no push)
shell: sh
run: |
@@ -694,15 +730,15 @@ jobs:
EXTRA_BUILD_ARGS="$EXTRA_BUILD_ARGS NGINX_CONF=infra/docker/nginx-staging.conf"
fi
# Worker fallback模式:基础镜像本地已构建,用普通docker build绕过buildx
if [ "${{ matrix.service }}" = "worker" ] && [ "${{ steps.prebuild.outputs.fallback }}" = "true" ]; then
echo "Fallback模式:用普通docker build(基础镜像本地已构建"
# Worker有本地base镜像时:用BuildKit直接构建(快,无需起buildx容器)
if [ "${{ matrix.service }}" = "worker" ] && [ "${{ steps.prebuild.outputs.has_local_base }}" = "true" ]; then
echo "本地base镜像已就绪,BuildKit快速构建"
BUILD_ARG_STR=""
for arg in $EXTRA_BUILD_ARGS; do
BUILD_ARG_STR="$BUILD_ARG_STR --build-arg $arg"
done
docker build -f ${{ matrix.dockerfile }} -t "${IMAGE_TAG}" $BUILD_ARG_STR .
echo "Fallback PR Build successful"
DOCKER_BUILDKIT=1 docker build -f ${{ matrix.dockerfile }} -t "${IMAGE_TAG}" $BUILD_ARG_STR .
echo "快速构建成功"
exit 0
fi
@@ -1083,7 +1119,8 @@ jobs:
set -eu
# DooD模式下不能用-v挂载(宿主机路径与CI容器路径不一致)
# 改用 docker create + docker cp 方式把代码拷进容器
CONTAINER_NAME="staging-e2e-$$"
CONTAINER_NAME="staging-e2e-${GITHUB_SHA::8}"
docker rm -f "$CONTAINER_NAME" 2>/dev/null || true
docker create --name "$CONTAINER_NAME" --ipc=host \
-e E2E_BASE_URL=https://staging.xiaoxiajianji.com \
-e E2E_API_BASE=https://staging-api.xiaoxiajianji.com/api/v1 \
@@ -1147,7 +1184,8 @@ jobs:
set -eu
# DooD模式下不能用-v挂载(宿主机路径与CI容器路径不一致)
# 改用 docker create + docker cp 方式把代码拷进容器
CONTAINER_NAME="staging-api-tests-$$"
CONTAINER_NAME="staging-api-tests-${GITHUB_SHA::8}"
docker rm -f "$CONTAINER_NAME" 2>/dev/null || true
docker create --name "$CONTAINER_NAME" \
-e E2E_BASE_URL=https://staging.xiaoxiajianji.com \
-e E2E_API_BASE=https://staging-api.xiaoxiajianji.com/api/v1 \
@@ -1662,160 +1700,3 @@ jobs:
START_TIME=""
[ -f /tmp/ci_job_start_time ] && START_TIME=$(cat /tmp/ci_job_start_time)
python3 scripts/ci/ci_trace_report.py --service xiaoxia-saas-ci --status $STATUS --start-time "$START_TIME" || true
ci-gate:
name: CI Gate
runs-on: ci-l2
if: always() && github.event_name == 'pull_request'
needs:
- check-frontend-only
- validate-code-quality
- validate-type-check
- validate-migration
- unit-tests
- integration-tests
- frontend-lint
- frontend-unit-test
- build-pr
timeout-minutes: 3
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: Evaluate CI Gate
id: gate
shell: bash
env:
GITHUB_TOKEN: ${{ github.token }}
PR_NUMBER: ${{ github.event.pull_request.number }}
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
RESULT_CHECK_FRONTEND: ${{ needs.check-frontend-only.result }}
RESULT_CODE_QUALITY: ${{ needs.validate-code-quality.result }}
RESULT_TYPE_CHECK: ${{ needs.validate-type-check.result }}
RESULT_MIGRATION: ${{ needs.validate-migration.result }}
RESULT_UNIT_TESTS: ${{ needs.unit-tests.result }}
RESULT_INTEGRATION: ${{ needs.integration-tests.result }}
RESULT_FRONTEND_LINT: ${{ needs.frontend-lint.result }}
RESULT_FRONTEND_UNIT: ${{ needs.frontend-unit-test.result }}
RESULT_BUILD_PR: ${{ needs.build-pr.result }}
run: |
set -eu
echo "=== CI Gate 评估 ==="
echo ""
echo "各job结果:"
echo " check-frontend-only: $RESULT_CHECK_FRONTEND"
echo " validate-code-quality: $RESULT_CODE_QUALITY"
echo " validate-type-check: $RESULT_TYPE_CHECK"
echo " validate-migration: $RESULT_MIGRATION"
echo " unit-tests: $RESULT_UNIT_TESTS"
echo " integration-tests: $RESULT_INTEGRATION"
echo " frontend-lint: $RESULT_FRONTEND_LINT"
echo " frontend-unit-test: $RESULT_FRONTEND_UNIT"
echo " build-pr: $RESULT_BUILD_PR"
echo ""
# 判断PR类型
SKIP_BACKEND="${{ needs.check-frontend-only.outputs.skip_backend }}"
SKIP_FRONTEND="${{ needs.check-frontend-only.outputs.skip_frontend }}"
echo "PR类型: skip_backend=$SKIP_BACKEND, skip_frontend=$SKIP_FRONTEND"
# 必填检查项(根据PR类型决定)
# 通用检查(所有PR都必须过)
REQUIRED_GENERAL=(
"validate-code-quality:$RESULT_CODE_QUALITY"
"validate-type-check:$RESULT_TYPE_CHECK"
"validate-migration:$RESULT_MIGRATION"
"frontend-lint:$RESULT_FRONTEND_LINT"
"build-pr:$RESULT_BUILD_PR"
)
# 后端检查
REQUIRED_BACKEND=(
"unit-tests:$RESULT_UNIT_TESTS"
)
# 前端检查
REQUIRED_FRONTEND=(
"frontend-unit-test:$RESULT_FRONTEND_UNIT"
)
ALL_PASSED=true
FAILED_ITEMS=()
check_job() {
local name=$1
local result=$2
if [ "$result" = "success" ]; then
echo " ✅ $name: success"
elif [ "$result" = "skipped" ]; then
echo " ⏭️ $name: skipped(跳过,不影响)"
else
echo " ❌ $name: $result"
ALL_PASSED=false
FAILED_ITEMS+=("$name=$result")
fi
}
echo ""
echo "=== 通用检查(所有PR必填)==="
for item in "${REQUIRED_GENERAL[@]}"; do
name="${item%%:*}"
result="${item##*:}"
check_job "$name" "$result"
done
if [ "$SKIP_BACKEND" != "true" ]; then
echo ""
echo "=== 后端检查 ==="
for item in "${REQUIRED_BACKEND[@]}"; do
name="${item%%:*}"
result="${item##*:}"
check_job "$name" "$result"
done
else
echo ""
echo "=== 后端检查(纯前端PR,跳过)==="
fi
if [ "$SKIP_FRONTEND" != "true" ]; then
echo ""
echo "=== 前端检查 ==="
for item in "${REQUIRED_FRONTEND[@]}"; do
name="${item%%:*}"
result="${item##*:}"
check_job "$name" "$result"
done
else
echo ""
echo "=== 前端检查(纯后端PR,跳过)==="
fi
echo ""
if [ "$ALL_PASSED" = "true" ]; then
echo "✅ CI Gate: PASSED"
echo "gate_result=success" >> $GITHUB_OUTPUT
exit 0
else
echo "❌ CI Gate: FAILED"
echo "失败项: ${FAILED_ITEMS[*]}"
echo "gate_result=failure" >> $GITHUB_OUTPUT
exit 1
fi
- name: Report CI trace
if: always()
shell: sh
env:
AGENTLOOP_LICENSE_KEY: ${{ secrets.AGENTLOOP_LICENSE_KEY }}
run: |
STATUS="ok"
[ "${{ steps.gate.outputs.gate_result }}" = "success" ] || STATUS="error"
START_TIME=""
[ -f /tmp/ci_job_start_time ] && START_TIME=$(cat /tmp/ci_job_start_time)
python3 scripts/ci/ci_trace_report.py --service xiaoxia-saas-ci --status $STATUS --start-time "$START_TIME" || true
+6 -6
View File
@@ -16,15 +16,15 @@ permissions:
jobs:
monitor:
name: Monitor CI Trigger Reliability
runs-on: ci-l2
runs-on: ubuntu-latest
timeout-minutes: 5
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
uses: actions/checkout@v3
# 网络波动自动重试2次
retry:
max_attempts: 2
retry_on: error
- name: Check CI trigger status for all open PRs
env:
+10 -10
View File
@@ -15,18 +15,20 @@ concurrency:
jobs:
code-review:
name: AI Code Review
runs-on: ci-l2
runs-on: ubuntu-latest
# 跳过草稿 PR
if: ${{ !gitea.event.pull_request.draft }}
steps:
# actions/checkout 由 runner 在宿主机层面处理,不受容器网络影响
- 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
uses: actions/checkout@v3
with:
fetch-depth: 0
# 网络波动自动重试2次
retry:
max_attempts: 2
retry_on: error
- name: Install dependencies
run: |
@@ -48,7 +50,6 @@ jobs:
GITEA_TOKEN: ${{ secrets.REVIEW_GITEA_TOKEN }}
REPO_NAME: ${{ gitea.repository }}
PR_NUMBER: ${{ gitea.event.pull_request.number }}
PR_HEAD_SHA: ${{ gitea.event.pull_request.head.sha }}
# LLM 提供商: coze (扣子原生Bot) / openai (OpenAI兼容)
LLM_PROVIDER: "coze"
# 扣子模式配置(默认国内站 api.coze.cn
@@ -61,9 +62,8 @@ jobs:
LLM_TIMEOUT: "120"
run: |
python3 scripts/ci_code_review.py
# 注意:脚本退出码决定job状态
# - 有阻塞级问题 → exit 1 → job失败 → 门禁拦截
# - 无阻塞级问题/LLM异常 → exit 0 → 通过(fail-open
# 审查脚本异常不影响 CI 通过
continue-on-error: true
- name: Report CI trace
if: always()
+134 -32
View File
@@ -1,5 +1,4 @@
name: Daily Health Check
# 注意:使用 curl step_checkout.sh 方式以兼容 docker runner
on:
schedule:
@@ -13,7 +12,7 @@ jobs:
# ── 1. 生产环境冒烟测试 ─────────────────────────────────────────────
production-smoke:
name: Production Smoke Test
runs-on: ci-l2
runs-on: saas
timeout-minutes: 8
outputs:
report: ${{ steps.smoke.outputs.report }}
@@ -24,9 +23,47 @@ jobs:
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
set -eu
python3 - <<'PY'
import io, os, tarfile, time, urllib.request, urllib.error
url = f"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz"
request = urllib.request.Request(url, headers={"Authorization": f"token {os.environ['GITHUB_TOKEN']}"})
last_err = None
for attempt in range(5):
try:
with urllib.request.urlopen(request, timeout=120) as response:
archive = response.read()
break
except urllib.error.HTTPError as e:
last_err = e
if e.code >= 500 and attempt < 4:
wait = 2 ** attempt
print(f"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...")
time.sleep(wait)
continue
raise
except Exception as e:
last_err = e
if attempt < 4:
wait = 2 ** attempt
print(f"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...")
time.sleep(wait)
continue
raise
else:
raise last_err
with tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar:
root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/'
for member in tar.getmembers():
name = member.name
if name == root_prefix[:-1]:
continue
if name.startswith(root_prefix):
member.name = name[len(root_prefix):]
if member.name:
tar.extract(member, '.')
PY
- name: Production health check & smoke test
id: smoke
shell: sh
@@ -84,7 +121,7 @@ jobs:
# ── 2. Staging API 集成测试 ─────────────────────────────────────────
staging-api-tests:
name: Staging API Integration Tests
runs-on: ci-l2
runs-on: saas
timeout-minutes: 10
outputs:
report: ${{ steps.smoke.outputs.report }}
@@ -95,15 +132,50 @@ jobs:
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
set -eu
python3 - <<'PY'
import io, os, tarfile, time, urllib.request, urllib.error
url = f"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz"
request = urllib.request.Request(url, headers={"Authorization": f"token {os.environ['GITHUB_TOKEN']}"})
last_err = None
for attempt in range(5):
try:
with urllib.request.urlopen(request, timeout=120) as response:
archive = response.read()
break
except urllib.error.HTTPError as e:
last_err = e
if e.code >= 500 and attempt < 4:
wait = 2 ** attempt
print(f"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...")
time.sleep(wait)
continue
raise
except Exception as e:
last_err = e
if attempt < 4:
wait = 2 ** attempt
print(f"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...")
time.sleep(wait)
continue
raise
else:
raise last_err
with tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar:
root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/'
for member in tar.getmembers():
name = member.name
if name == root_prefix[:-1]:
continue
if name.startswith(root_prefix):
member.name = name[len(root_prefix):]
if member.name:
tar.extract(member, '.')
PY
- name: Run API smoke test on staging
id: smoke
shell: sh
env:
STAGING_TEST_USER: ${{ secrets.STAGING_TEST_USER }}
STAGING_TEST_PASSWORD: ${{ secrets.STAGING_TEST_PASSWORD }}
run: |
set +e
START_TIME=$(date +%s)
@@ -111,8 +183,8 @@ jobs:
docker run --rm \
-e BASE_URL=https://staging-api.xiaoxiajianji.com \
-e WEB_URL=https://staging.xiaoxiajianji.com \
-e TEST_USER="$STAGING_TEST_USER" \
-e TEST_PASSWORD="$STAGING_TEST_PASSWORD" \
-e TEST_USER=18314979086@163.com \
-e TEST_PASSWORD=Ying1234 \
-e CLEANUP_ENABLED=1 \
-e PERF_CHECK_ENABLED=1 \
-e PERF_WARN_THRESHOLD_MS=500 \
@@ -198,7 +270,7 @@ jobs:
# ── 3. Staging 浏览器 E2E ──────────────────────────────────────────
staging-e2e:
name: Staging Browser E2E
runs-on: ci-l2
runs-on: saas
timeout-minutes: 15
outputs:
report: ${{ steps.smoke.outputs.report }}
@@ -209,9 +281,47 @@ jobs:
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
set -eu
python3 - <<'PY'
import io, os, tarfile, time, urllib.request, urllib.error
url = f"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz"
request = urllib.request.Request(url, headers={"Authorization": f"token {os.environ['GITHUB_TOKEN']}"})
last_err = None
for attempt in range(5):
try:
with urllib.request.urlopen(request, timeout=120) as response:
archive = response.read()
break
except urllib.error.HTTPError as e:
last_err = e
if e.code >= 500 and attempt < 4:
wait = 2 ** attempt
print(f"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...")
time.sleep(wait)
continue
raise
except Exception as e:
last_err = e
if attempt < 4:
wait = 2 ** attempt
print(f"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...")
time.sleep(wait)
continue
raise
else:
raise last_err
with tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar:
root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/'
for member in tar.getmembers():
name = member.name
if name == root_prefix[:-1]:
continue
if name.startswith(root_prefix):
member.name = name[len(root_prefix):]
if member.name:
tar.extract(member, '.')
PY
- name: Run Playwright E2E on staging
id: e2e
shell: sh
@@ -261,7 +371,7 @@ jobs:
# ── 4. 性能基线巡检 ────────────────────────────────────────────────
performance-check:
name: Performance Baseline Check
runs-on: ci-l2
runs-on: saas
timeout-minutes: 8
outputs:
report: ${{ steps.report.outputs.report }}
@@ -270,9 +380,6 @@ jobs:
- name: Run performance baseline checks
id: perf
shell: sh
env:
STAGING_TEST_USER: ${{ secrets.STAGING_TEST_USER }}
STAGING_TEST_PASSWORD: ${{ secrets.STAGING_TEST_PASSWORD }}
run: |
set +e
START_TIME=$(date +%s)
@@ -308,10 +415,9 @@ jobs:
# 先登录获取 token
echo "--- 准备: 获取测试 Token ---"
LOGIN_BODY="{\"email\":\"$STAGING_TEST_USER\",\"password\":\"$STAGING_TEST_PASSWORD\"}"
AUTH_RESP=$(curl -s -w "\n%{http_code}" -X POST \
-H "Content-Type: application/json" \
-d "$LOGIN_BODY" \
-d '{"email":"18314979086@163.com","password":"Ying1234"}' \
"https://staging-api.xiaoxiajianji.com/api/v1/auth/login" \
--max-time 10 2>&1)
AUTH_CODE=$(echo "$AUTH_RESP" | tail -1)
@@ -341,7 +447,7 @@ jobs:
# 构建 curl 命令
CURL_ARGS="-s -o /dev/null -w '%{http_code} %{time_total}' --max-time 30"
if [ "$method" = "POST" ]; then
CURL_ARGS="$CURL_ARGS -X POST -H 'Content-Type: application/json' -d \"$LOGIN_BODY\""
CURL_ARGS="$CURL_ARGS -X POST -H 'Content-Type: application/json' -d '{\"email\":\"18314979086@163.com\",\"password\":\"Ying1234\"}'"
fi
if [ -n "$TOKEN" ] && [ "$name" != "健康检查" ]; then
CURL_ARGS="$CURL_ARGS -H 'Authorization: Bearer $TOKEN'"
@@ -389,9 +495,6 @@ jobs:
- name: Generate performance report
id: report
shell: sh
env:
STAGING_TEST_USER: ${{ secrets.STAGING_TEST_USER }}
STAGING_TEST_PASSWORD: ${{ secrets.STAGING_TEST_PASSWORD }}
run: |
set +e
echo ""
@@ -406,11 +509,10 @@ jobs:
RESULTS=""
START_TIME=$(date +%s)
LOGIN_BODY="{\"email\":\"$STAGING_TEST_USER\",\"password\":\"$STAGING_TEST_PASSWORD\"}"
# 先登录获取 token
AUTH_RESP=$(curl -s -w "\n%{http_code}" -X POST \
-H "Content-Type: application/json" \
-d "$LOGIN_BODY" \
-d '{"email":"18314979086@163.com","password":"Ying1234"}' \
"https://staging-api.xiaoxiajianji.com/api/v1/auth/login" \
--max-time 10 2>&1)
AUTH_CODE=$(echo "$AUTH_RESP" | tail -1)
@@ -426,7 +528,7 @@ jobs:
local CURL_ARGS="-s -o /dev/null -w '%{http_code} %{time_total}' --max-time 30"
if [ "$method" = "POST" ]; then
CURL_ARGS="$CURL_ARGS -X POST -H 'Content-Type: application/json' -d \"$LOGIN_BODY\""
CURL_ARGS="$CURL_ARGS -X POST -H 'Content-Type: application/json' -d '{\"email\":\"18314979086@163.com\",\"password\":\"Ying1234\"}'"
fi
if [ -n "$TOKEN" ] && [ "$name" != "健康检查" ]; then
CURL_ARGS="$CURL_ARGS -H 'Authorization: Bearer $TOKEN'"
@@ -529,7 +631,7 @@ jobs:
# ── 5. 每日巡检汇总报告 ────────────────────────────────────────────
daily-report:
name: Daily Check Report
runs-on: ci-l2
runs-on: saas
timeout-minutes: 2
if: always()
needs:
+37 -24
View File
@@ -93,31 +93,44 @@ jobs:
shell: sh
run: |
set -eu
cd apps/web
NPM_CACHE_VOLUME="xiaoxia-npm-cache"
if ! docker volume inspect "$NPM_CACHE_VOLUME" >/dev/null 2>&1; then
docker volume create "$NPM_CACHE_VOLUME" >/dev/null
echo "Created npm cache volume: $NPM_CACHE_VOLUME"
fi
# Config npm mirror for speed
npm config set registry https://registry.npmmirror.com
# Install dependencies with retry
for i in 1 2 3; do
npm ci --no-audit --no-fund && break
echo "npm ci failed, retry $i/3..."
[ $i -eq 3 ] && exit 1
rm -rf node_modules
sleep 5
done
# TypeScript check
echo "=== TypeScript check ==="
npx --no-install tsc --noEmit
# Vite build
echo "=== Vite build ==="
export VITE_API_URL=https://staging-api.xiaoxiajianji.com
npx --no-install vite build
echo "=== Build completed ==="
ls -la dist/
docker run --rm \
-v "$PWD:/workspace" \
-v "$NPM_CACHE_VOLUME:/workspace/apps/web/node_modules" \
-w /workspace/apps/web \
-e VITE_API_URL=https://staging-api.xiaoxiajianji.com \
docker.m.daocloud.io/library/node:20 \
sh -lc '
PACKAGE_LOCK_HASH=$(md5sum package-lock.json 2>/dev/null | cut -d" " -f1)
CACHE_HASH_FILE="node_modules/.package-lock-hash"
CACHE_VALID=false
if [ -f "$CACHE_HASH_FILE" ] && [ "$(cat "$CACHE_HASH_FILE")" = "$PACKAGE_LOCK_HASH" ] && [ -x "node_modules/.bin/vite" ] && [ -x "node_modules/.bin/tsc" ]; then
CACHE_VALID=true
echo "Cache hit: dependencies valid, skipping npm ci"
fi
if [ "$CACHE_VALID" = "false" ]; then
echo "Cache miss or invalid: running npm ci..."
if ! npm ci; then
echo "npm ci failed, cleaning node_modules and retrying..."
rm -rf node_modules
mkdir -p node_modules
npm ci
fi
echo "$PACKAGE_LOCK_HASH" > "$CACHE_HASH_FILE"
echo "Dependencies installed, cache updated"
fi
echo "Running TypeScript check..."
npx --no-install tsc
echo "Running Vite build..."
npx --no-install vite build
echo "Build completed successfully"
ls -la dist/
'
- name: Install SSH client and rsync
shell: sh
+200 -12
View File
@@ -12,13 +12,12 @@
from __future__ import annotations
import json
import logging
import math
import random
from typing import Any, Dict, List, Optional
from packages.domain.ai_parsing import generate_titles_fallback as _generate_titles_fallback_base
from packages.domain.ai_parsing import keyword_match_fallback as _semantic_match_fallback_base
from packages.domain.ai_parsing import parse_semantic_match_response as _parse_semantic_match_base
from packages.domain.ai_parsing import parse_titles_from_response as _parse_titles_from_response
from packages.shared.ai_client import get_doubao_client
logger = logging.getLogger(__name__)
@@ -65,9 +64,85 @@ def _generate_titles_fallback(
style: str = "viral",
count: int = 5,
) -> List[str]:
"""本地降级:基于模板规则生成标题(薄包装,转发到 ai_parsing 模块)."""
"""本地降级:基于模板规则生成标题.
当豆包 API 不可用或调用失败时使用,保证接口始终有返回。
"""
style_info = TITLE_STYLES.get(style, TITLE_STYLES["viral"])
return _generate_titles_fallback_base(description, style_info, count)
examples = style_info["examples"]
# 从描述中提取关键词(取前几个词)
keywords = [w for w in description.strip().split() if len(w) > 1][:3]
keyword = keywords[0] if keywords else "精彩内容"
# 基于模板生成
templates = [
f"{keyword}{examples[0][:10]}...",
f"{keyword}{examples[1]}",
f"关于{keyword},你不知道的3件事",
f"{keyword}入门指南,新手必看",
f"深度解析:{keyword}背后的秘密",
f"{keyword}怎么做?手把手教你",
f"干货分享 | {keyword}全攻略",
f"建议收藏:{keyword}实用技巧",
f"{keyword}避坑指南,别再踩雷了",
f"一分钟搞懂{keyword}",
]
random.shuffle(templates)
return templates[: min(count, len(templates))]
def _parse_titles_from_response(content: str) -> List[str]:
"""从模型返回中解析标题列表.
支持多种返回格式:
- JSON 数组: ["标题1", "标题2"]
- 编号列表: 1. 标题1 / 2. 标题2
- 换行分隔: 标题1\n标题2
- 带破折号: - 标题1
"""
if not content:
return []
# 尝试解析 JSON
try:
# 清理可能的 markdown 代码块标记
cleaned = content.strip()
if cleaned.startswith("```"):
cleaned = cleaned.strip("`")
if cleaned.lower().startswith("json"):
cleaned = cleaned[4:]
cleaned = cleaned.strip()
data = json.loads(cleaned)
if isinstance(data, list):
return [str(item).strip() for item in data if str(item).strip()]
if isinstance(data, dict) and "titles" in data:
titles = data["titles"]
if isinstance(titles, list):
return [str(t).strip() for t in titles if str(t).strip()]
except (json.JSONDecodeError, ValueError):
pass
# 尝试按行解析
titles: List[str] = []
for line in content.strip().split("\n"):
line = line.strip()
if not line:
continue
# 去掉编号前缀 "1. " "1、" "1"
import re
line = re.sub(r"^[\d]+[\.、\)]\s*", "", line)
# 去掉破折号前缀 "- " "• "
line = re.sub(r"^[-•·]\s*", "", line)
# 去掉引号
line = line.strip('"').strip("'").strip("「」")
if line and len(line) < 100: # 过滤过长的行
titles.append(line)
return titles
def generate_smart_titles(
@@ -166,19 +241,132 @@ def _semantic_match_fallback(
description: str,
assets: List[Dict[str, Any]],
) -> List[Dict[str, Any]]:
"""本地降级:基于关键词的简单匹配(薄包装,转发到 ai_parsing 模块)."""
return _semantic_match_fallback_base(description, assets)
"""本地降级:基于关键词的简单匹配.
计算描述中的关键词与素材名称/标签/描述的重叠度,
作为匹配度评分。0-1分。
"""
import re
# 提取关键词(中文按2字以上片段,英文按单词)
desc = description.lower()
# 简单分词:提取2字以上的中文字符串和英文单词
keywords = set()
# 英文单词
for word in re.findall(r"[a-zA-Z]{3,}", desc):
keywords.add(word)
# 中文2-4字片段
for i in range(len(desc)):
for j in range(i + 2, min(i + 5, len(desc) + 1)):
fragment = desc[i:j]
if all("\u4e00" <= c <= "\u9fff" for c in fragment):
keywords.add(fragment)
if not keywords:
# 没有关键词时给所有素材中等分数
for asset in assets:
asset["match_score"] = 0.5
asset["match_reason"] = "fallback_default"
return assets
results = []
for asset in assets:
# 组合素材的文本信息:名称 + 标签 + 描述
asset_text_parts = [
str(asset.get("name", "")).lower(),
" ".join(str(t) for t in asset.get("tags", [])).lower(),
str(asset.get("description", "")).lower(),
]
asset_text = " | ".join(asset_text_parts)
# 计算匹配度:命中关键词占比 + 稀有关键词加权
hit_count = 0
hit_keywords = []
for kw in keywords:
if kw in asset_text:
hit_count += 1
hit_keywords.append(kw)
# 基础匹配度 = 命中关键词数 / 总关键词数(开根号平滑)
base_score = math.sqrt(hit_count / len(keywords)) if keywords else 0.5
# 名称命中加分(名称匹配更重要)
name = str(asset.get("name", "")).lower()
name_hits = sum(1 for kw in hit_keywords if kw in name)
name_bonus = min(0.2, name_hits * 0.05)
score = min(1.0, base_score * 0.8 + name_bonus)
score = round(score, 3)
results.append(
{
**asset,
"match_score": score,
"match_reason": "fallback_keyword",
}
)
# 按匹配度降序
results.sort(key=lambda x: x["match_score"], reverse=True)
return results
def _parse_semantic_match_response(
content: str,
asset_ids: List[str],
) -> Optional[Dict[str, float]]:
"""从模型返回中解析素材匹配度(薄包装,转发到 ai_parsing 模块)."""
result = _parse_semantic_match_base(content, asset_ids)
if result is None:
"""从模型返回中解析素材匹配度.
期望格式:JSON 对象 {asset_id: score} 或 {"matches": [{asset_id, score}]}
score 范围 0-1。
"""
if not content:
return None
return dict(result)
# 尝试解析 JSON
try:
cleaned = content.strip()
if cleaned.startswith("```"):
cleaned = cleaned.strip("`")
if cleaned.lower().startswith("json"):
cleaned = cleaned[4:]
cleaned = cleaned.strip()
data = json.loads(cleaned)
result: Dict[str, float] = {}
# 格式1: {"asset_id1": 0.8, "asset_id2": 0.6}
if isinstance(data, dict):
if "matches" in data and isinstance(data["matches"], list):
# 格式2: {"matches": [{"asset_id": "...", "score": 0.8}]}
for item in data["matches"]:
if isinstance(item, dict):
aid = item.get("asset_id") or item.get("id")
score = item.get("score", 0)
if aid and isinstance(score, (int, float)):
result[str(aid)] = max(0.0, min(1.0, float(score)))
else:
for key, value in data.items():
if isinstance(value, (int, float)):
result[str(key)] = max(0.0, min(1.0, float(value)))
# 格式3: [{"asset_id": "...", "score": 0.8}]
elif isinstance(data, list):
for item in data:
if isinstance(item, dict):
aid = item.get("asset_id") or item.get("id")
score = item.get("score", 0)
if aid and isinstance(score, (int, float)):
result[str(aid)] = max(0.0, min(1.0, float(score)))
if len(result) >= max(1, len(asset_ids) // 2): # 至少一半素材有评分才算成功
return result
except (json.JSONDecodeError, ValueError):
pass
return None
def semantic_match_assets(
+63 -52
View File
@@ -16,11 +16,6 @@ from packages.adapters.sqlalchemy_impl import (
SQLAlchemyEditPlanRepository,
SQLAlchemyGenerationTaskRepository,
)
from packages.domain.clip_operations import calculate_merge as _calc_merge
from packages.domain.clip_operations import calculate_shift_orders as _calc_shift_orders
from packages.domain.clip_operations import calculate_split as _calc_split
from packages.domain.clip_operations import validate_merge_clips as _validate_merge
from packages.domain.clip_operations import validate_split_time as _validate_split
from packages.domain.edit_plan import EditPlan, EditPlanStatus
from packages.domain.edit_plan_clip import EditPlanClip, EditPlanClipStatus
@@ -389,45 +384,36 @@ class EditPlanService:
clip = self.get_clip_or_raise(clip_id)
plan_id = clip.plan_id
# 纯逻辑:校验 + 计算
_validate_split(split_time, clip.duration)
split = _calc_split(
duration=clip.duration,
split_time=split_time,
start_time=clip.start_time,
)
if split_time <= 0 or split_time >= clip.duration:
raise ValueError(f"分割时间必须在 (0, {clip.duration:.3f}) 范围内,当前: {split_time}")
self._auto_resume_editing(plan_id)
original_duration = clip.duration
left_duration = round(split_time, 3)
right_duration = round(original_duration - split_time, 3)
original_order = clip.order
# 更新左半部分(原片段)
clip.duration = split.left_duration
clip.duration = left_duration
left_clip = self._clip_repo.update(clip)
# 后面片段的 order 全部 +1(给右半部分腾位置)
all_clips = self._clip_repo.list_by_plan(plan_id)
shifts = _calc_shift_orders(
all_clips,
threshold_order=original_order,
shift=1,
excluded_ids={clip_id},
id_attr="id",
order_attr="order",
)
for c, new_order in shifts:
c.order = new_order
self._clip_repo.update(c)
for c in all_clips:
if c.order > original_order and c.id != clip_id:
c.order += 1
self._clip_repo.update(c)
# 创建右半部分新片段(继承原片段的大部分属性)
right_config = dict(clip.config) if clip.config else {}
# 素材裁剪信息
if clip.asset_id:
# 右半部分从 split_time 开始播放
right_config["trim_start"] = split.right_trim_start
right_config["trim_start"] = left_duration
# 左半部分在 split_time 处结束
left_config = dict(left_clip.config) if left_clip.config else {}
left_config["trim_end"] = split.left_trim_end
left_config["trim_end"] = right_duration
left_clip.config = left_config
left_clip = self._clip_repo.update(left_clip)
@@ -438,8 +424,8 @@ class EditPlanService:
template_clip_config_id=clip.template_clip_config_id,
asset_id=clip.asset_id,
text_content=clip.text_content,
start_time=split.right_start_time,
duration=split.right_duration,
start_time=clip.start_time + left_duration,
duration=right_duration,
transition_effect=clip.transition_effect,
transition_duration=clip.transition_duration,
playback_speed=clip.playback_speed,
@@ -452,8 +438,8 @@ class EditPlanService:
clip_id,
plan_id,
split_time,
split.left_duration,
split.right_duration,
left_duration,
right_duration,
)
return {
@@ -482,45 +468,70 @@ class EditPlanService:
clip = self.get_clip_or_raise(cid)
clips.append(clip)
# 纯逻辑:校验 + 计算
plan_id, first_order = _validate_merge(clips)
merge = _calc_merge(clips)
# 校验:同一计划
plan_id = clips[0].plan_id
for c in clips[1:]:
if c.plan_id != plan_id:
raise ValueError("只能合并同一计划下的片段")
# 按 order 排序
clips.sort(key=lambda c: c.order)
# 校验:order 连续
for i in range(1, len(clips)):
if clips[i].order != clips[i - 1].order + 1:
raise ValueError(f"片段不连续:order {clips[i-1].order}{clips[i].order}")
# 校验:类型一致
clip_type = clips[0].clip_type
for c in clips[1:]:
if c.clip_type != clip_type:
raise ValueError("只能合并相同类型的片段")
self._auto_resume_editing(plan_id)
# 计算合并后的属性
first_clip = clips[0]
total_duration = round(sum(c.duration for c in clips), 3)
first_order = first_clip.order
# 合并文案(用换行连接)
merged_text = "\n".join(c.text_content for c in clips if c.text_content.strip())
# 合并 config(后面的覆盖前面的)
merged_config: Dict[str, Any] = {}
for c in clips:
if c.config:
merged_config.update(c.config)
# 清理 trim 相关字段(合并后就是完整片段了)
merged_config.pop("trim_start", None)
merged_config.pop("trim_end", None)
# 更新第一个片段(保留它作为合并结果)
first_clip = sorted(clips, key=lambda c: c.order)[0]
first_clip.duration = merge.total_duration
first_clip.text_content = merge.merged_text
first_clip.config = merge.merged_config
first_clip.duration = total_duration
first_clip.text_content = merged_text
first_clip.config = merged_config
# 转场保留第一个的(合并后的入点转场)
# playback_speed 取第一个的
merged_clip = self._clip_repo.update(first_clip)
# 删除其余片段
rest_ids = [c.id for c in clips if c.id != merged_clip.id]
for cid in rest_ids:
self._clip_repo.delete(cid)
for c in clips[1:]:
self._clip_repo.delete(c.id)
# 后面的片段 order 前移 (len - 1) 位
shift = len(clips) - 1
all_clips = self._clip_repo.list_by_plan(plan_id)
shifts = _calc_shift_orders(
all_clips,
threshold_order=first_order,
shift=-merge.shift_amount,
excluded_ids={merged_clip.id},
id_attr="id",
order_attr="order",
)
for c, new_order in shifts:
c.order = new_order
self._clip_repo.update(c)
for c in all_clips:
if c.order > first_order and c.id != merged_clip.id:
c.order -= shift
self._clip_repo.update(c)
logger.info(
"合并片段: plan_id=%s count=%d total_duration=%.3fs",
plan_id,
len(clips),
merge.total_duration,
total_duration,
)
return merged_clip
+161 -15
View File
@@ -23,13 +23,6 @@ from packages.domain.template_clip_config import (
TemplateClipConfig,
TransitionEffect,
)
from packages.domain.template_clip_converter import (
clip_configs_to_snapshots,
clips_to_template_clip_configs,
filter_plan_config_to_template,
snapshots_to_template_clip_configs,
validate_template_name,
)
logger = logging.getLogger(__name__)
@@ -128,7 +121,9 @@ class EditTemplateService:
ValueError: 名称为空或重复
"""
# 名称校验
clean_name = validate_template_name(name)
clean_name = name.strip()
if not clean_name:
raise ValueError("模板名称不能为空")
# 名称重复检查
existing = self._template_repo.list_all(skip=0, limit=1000)
@@ -476,7 +471,12 @@ class EditTemplateService:
raise ValueError(f"模板名称已存在: {clean_name}")
# 从计划 config 中提取模板级配置,去掉运行时/素材相关字段
template_config = filter_plan_config_to_template(plan.config)
plan_config = plan.config or {}
template_config: dict[str, Any] = {}
for key, value in plan_config.items():
# 跳过明显的运行时/实例字段,保留风格/模式类配置
if key not in {"asset_ids", "source_edit_plan_id", "generation_task_id"}:
template_config[key] = value
template = EditTemplate.create(
name=clean_name,
@@ -497,7 +497,40 @@ class EditTemplateService:
# 5. 转换每个片段为模板片段配置
created_configs: List[TemplateClipConfig] = []
for clip_config_obj in clips_to_template_clip_configs(created_template.id, clips):
for clip in clips:
clip_config: dict[str, Any] = {}
# 播放速度存入 config
if clip.playback_speed and clip.playback_speed != 1.0:
clip_config["playback_speed"] = clip.playback_speed
# 片段自有 config 合并(优先级:clip.config 覆盖上面的)
if clip.config:
clip_config.update(clip.config)
# 去掉素材相关字段
clip_config.pop("asset_info", None)
clip_config.pop("source_asset_id", None)
# 转场效果兼容校验
try:
transition = TransitionEffect(clip.transition_effect)
except ValueError:
transition = TransitionEffect.CUT
# 片段类型兼容校验
try:
clip_type = ClipType(clip.clip_type)
except ValueError:
clip_type = ClipType.MAIN
clip_config_obj = TemplateClipConfig.create(
template_id=created_template.id,
clip_type=clip_type,
order=clip.order,
min_duration=clip.duration,
max_duration=clip.duration,
text_template=clip.text_content or "",
transition_effect=transition,
config=clip_config,
)
created = self._clip_config_repo.create(clip_config_obj)
created_configs.append(created)
@@ -646,6 +679,8 @@ class EditTemplateService:
Raises:
ValueError: 模板/草稿不存在,或草稿不属于该模板
"""
from packages.domain.template_clip_config import TemplateClipConfig
# 1. 校验模板和草稿
template = self.get_template_or_raise(template_id)
draft = self._plan_repo.get(draft_plan_id)
@@ -665,14 +700,39 @@ class EditTemplateService:
editing_mode = config.get("editing_mode", "one_take")
# 4. 提取模板配置(去掉草稿/运行时字段)
template_config = filter_plan_config_to_template(draft.config)
draft_config = draft.config or {}
template_config: dict[str, Any] = {}
skip_keys = {
"is_template_draft",
"asset_ids",
"source_edit_plan_id",
"generation_task_id",
}
for key, value in draft_config.items():
if key not in skip_keys:
template_config[key] = value
# 5. 事务更新
try:
# 5.0 先保存旧版快照(发布前的状态),用于回滚
old_version = template.version or 1
old_clip_configs = self._clip_config_repo.list_by_template(template_id)
old_clip_snapshots = clip_configs_to_snapshots(old_clip_configs)
old_clip_snapshots = [
{
"clip_type": cfg.clip_type.value if hasattr(cfg.clip_type, "value") else cfg.clip_type,
"order": cfg.order,
"min_duration": cfg.min_duration,
"max_duration": cfg.max_duration,
"text_template": cfg.text_template or "",
"transition_effect": (
cfg.transition_effect.value
if hasattr(cfg.transition_effect, "value")
else cfg.transition_effect
),
"config": cfg.config or {},
}
for cfg in old_clip_configs
]
from packages.domain.template_version import EditTemplateVersion
@@ -699,7 +759,46 @@ class EditTemplateService:
# 创建新的片段配置
created_configs: list[TemplateClipConfig] = []
for config_obj in clips_to_template_clip_configs(template_id, draft_clips):
for clip in draft_clips:
clip_config: dict[str, Any] = {}
# 播放速度存入 config
if clip.playback_speed and clip.playback_speed != 1.0:
clip_config["playback_speed"] = clip.playback_speed
# 片段自有 config 合并
if clip.config:
clip_config.update(clip.config)
# 去掉素材相关字段
clip_config.pop("asset_info", None)
clip_config.pop("source_asset_id", None)
# 转场效果兼容校验
try:
from packages.domain.template_clip_config import (
TransitionEffect,
)
transition = TransitionEffect(clip.transition_effect)
except (ValueError, ImportError):
transition = TransitionEffect.CUT # type: ignore
# 片段类型兼容校验
try:
from packages.domain.template_clip_config import ClipType
clip_type = ClipType(clip.clip_type)
except (ValueError, ImportError):
clip_type = ClipType.MAIN # type: ignore
config_obj = TemplateClipConfig.create(
template_id=template_id,
clip_type=clip_type,
order=clip.order,
min_duration=clip.duration,
max_duration=clip.duration,
text_template=clip.text_content or "",
transition_effect=transition,
config=clip_config,
)
created = self._clip_config_repo.create(config_obj)
created_configs.append(created)
@@ -744,6 +843,8 @@ class EditTemplateService:
Raises:
ValueError: 模板/版本不存在
"""
from packages.domain.template_clip_config import TemplateClipConfig
template = self.get_template_or_raise(template_id)
# 1. 读取目标版本快照
@@ -756,7 +857,22 @@ class EditTemplateService:
try:
# 2. 先保存当前状态快照(当前版本号),确保回滚可撤销
old_clip_configs = self._clip_config_repo.list_by_template(template_id)
old_clip_snapshots = clip_configs_to_snapshots(old_clip_configs)
old_clip_snapshots = [
{
"clip_type": cfg.clip_type.value if hasattr(cfg.clip_type, "value") else cfg.clip_type,
"order": cfg.order,
"min_duration": cfg.min_duration,
"max_duration": cfg.max_duration,
"text_template": cfg.text_template or "",
"transition_effect": (
cfg.transition_effect.value
if hasattr(cfg.transition_effect, "value")
else cfg.transition_effect
),
"config": cfg.config or {},
}
for cfg in old_clip_configs
]
from packages.domain.template_version import EditTemplateVersion
@@ -789,7 +905,37 @@ class EditTemplateService:
synchronize_session=False
)
for config_obj in snapshots_to_template_clip_configs(template_id, target_version.clip_configs):
for clip_snap in target_version.clip_configs:
# 转场效果兼容校验
try:
from packages.domain.template_clip_config import TransitionEffect
transition = TransitionEffect(clip_snap.get("transition_effect", "cut"))
except (ValueError, ImportError):
from packages.domain.template_clip_config import TransitionEffect
transition = TransitionEffect.CUT
# 片段类型兼容校验
try:
from packages.domain.template_clip_config import ClipType
clip_type = ClipType(clip_snap.get("clip_type", "main"))
except (ValueError, ImportError):
from packages.domain.template_clip_config import ClipType
clip_type = ClipType.MAIN
config_obj = TemplateClipConfig.create(
template_id=template_id,
clip_type=clip_type,
order=clip_snap.get("order", 0),
min_duration=clip_snap.get("min_duration", 0.0),
max_duration=clip_snap.get("max_duration", 0.0),
text_template=clip_snap.get("text_template", ""),
transition_effect=transition,
config=clip_snap.get("config", {}) or {},
)
self._clip_config_repo.create(config_obj)
self._db.commit()
+250 -20
View File
@@ -26,13 +26,7 @@ from packages.domain.edit_plan import EditPlan
from packages.domain.edit_plan_clip import EditPlanClip
from packages.domain.edit_template import EditTemplate
from packages.domain.editing_mode import EditingMode
from packages.domain.plan_generator_utils import (
create_clips_from_configs,
distribute_assets,
generate_default_clips,
map_clip_types_for_mode,
)
from packages.domain.template_clip_config import TemplateClipConfig
from packages.domain.template_clip_config import ClipType, TemplateClipConfig
logger = logging.getLogger(__name__)
@@ -169,18 +163,83 @@ class PlanGeneratorService:
plan_id: str,
clip_configs: List[TemplateClipConfig],
) -> List[EditPlanClip]:
"""从 TemplateClipConfig 列表创建 EditPlanClip 列表(未持久化).
"""从 TemplateClipConfig 列表创建 EditPlanClip 列表(未持久化)"""
clips: List[EditPlanClip] = []
# 按 order 排序
sorted_configs = sorted(clip_configs, key=lambda c: c.order)
委托给 plan_generator_utils.create_clips_from_configs 纯函数。
"""
return create_clips_from_configs(plan_id, clip_configs)
for cfg in sorted_configs:
# 计算时长:取 min_duration 和 max_duration 的中间值
if cfg.min_duration > 0 and cfg.max_duration > 0:
duration = (cfg.min_duration + cfg.max_duration) / 2
elif cfg.min_duration > 0:
duration = cfg.min_duration
elif cfg.max_duration > 0:
duration = cfg.max_duration
else:
duration = _DEFAULT_CLIP_DURATION
# clip_type 可能是枚举或字符串
clip_type = cfg.clip_type.value if hasattr(cfg.clip_type, "value") else cfg.clip_type
# transition_effect 可能是枚举或字符串
transition = (
cfg.transition_effect.value if hasattr(cfg.transition_effect, "value") else cfg.transition_effect
)
# 从 clip config 中解析 playback_speed(兼容 speed_ratio 字段名)
clip_cfg = cfg.config or {}
playback_speed = clip_cfg.get("playback_speed", clip_cfg.get("speed_ratio", 1.0)) or 1.0
clip = EditPlanClip.create(
plan_id=plan_id,
clip_type=clip_type,
order=cfg.order,
template_clip_config_id=cfg.id,
text_content=getattr(cfg, "text_template", "") or "",
duration=duration,
transition_effect=transition or "cut",
playback_speed=playback_speed,
config=clip_cfg,
)
clips.append(clip)
return clips
def _map_clip_types_for_mode(self, clips: List[EditPlanClip], editing_mode: str) -> None:
"""将 MAIN 类型片段按 editing_mode 映射为对应角色类型.
"""模板 clip_config 生成的 MAIN 类型片段按 editing_mode 映射为对应角色类型
委托给 plan_generator_utils.map_clip_types_for_mode 纯函数。
模板的 clip_config 使用 ClipType 枚举(main/intro/outro 等),
但 PIP / VOICE_PIP 模式的素材分配和渲染分层依赖特定的 clip_type 命名
overlay / background / corner_voice / b_roll)。
映射规则(仅修改 MAIN 类型片段,非 MAIN 片段保持原类型):
- PIP: 第1个 MAIN → main(背景),其余 MAIN → overlay(画中画)
- VOICE_PIP: 第1个 → background,第2个 → corner_voice,第3+个 → b_roll
- ONE_TAKE / VOICE_OVER: 保持 main 不变
"""
map_clip_types_for_mode(clips, editing_mode)
from packages.domain.template_clip_config import ClipType
main_clips = [c for c in clips if c.clip_type == ClipType.MAIN.value]
if not main_clips:
return
if editing_mode == EditingMode.PIP.value:
# 第1个 main 保持(背景层),其余改为 overlay(画中画层)
for i, clip in enumerate(main_clips):
if i > 0:
clip.clip_type = "overlay"
elif editing_mode == EditingMode.VOICE_PIP.value:
for i, clip in enumerate(main_clips):
if i == 0:
clip.clip_type = "background"
elif i == 1:
clip.clip_type = "corner_voice"
else:
clip.clip_type = "b_roll"
# ONE_TAKE / VOICE_OVER: 保持 main 不变,无需处理
def _generate_default_clips(
self,
@@ -188,11 +247,101 @@ class PlanGeneratorService:
editing_mode: str,
asset_count: int,
) -> List[EditPlanClip]:
"""无 clip_configs 时,根据 editing_mode 生成默认 clip 结构.
"""无 clip_configs 时,根据 editing_mode 生成默认 clip 结构
委托给 plan_generator_utils.generate_default_clips 纯函数。
- ONE_TAKE: N 个 main clipsN = asset_count,至少1个)
- PIP: 1 个 main + (N-1) 个 overlayN = asset_count
- VOICE_OVER: N 个 main clips + 标记需要配音
- VOICE_PIP: 1 个 background + 1 个 corner_voice + (N-2) 个 b_roll
"""
return generate_default_clips(plan_id, editing_mode, asset_count)
n = max(asset_count, 1)
clips: List[EditPlanClip] = []
order = 0
if editing_mode == EditingMode.PIP.value:
# 1 个 main(全屏背景)
clips.append(
EditPlanClip.create(
plan_id=plan_id,
clip_type=ClipType.MAIN.value,
order=order,
duration=_DEFAULT_CLIP_DURATION,
)
)
order += 1
# 剩余为 overlay
for _ in range(1, n):
clips.append(
EditPlanClip.create(
plan_id=plan_id,
clip_type="overlay",
order=order,
duration=_DEFAULT_CLIP_DURATION,
)
)
order += 1
elif editing_mode == EditingMode.VOICE_OVER.value:
# N 个 main clipsB-roll
for _ in range(n):
clips.append(
EditPlanClip.create(
plan_id=plan_id,
clip_type=ClipType.MAIN.value,
order=order,
duration=_DEFAULT_CLIP_DURATION,
config={"role": "b_roll"},
)
)
order += 1
elif editing_mode == EditingMode.VOICE_PIP.value:
# 1 个 background
clips.append(
EditPlanClip.create(
plan_id=plan_id,
clip_type="background",
order=order,
duration=_DEFAULT_CLIP_DURATION,
)
)
order += 1
# 1 个 corner_voice
clips.append(
EditPlanClip.create(
plan_id=plan_id,
clip_type="corner_voice",
order=order,
duration=_DEFAULT_CLIP_DURATION,
)
)
order += 1
# 剩余为 b_roll
for _ in range(2, n):
clips.append(
EditPlanClip.create(
plan_id=plan_id,
clip_type="b_roll",
order=order,
duration=_DEFAULT_CLIP_DURATION,
)
)
order += 1
else:
# ONE_TAKE: N 个 main clips
for _ in range(n):
clips.append(
EditPlanClip.create(
plan_id=plan_id,
clip_type=ClipType.MAIN.value,
order=order,
duration=_DEFAULT_CLIP_DURATION,
)
)
order += 1
return clips
def _distribute_assets(
self,
@@ -200,8 +349,89 @@ class PlanGeneratorService:
asset_ids: List[str],
editing_mode: str,
) -> None:
"""按 editing_mode 将素材分配到 clips(就地修改,未持久化).
"""按 editing_mode 将素材分配到 clips(就地修改,未持久化)
委托给 plan_generator_utils.distribute_assets 纯函数。
分配策略:
- ONE_TAKE: 素材按顺序依次分配给 main 类型 clips
- PIP: 第1个素材→main(全屏背景),其余→交替分配给 overlay clips
- VOICE_OVER: 素材→main clips (B-roll)
- VOICE_PIP: 第1个→background, 第2个→corner_voice, 其余→b_roll
"""
distribute_assets(clips, asset_ids, editing_mode)
if not asset_ids or not clips:
return
if editing_mode == EditingMode.ONE_TAKE.value:
self._distribute_one_take(clips, asset_ids)
elif editing_mode == EditingMode.PIP.value:
self._distribute_pip(clips, asset_ids)
elif editing_mode == EditingMode.VOICE_OVER.value:
self._distribute_voice_over(clips, asset_ids)
elif editing_mode == EditingMode.VOICE_PIP.value:
self._distribute_voice_pip(clips, asset_ids)
else:
# 未知模式,退化为 one_take
self._distribute_one_take(clips, asset_ids)
def _distribute_one_take(
self,
clips: List[EditPlanClip],
asset_ids: List[str],
) -> None:
"""ONE_TAKE: 素材按顺序依次分配给 main 类型 clips"""
main_clips = [c for c in clips if c.clip_type == ClipType.MAIN.value]
for i, clip in enumerate(main_clips):
if i < len(asset_ids):
clip.assign_asset(asset_ids[i])
def _distribute_pip(
self,
clips: List[EditPlanClip],
asset_ids: List[str],
) -> None:
"""PIP: 第1个素材→main(全屏背景),其余→overlay clips"""
# 第1个素材 → main clip
main_clips = [c for c in clips if c.clip_type == ClipType.MAIN.value]
if main_clips and asset_ids:
main_clips[0].assign_asset(asset_ids[0])
# 其余素材 → overlay clips
overlay_clips = [c for c in clips if c.clip_type == "overlay"]
remaining = asset_ids[1:]
for i, clip in enumerate(overlay_clips):
if i < len(remaining):
clip.assign_asset(remaining[i])
def _distribute_voice_over(
self,
clips: List[EditPlanClip],
asset_ids: List[str],
) -> None:
"""VOICE_OVER: 素材→main clips (B-roll)"""
main_clips = [c for c in clips if c.clip_type == ClipType.MAIN.value]
for i, clip in enumerate(main_clips):
if i < len(asset_ids):
clip.assign_asset(asset_ids[i])
def _distribute_voice_pip(
self,
clips: List[EditPlanClip],
asset_ids: List[str],
) -> None:
"""VOICE_PIP: 第1个→background, 第2个→corner_voice, 其余→b_roll"""
bg_clips = [c for c in clips if c.clip_type == "background"]
corner_clips = [c for c in clips if c.clip_type == "corner_voice"]
broll_clips = [c for c in clips if c.clip_type == "b_roll"]
# 第1个素材 → background
if bg_clips and len(asset_ids) > 0:
bg_clips[0].assign_asset(asset_ids[0])
# 第2个素材 → corner_voice
if corner_clips and len(asset_ids) > 1:
corner_clips[0].assign_asset(asset_ids[1])
# 其余素材 → b_roll
remaining = asset_ids[2:]
for i, clip in enumerate(broll_clips):
if i < len(remaining):
clip.assign_asset(remaining[i])
+210 -48
View File
@@ -13,26 +13,57 @@
- 最低质量分门槛:自动过滤低质量素材
- 时长多样性:保证选出的素材时长分布均匀(短/中/长各占一定比例)
- 兼容全部模式:素材库模式和项目模式都可用
纯逻辑部分已抽离到 packages.domain.asset_scoring。
"""
from __future__ import annotations
import logging
from packages.domain.asset_scoring import MEDIUM_BUCKET_MAX as _MEDIUM_BUCKET_MAX # noqa: F401 - re-export for tests
from packages.domain.asset_scoring import SHORT_BUCKET_MAX as _SHORT_BUCKET_MAX # noqa: F401 - re-export for tests
from packages.domain.asset_scoring import (
AssetScoreDetail,
SmartSelectResult,
diverse_selection,
filter_candidates,
score_asset_detail,
)
from dataclasses import dataclass
logger = logging.getLogger(__name__)
# ── 评分权重 ──────────────────────────────────────────────────────────────────
_WEIGHT_QUALITY = 0.5
_WEIGHT_RESOLUTION = 0.2
_WEIGHT_DURATION = 0.2
_WEIGHT_BITRATE = 0.1
# ── 评分参数 ──────────────────────────────────────────────────────────────────
_TARGET_WIDTH = 1920 # 目标分辨率宽度基准
_TARGET_HEIGHT = 1080 # 目标分辨率高度基准
_MIN_QUALITY_SCORE = 30.0 # 最低质量分门槛(低于此值的素材直接排除)
_OPTIMAL_DURATION_MIN = 3.0 # 最佳时长区间(秒)
_OPTIMAL_DURATION_MAX = 30.0
# ── 多样性分桶 ───────────────────────────────────────────────────────────────
_SHORT_BUCKET_MAX = 5.0 # 短素材:< 5s
_MEDIUM_BUCKET_MAX = 15.0 # 中素材:5-15s
# 长素材:> 15s
@dataclass
class SmartSelectResult:
"""智能选择结果."""
selected_ids: list[str]
total_candidates: int
filtered_out: int # 被质量门槛过滤的数量
avg_score: float
details: list[AssetScoreDetail]
@dataclass
class AssetScoreDetail:
"""单个素材的评分详情."""
asset_id: str
total_score: float
quality_score: float
resolution_score: float
duration_score: float
bitrate_score: float
duration: float | None
class SmartAssetSelector:
"""智能素材选择器.
@@ -43,9 +74,9 @@ class SmartAssetSelector:
def __init__(
self,
min_quality_score: float = 30.0,
target_width: int = 1920,
target_height: int = 1080,
min_quality_score: float = _MIN_QUALITY_SCORE,
target_width: int = _TARGET_WIDTH,
target_height: int = _TARGET_HEIGHT,
):
self.min_quality_score = min_quality_score
self.target_width = target_width
@@ -71,7 +102,21 @@ class SmartAssetSelector:
SmartSelectResult 选择结果
"""
# 1. 过滤:只保留 ready 状态的视频素材 + 最低质量分门槛
candidates, filtered_out = filter_candidates(assets, self.min_quality_score)
candidates = []
filtered_out = 0
for asset in assets:
status = getattr(asset, "status", None)
status_val = status.value if hasattr(status, "value") else str(status)
if status_val != "ready":
continue
mime_type = getattr(asset, "mime_type", "") or ""
if not mime_type.startswith("video"):
continue
quality = getattr(asset, "quality_score", None)
if quality is not None and quality < self.min_quality_score:
filtered_out += 1
continue
candidates.append(asset)
if not candidates:
return SmartSelectResult(
@@ -85,16 +130,7 @@ class SmartAssetSelector:
# 2. 对每个候选素材评分
scored: list[AssetScoreDetail] = []
for asset in candidates:
detail = score_asset_detail(
asset_id=asset.id,
quality=getattr(asset, "quality_score", None),
width=getattr(asset, "width", None),
height=getattr(asset, "height", None),
duration=getattr(asset, "duration", None),
file_size=getattr(asset, "file_size", 0) or 0,
target_width=self.target_width,
target_height=self.target_height,
)
detail = self._score_asset(asset)
scored.append(detail)
# 3. 按总分降序排列
@@ -102,7 +138,7 @@ class SmartAssetSelector:
# 4. 多样性选择(如果需要且数量有限制)
if ensure_diversity and count > 0 and len(scored) > count:
selected = diverse_selection(scored, count)
selected = self._diverse_selection(scored, count)
else:
# 无数量限制或不要求多样性,直接按排名取
selected = scored if count <= 0 else scored[:count]
@@ -126,39 +162,165 @@ class SmartAssetSelector:
)
return result
# ── 向后兼容:私有方法别名(委托给 asset_scoring 纯函数) ────────────────
# ── 内部方法 ──────────────────────────────────────────────────────────────
def _score_asset(self, asset) -> AssetScoreDetail:
"""对单个素材进行多维度评分(向后兼容)."""
return score_asset_detail(
"""对单个素材进行多维度评分."""
# 质量分
quality = getattr(asset, "quality_score", None)
quality_score = (quality / 100.0) if quality is not None else 0.5
# 分辨率评分:越接近目标分辨率得分越高
width = getattr(asset, "width", None)
height = getattr(asset, "height", None)
resolution_score = self._score_resolution(width, height)
# 时长评分:在最佳区间内得分高,过短过长扣分
duration = getattr(asset, "duration", None)
duration_score = self._score_duration(duration)
# 码率评分:用 file_size/duration 估算,适中得分高
file_size = getattr(asset, "file_size", 0) or 0
bitrate_score = self._score_bitrate(file_size, duration)
# 加权总分
total = (
_WEIGHT_QUALITY * quality_score
+ _WEIGHT_RESOLUTION * resolution_score
+ _WEIGHT_DURATION * duration_score
+ _WEIGHT_BITRATE * bitrate_score
)
return AssetScoreDetail(
asset_id=asset.id,
quality=getattr(asset, "quality_score", None),
width=getattr(asset, "width", None),
height=getattr(asset, "height", None),
duration=getattr(asset, "duration", None),
file_size=getattr(asset, "file_size", 0) or 0,
target_width=self.target_width,
target_height=self.target_height,
total_score=round(total, 4),
quality_score=round(quality_score, 4),
resolution_score=round(resolution_score, 4),
duration_score=round(duration_score, 4),
bitrate_score=round(bitrate_score, 4),
duration=duration,
)
def _score_resolution(self, width: int | None, height: int | None) -> float:
"""分辨率评分(向后兼容)."""
from packages.domain.asset_scoring import score_resolution
"""分辨率评分:越接近目标分辨率得分越高,低于480p扣分严重."""
if width is None or height is None or width <= 0 or height <= 0:
return 0.5 # 未知分辨率给中评分
return score_resolution(width, height, self.target_width, self.target_height)
target_pixels = self.target_width * self.target_height
actual_pixels = width * height
# 计算像素数比例
ratio = actual_pixels / target_pixels
if ratio >= 1.0:
# 高于或等于目标分辨率:满分,略高不扣分(4K也给满分)
return 1.0
else:
# 低于目标分辨率:线性衰减,但最低不低于 0.1
# 例如:720p (921600) / 1080p (2073600) = 0.44 → 得分 0.6
score = 0.3 + 0.7 * ratio
return max(0.1, min(1.0, score))
def _score_duration(self, duration: float | None) -> float:
"""时长评分(向后兼容)."""
from packages.domain.asset_scoring import score_duration
"""时长评分3-30秒最佳,过短或过长都扣分."""
if duration is None or duration <= 0:
return 0.5 # 未知时长给中评分
return score_duration(duration)
if _OPTIMAL_DURATION_MIN <= duration <= _OPTIMAL_DURATION_MAX:
# 最佳区间:满分
return 1.0
if duration < _OPTIMAL_DURATION_MIN:
# 太短:线性衰减,1秒以下给 0.3
ratio = duration / _OPTIMAL_DURATION_MIN
return 0.3 + 0.7 * ratio
# 太长:每超过最佳区间上限10秒扣 0.1 分,最低 0.2
excess = duration - _OPTIMAL_DURATION_MAX
penalty = min(0.8, excess / 10.0 * 0.1)
return max(0.2, 1.0 - penalty)
def _score_bitrate(self, file_size: int, duration: float | None) -> float:
"""码率评分(向后兼容)."""
from packages.domain.asset_scoring import score_bitrate
"""码率评分:根据文件大小和时长估算码率,适中得分高."""
if not file_size or not duration or duration <= 0:
return 0.5 # 未知给中评分
return score_bitrate(file_size, duration)
# 估算码率(bps
bitrate = (file_size * 8) / duration
# 最佳码率范围:2-8 Mbps
optimal_low = 2_000_000 # 2 Mbps
optimal_high = 8_000_000 # 8 Mbps
if optimal_low <= bitrate <= optimal_high:
return 1.0
if bitrate < optimal_low:
# 码率太低:线性衰减
ratio = bitrate / optimal_low
return 0.3 + 0.7 * ratio
# 码率太高(文件太大):适度扣分
excess = bitrate / optimal_high - 1.0
penalty = min(0.5, excess * 0.2)
return max(0.5, 1.0 - penalty)
def _diverse_selection(self, scored: list[AssetScoreDetail], count: int) -> list[AssetScoreDetail]:
"""多样性选择(向后兼容)."""
return diverse_selection(scored, count)
"""多样性选择:按时长分桶,保证每个桶都有素材.
策略:
1. 按时长分为三桶:短(<5s)、中(5-15s)、长(>15s)
2. 每个桶配额 = max(1, count / 3)
3. 先从每桶按配额取最高分的
4. 剩余名额从全局最高分中取(不重复)
"""
# 分桶
short_bucket = [d for d in scored if d.duration is not None and d.duration < _SHORT_BUCKET_MAX]
medium_bucket = [
d for d in scored if d.duration is not None and _SHORT_BUCKET_MAX <= d.duration < _MEDIUM_BUCKET_MAX
]
long_bucket = [d for d in scored if d.duration is not None and d.duration >= _MEDIUM_BUCKET_MAX]
unknown_bucket = [d for d in scored if d.duration is None]
buckets = [short_bucket, medium_bucket, long_bucket]
bucket_names = ["short", "medium", "long"]
# 每个桶基础配额(至少1个,如果桶非空且需要的话)
base_quota = max(1, count // 3)
selected: list[AssetScoreDetail] = []
selected_ids: set[str] = set()
# 先按配额从每个桶取
for bucket, _name in zip(buckets, bucket_names, strict=False):
quota = min(base_quota, len(bucket))
if quota <= 0:
continue
# 桶内已经按分数排好序了,直接取前 quota 个
for item in bucket[:quota]:
if item.asset_id not in selected_ids:
selected.append(item)
selected_ids.add(item.asset_id)
if len(selected) >= count:
return selected
# 剩余名额:从全局(未被选中的)中按分数高低取
remaining_needed = count - len(selected)
if remaining_needed > 0:
for item in scored:
if item.asset_id not in selected_ids:
selected.append(item)
selected_ids.add(item.asset_id)
if len(selected) >= count:
break
# 如果还不够(不应该发生),加上未知时长的
if len(selected) < count and unknown_bucket:
for item in unknown_bucket:
if item.asset_id not in selected_ids:
selected.append(item)
selected_ids.add(item.asset_id)
if len(selected) >= count:
break
return selected[:count]
+245 -30
View File
@@ -29,33 +29,47 @@ from packages.adapters.sqlalchemy_impl.edit_plan_repository import (
)
from packages.domain.edit_plan import EditPlanStatus
from packages.domain.edit_plan_clip import EditPlanClip, EditPlanClipStatus
from packages.domain.video_filter_builder import (
DEFAULT_FPS,
DEFAULT_OUTPUT_HEIGHT,
DEFAULT_OUTPUT_WIDTH,
DEFAULT_TRANSITION_DURATION,
ClipFilterChain,
build_clip_filter,
)
from packages.domain.video_filter_builder import build_concat_filter as _build_concat_filter_func
from packages.domain.video_filter_builder import build_filter_complex as _build_filter_complex
from packages.domain.video_filter_builder import build_xfade_filter as _build_xfade_filter_func
from packages.domain.video_filter_builder import chain_filters as _chain_filters_func
from packages.domain.video_filter_builder import has_audio as _has_audio_func
from packages.domain.template_clip_config import TransitionEffect
logger = logging.getLogger(__name__)
# ── 常量(向后兼容别名) ──────────────────────────────────────────────────────
# 实际定义已迁移至 packages/domain/video_filter_builder.py
# ── 常量 ──────────────────────────────────────────────────────────────────────
DEFAULT_OUTPUT_WIDTH = 1280
DEFAULT_OUTPUT_HEIGHT = 720
DEFAULT_FPS = 25
DEFAULT_CODEC = "libx264"
DEFAULT_CRF = 23
DEFAULT_PRESET = "medium"
# xfade 转场映射:TransitionEffect → FFmpeg xfade transition 名称
_XFADE_TRANSITION_MAP: dict[str, str] = {
TransitionEffect.FADE: "fade",
TransitionEffect.SLIDE_LEFT: "slideleft",
TransitionEffect.SLIDE_RIGHT: "slideright",
TransitionEffect.DISSOLVE: "dissolve",
TransitionEffect.WIPE: "wipeleft",
}
# 转场默认时长(秒)
DEFAULT_TRANSITION_DURATION = 0.5
# ── 数据结构 ──────────────────────────────────────────────────────────────────
@dataclass(frozen=True)
class ClipFilterChain:
"""单个片段的滤镜链描述。"""
clip_id: str
input_index: int
video_label: str
audio_label: str | None
filters: list[str]
duration: float
@dataclass(frozen=True)
class ComposeCommand:
"""完整的 FFmpeg 合成命令描述。"""
@@ -387,8 +401,62 @@ class VideoComposeService:
output_height: int,
fps: int,
) -> ClipFilterChain:
"""向后兼容:委托给 video_filter_builder.build_clip_filter。"""
return build_clip_filter(clip, input_index, output_width, output_height, fps)
"""为单个片段构建滤镜链。
滤镜顺序:
1. scale — 等比缩放到目标分辨率(保证覆盖)
2. crop — 居中裁剪到目标分辨率
3. fps — 统一输出帧率(concat 要求所有输入帧率一致)
4. setpts — 重置时间戳 + 偏移
5. trim — 视频时长裁剪
6. atrim — 音频时长裁剪(如有音频流)
"""
duration = clip.duration if clip.duration > 0 else 5.0 # 默认 5 秒
start = clip.start_time
filters: list[str] = []
# 1. scale: 等比缩放(保持比例,不裁剪)
filters.append(f"scale={output_width}:{output_height}" f":force_original_aspect_ratio=decrease")
# 2. pad: 居中+留黑边到目标分辨率(保持原始比例,不裁剪内容)
filters.append(f"pad={output_width}:{output_height}:(ow-iw)/2:(oh-ih)/2:black")
# 3. format: 统一像素格式为 yuv420pH.264 标准格式,concat 要求所有输入像素格式一致)
# 不同素材可能是 yuv420p / yuv422p / yuv444p / nv12 等,必须统一
filters.append("format=yuv420p")
# 4. fps: 统一帧率(concat 要求所有输入帧率一致)
# 放在 pad 之后、setpts 之前,确保分辨率和帧率都已统一
if fps and fps > 0:
filters.append(f"fps={fps}")
# 3. setpts: 重置时间戳
if start > 0:
filters.append(f"setpts=PTS-STARTPTS+{start}/TB")
else:
filters.append("setpts=PTS-STARTPTS")
# 4. trim: 视频时长
filters.append(f"trim=0:{duration}")
filters.append("setpts=PTS-STARTPTS") # trim 后需要重置 PTS
video_label = f"v{input_index}"
# 5. 音频标签:仅当片段类型可能有音频时才设置
# title/subtitle 是纯文字/图片卡片,没有音频流
clip_type = clip.clip_type.lower() if clip.clip_type else ""
has_audio_stream = clip_type not in ("title", "subtitle")
audio_label = f"a{input_index}" if has_audio_stream else None
return ClipFilterChain(
clip_id=clip.id,
input_index=input_index,
video_label=video_label,
audio_label=audio_label,
filters=filters,
duration=duration,
)
@staticmethod
def _build_filter_complex(
@@ -398,30 +466,102 @@ class VideoComposeService:
transition_duration: float,
transitions: list[str],
) -> tuple[str, float]:
"""向后兼容:委托给 video_filter_builder.build_filter_complex"""
return _build_filter_complex(clip_chains, output_width, output_height, transition_duration, transitions)
"""构建完整的 filter_complex 字符串。
策略:
- 单片段:直接输出
- 多片段 + 全 cut:使用 concat 滤镜(高效)
- 多片段 + 有转场:使用 xfade 滤镜链
返回 (filter_complex_string, estimated_total_duration)。
"""
n = len(clip_chains)
if n == 0:
return "", 0.0
# ── 单片段 ─────────────────────────────────────────────────────
if n == 1:
chain = clip_chains[0]
filter_str = _chain_filters(chain.filters, chain.video_label)
# 音频
if chain.audio_label:
filter_str += f";[0:a]{chain.audio_label}"
total_duration = chain.duration
return filter_str, total_duration
# ── 检查是否有转场 ─────────────────────────────────────────────
has_transitions = any(t != TransitionEffect.CUT and t != "cut" for t in transitions)
if not has_transitions:
return _build_concat_filter(clip_chains)
# ── 有转场:使用 xfade ─────────────────────────────────────────
return _build_xfade_filter(
clip_chains=clip_chains,
transition_duration=transition_duration,
transitions=transitions,
)
@staticmethod
def _has_audio(clip_chains: list[ClipFilterChain]) -> bool:
"""向后兼容:委托给 video_filter_builder.has_audio"""
return _has_audio_func(clip_chains)
"""是否有任何片段包含音频流"""
return any(c.audio_label is not None for c in clip_chains)
# ── 模块级辅助函数(向后兼容别名) ──────────────────────────────────────────
# 实际实现已迁移至 packages/domain/video_filter_builder.py
# 保留此处别名以兼容现有测试与调用方
# ── 模块级辅助函数 ────────────────────────────────────────────────────────────
def _chain_filters(filters: list[str], output_label: str) -> str:
"""向后兼容:委托给 video_filter_builder.chain_filters"""
return _chain_filters_func(filters, output_label)
"""将滤镜列表串联为 FFmpeg 滤镜字符串"""
filter_body = ",".join(filters)
return f"[0:v]{filter_body}[{output_label}]"
def _build_concat_filter(
clip_chains: list[ClipFilterChain],
) -> tuple[str, float]:
"""向后兼容:委托给 video_filter_builder.build_concat_filter。"""
return _build_concat_filter_func(clip_chains)
"""构建 concat 滤镜(无转场,高效拼接)。
格式:
[0:v]filters[v0]; [1:v]filters[v1]; ...
[v0][v1]...[vN]concat=n=N:v=1:a=0[outv]
"""
n = len(clip_chains)
parts: list[str] = []
total_duration = 0.0
# 每个片段的滤镜链
for idx, chain in enumerate(clip_chains):
filter_body = ",".join(chain.filters)
parts.append(f"[{idx}:v]{filter_body}[{chain.video_label}]")
total_duration += chain.duration
# concat 滤镜
concat_inputs = "".join(f"[{c.video_label}]" for c in clip_chains)
concat_filter = f"{concat_inputs}concat=n={n}:v=1:a=0[outv]"
parts.append(concat_filter)
# 音频 concat(如果有)— 先统一音频格式再拼接,否则不同采样率/声道会导致concat失败
audio_parts: list[str] = []
for idx, chain in enumerate(clip_chains):
if chain.audio_label:
# aformat: 统一采样率48000Hz + 双声道stereo + fltp采样格式(AAC标准格式)
audio_filters = [
"aformat=sample_rates=48000:channel_layouts=stereo:sample_fmts=fltp",
f"atrim=0:{chain.duration}",
"asetpts=PTS-STARTPTS",
]
audio_parts.append(f"[{idx}:a]{','.join(audio_filters)}[{chain.audio_label}]")
if audio_parts:
parts.extend(audio_parts)
audio_inputs = "".join(f"[{c.audio_label}]" for c in clip_chains if c.audio_label)
audio_count = sum(1 for c in clip_chains if c.audio_label)
if audio_count > 0:
parts.append(f"{audio_inputs}concat=n={audio_count}:v=0:a=1[outa]")
return ";".join(parts), total_duration
def _build_xfade_filter(
@@ -429,5 +569,80 @@ def _build_xfade_filter(
transition_duration: float,
transitions: list[str],
) -> tuple[str, float]:
"""向后兼容:委托给 video_filter_builder.build_xfade_filter。"""
return _build_xfade_filter_func(clip_chains, transition_duration, transitions)
"""构建 xfade 转场滤镜链。
每两个相邻片段之间插入 xfade 转场。
offset = 前一个片段的累积时长 - 转场时长。
格式(2 片段):
[0:v]filters[v0]; [1:v]filters[v1];
[v0][v1]xfade=transition=fade:duration=0.5:offset=4.5[outv]
格式(3+ 片段):
[v0][v1]xfade=...[tmp1]; [tmp1][v2]xfade=...[outv]
"""
n = len(clip_chains)
parts: list[str] = []
total_duration = 0.0
# 每个片段的滤镜链
for idx, chain in enumerate(clip_chains):
filter_body = ",".join(chain.filters)
parts.append(f"[{idx}:v]{filter_body}[{chain.video_label}]")
total_duration += chain.duration
# xfade 链
if n == 1:
# 单片段不需要 xfade
parts.append(f"[{clip_chains[0].video_label}]copy[outv]")
return ";".join(parts), total_duration
# 计算每个转场的 offset
cumulative = 0.0
prev_label = clip_chains[0].video_label
for i in range(1, n):
cumulative += clip_chains[i - 1].duration
offset = max(0.0, cumulative - transition_duration * i)
# 获取转场类型
transition = transitions[i] if i < len(transitions) else "cut"
xfade_transition = _XFADE_TRANSITION_MAP.get(transition, "fade")
if i == n - 1:
# 最后一个转场,输出到 [outv]
out_label = "outv"
else:
out_label = f"xf{i}"
parts.append(
f"[{prev_label}][{clip_chains[i].video_label}]"
f"xfade=transition={xfade_transition}"
f":duration={transition_duration}"
f":offset={offset:.3f}"
f"[{out_label}]"
)
prev_label = out_label
# 总时长需要减去转场重叠部分
total_duration -= transition_duration * (n - 1)
# 音频:先 aformat 归一化再 concat(不同采样率/声道/采样格式会导致concat失败)
audio_chains_with_label = [(c, c.audio_label) for c in clip_chains if c.audio_label]
if len(audio_chains_with_label) >= 2:
normalized_audio_labels: list[str] = []
for chain, _ in audio_chains_with_label:
norm_label = f"anorm_{chain.video_label}"
audio_filters = [
"aformat=sample_rates=48000:channel_layouts=stereo:sample_fmts=fltp",
f"atrim=0:{chain.duration}",
"asetpts=PTS-STARTPTS",
]
parts.append(f"[{chain.audio_label}]{','.join(audio_filters)}[{norm_label}]")
normalized_audio_labels.append(norm_label)
audio_inputs = "".join(f"[{label}]" for label in normalized_audio_labels)
parts.append(f"{audio_inputs}concat=n={len(normalized_audio_labels)}:v=0:a=1[outa]")
elif len(audio_chains_with_label) == 1:
parts.append(f"[{audio_chains_with_label[0][0].audio_label}]acopy[outa]")
return ";".join(parts), max(0.0, total_duration)
+443
View File
@@ -0,0 +1,443 @@
/**
* 素材相关 API
* Phase 1 重构:去掉 project_id,素材直接归属用户
*/
import apiClient from "./client"
import { getOrCreateDefaultProject } from "./projects"
/** 素材元数据 */
export interface AssetMetadata {
/** 时长(秒) */
duration?: number
/** 宽度(像素) */
width?: number
/** 高度(像素) */
height?: number
/** 比特率(bps */
bitrate?: number
/** 编码格式 */
codec?: string
/** 帧率 */
fps?: number
/** 采样率(Hz */
sample_rate?: number
/** 声道数 */
channels?: number
/** 其他扩展字段 */
[key: string]: unknown
}
/** 素材分类状态 */
export type AssetClassificationStatus = "pending" | "processing" | "completed" | "failed"
/** 素材条目 */
export interface AssetItem {
id: string
library_id: string
name: string
storage_key: string
mime_type: string
metadata: AssetMetadata
file_size?: number
file_url?: string
thumbnail_url?: string
/** 时长(秒),视频/音频素材由后端从 metadata 提取到顶层 */
duration?: number
status?: string
classification_status?: AssetClassificationStatus | null
quality_score?: number | null
tag_ids?: string[]
created_at?: string
}
/** 素材库 */
export interface AssetLibraryItem {
id: string
name: string
kind: "video" | "voice" | "image"
asset_count?: number
total_size?: number
created_at?: string
}
/** 入库任务 */
export interface IngestJob {
id: string
library_id: string
storage_key: string
status: "pending" | "processing" | "completed" | "failed"
error_message: string
result_asset_id: string
}
/** 分类任务 */
export interface ClassificationJob {
id: string
asset_id: string
status: "pending" | "processing" | "completed" | "failed"
classification: string
confidence: number
error_message: string
}
/** 素材诊断信息 */
export interface AssetDiagnosis {
readiness_score: number
readiness_label: string
total_assets: number
ready_assets: number
video_assets: number
image_assets: number
voice_assets: number
total_duration_seconds: number
estimated_video_count: number
used_assets: number
unused_assets: number
pending_review_assets: number
smart_views: Array<{
key: string
label: string
count: number
description: string
}>
gaps: Array<{
key: string
severity: "critical" | "warning" | "info"
message: string
recommendation: string
}>
}
// ─── 素材诊断 ──────────────────────────────────────────────
/** 获取素材诊断信息(可选 asset_id 查单素材,否则全局诊断) */
export const getAssetDiagnosis = async (assetId?: string): Promise<AssetDiagnosis> => {
const params: Record<string, string> = {}
if (assetId) params.asset_id = assetId
const response = await apiClient.get("/asset-diagnosis", { params })
return response.data
}
// ─── 素材库 ────────────────────────────────────────────────
/** 获取当前用户的所有素材库 */
export const getAssetLibraries = async (): Promise<AssetLibraryItem[]> => {
const response = await apiClient.get("/asset-libraries")
return response.data.items || []
}
/** 创建素材库(自动获取或创建默认项目以提供 project_id */
export const createAssetLibrary = async (data: {
name: string
kind: "video" | "voice" | "image"
}): Promise<AssetLibraryItem> => {
// 后端要求 project_id,前端自动管理默认项目
const project = await getOrCreateDefaultProject()
const response = await apiClient.post("/asset-libraries", {
project_id: project.id,
...data,
})
return response.data
}
/** 确保项目下指定 kind 的默认素材库存在(不存在则自动创建) */
export const ensureDefaultLibrary = async (data: {
project_id: string
kind: "video" | "voice" | "image"
}): Promise<AssetLibraryItem> => {
const response = await apiClient.post("/asset-libraries/ensure-default", data)
return response.data
}
/** 删除素材库 */
export const deleteAssetLibrary = async (libraryId: string): Promise<void> => {
await apiClient.delete(`/asset-libraries/${libraryId}`)
}
// ─── 素材 ──────────────────────────────────────────────────
/** 获取素材库下的所有素材 */
export const getAssets = async (
libraryId: string,
options?: { status?: string; page?: number; page_size?: number },
): Promise<{ items: AssetItem[]; total: number }> => {
const params: Record<string, string | number> = { library_id: libraryId }
// 默认拉取所有非删除状态的素材(ready/ingesting/processing/uploading/error/failed
// 让用户能看到"处理中"的素材,不会以为上传失败了
if (options?.status) {
params.status = options.status
}
if (options?.page) params.page = options.page
if (options?.page_size) params.page_size = options.page_size
const response = await apiClient.get("/assets", { params })
const data = response.data || {}
const items: AssetItem[] = data.items || []
const total: number = typeof data.total === "number" ? data.total : items.length
return { items, total }
}
/** 按类型获取素材(如 voice/video/image),支持可选筛选 */
export const getAssetsByKind = async (
kind: string,
filters?: {
keyword?: string
gender?: string
style?: string
tag_ids?: string[]
limit?: number
page?: number
page_size?: number
},
): Promise<AssetItem[]> => {
const params: Record<string, string | number> = { kind }
if (filters?.keyword) params.keyword = filters.keyword
if (filters?.gender) params.gender = filters.gender
if (filters?.style) params.style = filters.style
if (filters?.tag_ids?.length) params.tag_ids = filters.tag_ids.join(",")
if (filters?.limit) params.limit = filters.limit
if (filters?.page) params.page = filters.page
if (filters?.page_size) params.page_size = filters.page_size
const response = await apiClient.get("/assets", { params })
return response.data.items || []
}
/** 创建素材(上传文件后调用,附带 metadata) */
export const createAsset = async (data: {
library_id: string
name: string
storage_key: string
mime_type: string
metadata?: AssetMetadata
}): Promise<AssetItem> => {
const response = await apiClient.post("/assets", data)
return response.data
}
/** 更新素材(名称、metadata 等) */
export const updateAsset = async (
assetId: string,
data: { name?: string; metadata?: AssetMetadata },
): Promise<AssetItem> => {
const response = await apiClient.put(`/assets/${assetId}`, data)
return response.data
}
/** 更新素材审核状态 */
export const updateAssetReviewStatus = async (
assetId: string,
reviewStatus: "pending_review" | "approved" | "rejected",
): Promise<AssetItem> => {
const response = await apiClient.patch(`/assets/${assetId}/review`, {
review_status: reviewStatus,
})
return response.data
}
/** 删除素材 */
export const deleteAsset = async (assetId: string): Promise<void> => {
await apiClient.delete(`/assets/${assetId}`)
}
// ─── 上传 ──────────────────────────────────────────────────
/** 表单上传素材(小文件) */
export const uploadAsset = async (
formData: FormData,
): Promise<{ storage_key: string; ingest_job_id: string; url: string }> => {
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: {
project_id: string
library_id: string
filename: string
content_type: string
file_size: number
}): Promise<{
upload_url: string
method: string
storage_key: string
expires_at: string
fields: Record<string, string>
max_size_bytes: number
}> => {
const response = await apiClient.post("/upload/direct/prepare", data)
return response.data
}
/** 直传完成确认 */
export const completeDirectUpload = async (data: {
project_id: string
library_id: string
storage_key: string
}): Promise<{ storage_key: string; ingest_job_id: string }> => {
const response = await apiClient.post("/upload/direct/complete", data)
return response.data
}
/** 直传上传(大文件推荐),支持可选进度回调 */
export const uploadAssetDirect = async (data: {
file: File
library_id: string
onProgress?: (percent: number) => void
}): Promise<{ storage_key: string; ingest_job_id: string }> => {
// 后端要求 project_id,前端自动获取默认项目
const project = await getOrCreateDefaultProject()
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()
xhr.open(prepared.method, prepared.upload_url)
// 超时 10 分钟
xhr.timeout = 10 * 60 * 1000
xhr.upload.onprogress = (e) => {
if (e.lengthComputable && data.onProgress) {
data.onProgress(Math.round((e.loaded / e.total) * 100))
}
}
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) {
resolve()
} else {
// 解析 OSS 返回的 XML 错误信息
let ossError = ""
try {
const codeMatch = xhr.responseText.match(/<Code>([^<]+)<\/Code>/)
const msgMatch = xhr.responseText.match(/<Message>([^<]+)<\/Message>/)
if (codeMatch || msgMatch) {
ossError = ` [OSS: ${codeMatch?.[1] || "unknown"} - ${msgMatch?.[1] || "unknown"}]`
}
} catch {
// 无法解析响应体
}
const detail = `OSS 直传失败: HTTP ${xhr.status} ${xhr.statusText}${ossError}`
console.error("[OSS Upload] 直传失败:", {
url: prepared.upload_url,
storage_key: prepared.storage_key,
status: xhr.status,
statusText: xhr.statusText,
})
reject(new Error(detail))
}
}
xhr.onerror = () => {
console.error("[OSS Upload] 网络错误:", {
url: prepared.upload_url,
storage_key: prepared.storage_key,
})
reject(new Error("OSS 上传网络错误,请检查网络连接"))
}
xhr.ontimeout = () => {
console.error("[OSS Upload] 上传超时:", {
url: prepared.upload_url,
storage_key: prepared.storage_key,
})
reject(new Error("OSS 上传超时(10分钟),请检查网络或尝试更小的文件"))
}
xhr.send(directForm)
})
return completeDirectUpload({
project_id: project.id,
library_id: data.library_id,
storage_key: prepared.storage_key,
})
}
// ─── 入库 / 分类任务 ───────────────────────────────────────
/** 查询入库任务状态 */
export const getIngestJob = async (jobId: string): Promise<IngestJob> => {
const response = await apiClient.get(`/ingest-jobs/${jobId}`)
return response.data
}
/** 提交素材分类任务 */
export const submitClassificationJob = async (data: {
asset_id: string
}): Promise<ClassificationJob> => {
const response = await apiClient.post("/classification-jobs", data)
return response.data
}
/** 查询分类任务状态 */
export const getClassificationJob = async (jobId: string): Promise<ClassificationJob> => {
const response = await apiClient.get(`/classification-jobs/${jobId}`)
return response.data
}
// ─── 批量操作 ───────────────────────────────────────────────
/** 批量操作结果 */
export interface BatchOperationResult {
succeeded: string[]
failed: string[]
total: number
success_count: number
failure_count: number
}
/** 统一批量操作结果归一化,防御后端字段缺失或格式不一致 */
const normalizeBatchResult = (raw: Record<string, unknown>): BatchOperationResult => {
const succeeded = Array.isArray(raw.succeeded) ? (raw.succeeded as string[]) : []
const failed = Array.isArray(raw.failed) ? (raw.failed as string[]) : []
const success_count = typeof raw.success_count === "number" ? raw.success_count : succeeded.length
const failure_count = typeof raw.failure_count === "number" ? raw.failure_count : failed.length
const total = typeof raw.total === "number" ? raw.total : success_count + failure_count
return { succeeded, failed, total, success_count, failure_count }
}
/** 批量删除素材 */
export const batchDeleteAssets = async (assetIds: string[]): Promise<BatchOperationResult> => {
const response = await apiClient.post("/assets/batch-delete", {
asset_ids: assetIds,
})
return normalizeBatchResult((response.data || {}) as Record<string, unknown>)
}
/** 批量打标签 */
export const batchTagAssets = async (data: {
asset_ids: string[]
tags: string[]
mode: "add" | "replace"
}): Promise<BatchOperationResult> => {
const response = await apiClient.post("/assets/batch-tag", data)
return normalizeBatchResult((response.data || {}) as Record<string, unknown>)
}
/** 批量改分类 */
export const batchClassifyAssets = async (data: {
asset_ids: string[]
category: string
}): Promise<BatchOperationResult> => {
const response = await apiClient.post("/assets/batch-classify", data)
return normalizeBatchResult((response.data || {}) as Record<string, unknown>)
}
/** 批量智能标记 */
export const batchMarkAssets = async (data: {
asset_ids: string[]
smart_view: "recommended" | "caution" | "high_risk"
}): Promise<BatchOperationResult> => {
const response = await apiClient.post("/assets/batch-mark", data)
return normalizeBatchResult((response.data || {}) as Record<string, unknown>)
}
-87
View File
@@ -1,87 +0,0 @@
/**
* 素材 CRUD API
*/
import apiClient from "../client"
import type { AssetItem, AssetMetadata } from "./types"
/** 获取素材库下的所有素材 */
export const getAssets = async (
libraryId: string,
options?: {
status?: string
page?: number
page_size?: number
},
): Promise<{ items: AssetItem[]; total: number }> => {
const params: Record<string, string | number> = { library_id: libraryId }
if (options?.status) params.status = options.status
if (options?.page) params.page = options.page
if (options?.page_size) params.page_size = options.page_size
const response = await apiClient.get("/assets", { params })
const data = response.data || {}
const items: AssetItem[] = data.items || []
const total: number = typeof data.total === "number" ? data.total : items.length
return { items, total }
}
/** 按类型获取素材(如 voice/video/image),支持可选筛选 */
export const getAssetsByKind = async (
kind: string,
filters?: {
keyword?: string
gender?: string
style?: string
tag_ids?: string[]
limit?: number
page?: number
page_size?: number
},
): Promise<AssetItem[]> => {
const params: Record<string, string | number> = { kind }
if (filters?.keyword) params.keyword = filters.keyword
if (filters?.gender) params.gender = filters.gender
if (filters?.style) params.style = filters.style
if (filters?.tag_ids?.length) params.tag_ids = filters.tag_ids.join(",")
if (filters?.limit) params.limit = filters.limit
if (filters?.page) params.page = filters.page
if (filters?.page_size) params.page_size = filters.page_size
const response = await apiClient.get("/assets", { params })
return response.data.items || []
}
/** 创建素材(上传文件后调用,附带 metadata) */
export const createAsset = async (data: {
library_id: string
name: string
storage_key: string
mime_type: string
metadata?: AssetMetadata
}): Promise<AssetItem> => {
const response = await apiClient.post("/assets", data)
return response.data
}
/** 更新素材(名称、metadata 等) */
export const updateAsset = async (
assetId: string,
data: { name?: string; metadata?: AssetMetadata },
): Promise<AssetItem> => {
const response = await apiClient.put(`/assets/${assetId}`, data)
return response.data
}
/** 更新素材审核状态 */
export const updateAssetReviewStatus = async (
assetId: string,
reviewStatus: "pending_review" | "approved" | "rejected",
): Promise<AssetItem> => {
const response = await apiClient.patch(`/assets/${assetId}/review`, {
review_status: reviewStatus,
})
return response.data
}
/** 删除素材 */
export const deleteAsset = async (assetId: string): Promise<void> => {
await apiClient.delete(`/assets/${assetId}`)
}
-51
View File
@@ -1,51 +0,0 @@
/**
* 素材批量操作 API
*/
import apiClient from "../client"
import type { BatchOperationResult } from "./types"
/** 统一批量操作结果归一化,防御后端字段缺失或格式不一致 */
export const normalizeBatchResult = (raw: Record<string, unknown>): BatchOperationResult => {
const succeeded = Array.isArray(raw.succeeded) ? (raw.succeeded as string[]) : []
const failed = Array.isArray(raw.failed) ? (raw.failed as string[]) : []
const success_count = typeof raw.success_count === "number" ? raw.success_count : succeeded.length
const failure_count = typeof raw.failure_count === "number" ? raw.failure_count : failed.length
const total = typeof raw.total === "number" ? raw.total : success_count + failure_count
return { succeeded, failed, total, success_count, failure_count }
}
/** 批量删除素材 */
export const batchDeleteAssets = async (assetIds: string[]): Promise<BatchOperationResult> => {
const response = await apiClient.post("/assets/batch-delete", {
asset_ids: assetIds,
})
return normalizeBatchResult((response.data || {}) as Record<string, unknown>)
}
/** 批量打标签 */
export const batchTagAssets = async (data: {
asset_ids: string[]
tags: string[]
mode: "add" | "replace"
}): Promise<BatchOperationResult> => {
const response = await apiClient.post("/assets/batch-tag", data)
return normalizeBatchResult((response.data || {}) as Record<string, unknown>)
}
/** 批量改分类 */
export const batchClassifyAssets = async (data: {
asset_ids: string[]
category: string
}): Promise<BatchOperationResult> => {
const response = await apiClient.post("/assets/batch-classify", data)
return normalizeBatchResult((response.data || {}) as Record<string, unknown>)
}
/** 批量智能标记 */
export const batchMarkAssets = async (data: {
asset_ids: string[]
smart_view: "recommended" | "caution" | "high_risk"
}): Promise<BatchOperationResult> => {
const response = await apiClient.post("/assets/batch-mark", data)
return normalizeBatchResult((response.data || {}) as Record<string, unknown>)
}
-13
View File
@@ -1,13 +0,0 @@
/**
* 素材诊断 API
*/
import apiClient from "../client"
import type { AssetDiagnosis } from "./types"
/** 获取素材诊断信息(可选 asset_id 查单素材,否则全局诊断) */
export const getAssetDiagnosis = async (assetId?: string): Promise<AssetDiagnosis> => {
const params: Record<string, string> = {}
if (assetId) params.asset_id = assetId
const response = await apiClient.get("/asset-diagnosis", { params })
return response.data
}
-55
View File
@@ -1,55 +0,0 @@
/**
* 素材相关 API — 按模块拆分后的统一入口
* 保持与原 assets.ts 相同的导出结构,向后兼容
*/
// 类型
export type {
AssetMetadata,
AssetClassificationStatus,
AssetItem,
AssetLibraryItem,
IngestJob,
ClassificationJob,
AssetDiagnosis,
BatchOperationResult,
UploadResult,
DirectUploadPrepareResult,
DirectUploadCompleteResult,
} from "./types"
// 素材诊断
export { getAssetDiagnosis } from "./diagnosis"
// 素材库
export {
getAssetLibraries,
createAssetLibrary,
ensureDefaultLibrary,
deleteAssetLibrary,
} from "./libraries"
// 素材 CRUD
export {
getAssets,
getAssetsByKind,
createAsset,
updateAsset,
updateAssetReviewStatus,
deleteAsset,
} from "./assets"
// 上传
export { uploadAsset, prepareDirectUpload, completeDirectUpload, uploadAssetDirect } from "./upload"
// 任务
export { getIngestJob, submitClassificationJob, getClassificationJob } from "./jobs"
// 批量操作
export {
normalizeBatchResult,
batchDeleteAssets,
batchTagAssets,
batchClassifyAssets,
batchMarkAssets,
} from "./batch"
-25
View File
@@ -1,25 +0,0 @@
/**
* 入库任务 & 分类任务 API
*/
import apiClient from "../client"
import type { IngestJob, ClassificationJob } from "./types"
/** 查询入库任务状态 */
export const getIngestJob = async (jobId: string): Promise<IngestJob> => {
const response = await apiClient.get(`/ingest-jobs/${jobId}`)
return response.data
}
/** 提交素材分类任务 */
export const submitClassificationJob = async (data: {
asset_id: string
}): Promise<ClassificationJob> => {
const response = await apiClient.post("/classification-jobs", data)
return response.data
}
/** 查询分类任务状态 */
export const getClassificationJob = async (jobId: string): Promise<ClassificationJob> => {
const response = await apiClient.get(`/classification-jobs/${jobId}`)
return response.data
}
-39
View File
@@ -1,39 +0,0 @@
/**
* 素材库 API
*/
import apiClient from "../client"
import { getOrCreateDefaultProject } from "../projects"
import type { AssetLibraryItem } from "./types"
/** 获取当前用户的所有素材库 */
export const getAssetLibraries = async (): Promise<AssetLibraryItem[]> => {
const response = await apiClient.get("/asset-libraries")
return response.data.items || []
}
/** 创建素材库(自动获取或创建默认项目以提供 project_id */
export const createAssetLibrary = async (data: {
name: string
kind: "video" | "voice" | "image"
}): Promise<AssetLibraryItem> => {
const project = await getOrCreateDefaultProject()
const response = await apiClient.post("/asset-libraries", {
project_id: project.id,
...data,
})
return response.data
}
/** 确保项目下指定 kind 的默认素材库存在 */
export const ensureDefaultLibrary = async (data: {
project_id: string
kind: "video" | "voice" | "image"
}): Promise<AssetLibraryItem> => {
const response = await apiClient.post("/asset-libraries/ensure-default", data)
return response.data
}
/** 删除素材库 */
export const deleteAssetLibrary = async (libraryId: string): Promise<void> => {
await apiClient.delete(`/asset-libraries/${libraryId}`)
}
-138
View File
@@ -1,138 +0,0 @@
/**
* 素材相关类型定义
*/
/** 素材元数据 */
export interface AssetMetadata {
/** 时长(秒) */
duration?: number
/** 宽度(像素) */
width?: number
/** 高度(像素) */
height?: number
/** 比特率(bps */
bitrate?: number
/** 编码格式 */
codec?: string
/** 帧率 */
fps?: number
/** 采样率(Hz */
sample_rate?: number
/** 声道数 */
channels?: number
/** 其他扩展字段 */
[key: string]: unknown
}
/** 素材分类状态 */
export type AssetClassificationStatus = "pending" | "processing" | "completed" | "failed"
/** 素材条目 */
export interface AssetItem {
id: string
library_id: string
name: string
storage_key: string
mime_type: string
metadata: AssetMetadata
file_size?: number
file_url?: string
thumbnail_url?: string
/** 时长(秒),视频/音频素材由后端从 metadata 提取到顶层 */
duration?: number
status?: string
classification_status?: AssetClassificationStatus | null
quality_score?: number | null
tag_ids?: string[]
created_at?: string
}
/** 素材库 */
export interface AssetLibraryItem {
id: string
name: string
kind: "video" | "voice" | "image"
asset_count?: number
total_size?: number
created_at?: string
}
/** 入库任务 */
export interface IngestJob {
id: string
library_id: string
storage_key: string
status: "pending" | "processing" | "completed" | "failed"
error_message: string
result_asset_id: string
}
/** 分类任务 */
export interface ClassificationJob {
id: string
asset_id: string
status: "pending" | "processing" | "completed" | "failed"
classification: string
confidence: number
error_message: string
}
/** 素材诊断信息 */
export interface AssetDiagnosis {
readiness_score: number
readiness_label: string
total_assets: number
ready_assets: number
video_assets: number
image_assets: number
voice_assets: number
total_duration_seconds: number
estimated_video_count: number
used_assets: number
unused_assets: number
pending_review_assets: number
smart_views: Array<{
key: string
label: string
count: number
description: string
}>
gaps: Array<{
key: string
severity: "critical" | "warning" | "info"
message: string
recommendation: string
}>
}
/** 批量操作结果 */
export interface BatchOperationResult {
succeeded: string[]
failed: string[]
total: number
success_count: number
failure_count: number
}
/** 上传返回 */
export interface UploadResult {
storage_key: string
ingest_job_id: string
url: string
}
/** 预签名直传准备返回 */
export interface DirectUploadPrepareResult {
upload_url: string
method: string
storage_key: string
expires_at: string
fields: Record<string, string>
max_size_bytes: number
}
/** 直传完成确认返回 */
export interface DirectUploadCompleteResult {
storage_key: string
ingest_job_id: string
}
-119
View File
@@ -1,119 +0,0 @@
/**
* 上传相关 API(表单上传 + OSS 直传)
*/
import apiClient from "../client"
import { getOrCreateDefaultProject } from "../projects"
import type { UploadResult, DirectUploadPrepareResult, DirectUploadCompleteResult } from "./types"
/** 表单上传素材(小文件) */
export const uploadAsset = async (formData: FormData): Promise<UploadResult> => {
const response = await apiClient.post("/upload", formData, {
headers: { "Content-Type": "multipart/form-data" },
timeout: 30 * 60 * 1000,
})
return response.data
}
/** 预签名直传准备 */
export const prepareDirectUpload = async (data: {
project_id: string
library_id: string
filename: string
content_type: string
file_size: number
}): Promise<DirectUploadPrepareResult> => {
const response = await apiClient.post("/upload/direct/prepare", data)
return response.data
}
/** 直传完成确认 */
export const completeDirectUpload = async (data: {
project_id: string
library_id: string
storage_key: string
}): Promise<DirectUploadCompleteResult> => {
const response = await apiClient.post("/upload/direct/complete", data)
return response.data
}
/** 直传上传(大文件推荐),支持可选进度回调 */
export const uploadAssetDirect = async (data: {
file: File
library_id: string
onProgress?: (percent: number) => void
}): Promise<DirectUploadCompleteResult> => {
const project = await getOrCreateDefaultProject()
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()
xhr.open(prepared.method, prepared.upload_url)
// 超时 10 分钟
xhr.timeout = 10 * 60 * 1000
xhr.upload.onprogress = (e) => {
if (e.lengthComputable && data.onProgress) {
data.onProgress(Math.round((e.loaded / e.total) * 100))
}
}
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) {
resolve()
} else {
// 解析 OSS 返回的 XML 错误信息
let ossError = ""
try {
const codeMatch = xhr.responseText.match(/<Code>([^<]+)<\/Code>/)
const msgMatch = xhr.responseText.match(/<Message>([^<]+)<\/Message>/)
if (codeMatch || msgMatch) {
ossError = ` [OSS: ${codeMatch?.[1] || "unknown"} - ${msgMatch?.[1] || "unknown"}]`
}
} catch {
// 无法解析响应体
}
const detail = `OSS 直传失败: HTTP ${xhr.status} ${xhr.statusText}${ossError}`
console.error("[OSS Upload] 直传失败:", {
url: prepared.upload_url,
storage_key: prepared.storage_key,
status: xhr.status,
statusText: xhr.statusText,
})
reject(new Error(detail))
}
}
xhr.onerror = () => {
console.error("[OSS Upload] 网络错误:", {
url: prepared.upload_url,
storage_key: prepared.storage_key,
})
reject(new Error("OSS 上传网络错误,请检查网络连接"))
}
xhr.ontimeout = () => {
console.error("[OSS Upload] 上传超时:", {
url: prepared.upload_url,
storage_key: prepared.storage_key,
})
reject(new Error("OSS 上传超时(10分钟),请检查网络或尝试更小的文件"))
}
xhr.send(directForm)
})
return completeDirectUpload({
project_id: project.id,
library_id: data.library_id,
storage_key: prepared.storage_key,
})
}
+68
View File
@@ -0,0 +1,68 @@
/**
* BGM 预设音乐 API
* 对接后端 BGM 混音能力:预设列表查询(按风格分类 + 关键词搜索)
*/
import apiClient from "./client"
/* ──────────── 类型 ──────────── */
/** BGM 风格分类 */
export type BgmCategory = "轻快" | "治愈" | "科技" | "电商"
/** BGM 预设项 */
export interface BgmPreset {
id: string
name: string
category: BgmCategory
/** 音频文件 URL */
url: string
/** 时长(秒) */
duration: number
/** 关键词标签 */
tags: string[]
/** 封面图 URL */
cover_url?: string
}
/** BGM 预设列表查询参数 */
export interface BgmPresetsQuery {
category?: BgmCategory | string
keyword?: string
}
/** BGM 混音配置(嵌入模板) */
export interface BgmMixConfig {
/** 是否启用 BGM */
enabled: boolean
/** 选中的 BGM ID */
music_id: string
/** BGM 音量 0-100 */
volume: number
/** 淡入时长(秒) 0-3 */
fade_in: number
/** 淡出时长(秒) 0-3 */
fade_out: number
/** 人声闪避(sidechain */
voice_dodge: boolean
}
/** 默认 BGM 混音配置 */
export const DEFAULT_BGM_MIX_CONFIG: BgmMixConfig = {
enabled: false,
music_id: "",
volume: 50,
fade_in: 0.5,
fade_out: 0.5,
voice_dodge: true,
}
/* ──────────── API ──────────── */
/** 获取 BGM 预设列表 */
export const getBgmPresets = async (params?: BgmPresetsQuery): Promise<BgmPreset[]> => {
const searchParams: Record<string, string> = {}
if (params?.category) searchParams.category = params.category
if (params?.keyword) searchParams.keyword = params.keyword
const res = await apiClient.get("/bgm/presets", { params: searchParams })
return res.data?.data ?? res.data ?? []
}
-14
View File
@@ -1,14 +0,0 @@
/**
* BGM 预设音乐 API 函数
*/
import apiClient from "../client"
import type { BgmPreset, BgmPresetsQuery } from "./types"
/** 获取 BGM 预设列表 */
export const getBgmPresets = async (params?: BgmPresetsQuery): Promise<BgmPreset[]> => {
const searchParams: Record<string, string> = {}
if (params?.category) searchParams.category = params.category
if (params?.keyword) searchParams.keyword = params.keyword
const res = await apiClient.get("/bgm/presets", { params: searchParams })
return res.data?.data ?? res.data ?? []
}
-14
View File
@@ -1,14 +0,0 @@
/**
* BGM 相关常量
*/
import type { BgmMixConfig } from "./types"
/** 默认 BGM 混音配置 */
export const DEFAULT_BGM_MIX_CONFIG: BgmMixConfig = {
enabled: false,
music_id: "",
volume: 50,
fade_in: 0.5,
fade_out: 0.5,
voice_dodge: true,
}
-13
View File
@@ -1,13 +0,0 @@
/**
* BGM API — 目录化入口
* 保持与原 bgm.ts 相同导出,向后兼容
*/
// 类型
export type { BgmCategory, BgmPreset, BgmPresetsQuery, BgmMixConfig } from "./types"
// 常量
export { DEFAULT_BGM_MIX_CONFIG } from "./constants"
// API 函数
export { getBgmPresets } from "./bgm"
-43
View File
@@ -1,43 +0,0 @@
/**
* BGM 相关类型定义
*/
/** BGM 风格分类 */
export type BgmCategory = "轻快" | "治愈" | "科技" | "电商"
/** BGM 预设项 */
export interface BgmPreset {
id: string
name: string
category: BgmCategory
/** 音频文件 URL */
url: string
/** 时长(秒) */
duration: number
/** 关键词标签 */
tags: string[]
/** 封面图 URL */
cover_url?: string
}
/** BGM 预设列表查询参数 */
export interface BgmPresetsQuery {
category?: BgmCategory | string
keyword?: string
}
/** BGM 混音配置(嵌入模板) */
export interface BgmMixConfig {
/** 是否启用 BGM */
enabled: boolean
/** 选中的 BGM ID */
music_id: string
/** BGM 音量 0-100 */
volume: number
/** 淡入时长(秒) 0-3 */
fade_in: number
/** 淡出时长(秒) 0-3 */
fade_out: number
/** 人声闪避(sidechain */
voice_dodge: boolean
}
Regular → Executable
+15 -1
View File
@@ -57,7 +57,21 @@ apiClient.interceptors.response.use(
}
// 401 → 尝试刷新 Token
if (error.response?.status === 401 && originalRequest && !originalRequest._retry) {
// 排除 auth 端点:登录/注册/找回密码的 401 是正常业务响应(如密码错误),
// 不应触发 token 刷新或登出跳转,走后面的错误提示逻辑即可
const requestUrl = originalRequest?.url || ""
const isAuthEndpoint =
requestUrl.includes("/auth/login") ||
requestUrl.includes("/auth/register") ||
requestUrl.includes("/auth/forgot-password") ||
requestUrl.includes("/auth/reset-password")
if (
error.response?.status === 401 &&
originalRequest &&
!originalRequest._retry &&
!isAuthEndpoint
) {
const refreshToken = useAuthStore.getState().refreshToken
// 无 refresh_token → 直接登出
@@ -1,6 +1,8 @@
/**
*
* API
*
*/
import apiClient from "./client"
/** 查重记录状态 */
export type DuplicationStatus = "pending" | "processing" | "completed" | "failed"
@@ -60,3 +62,38 @@ export interface DuplicationUploadResponse {
/** 消息 */
message: string
}
// ============ API 函数 ============
/** 上传视频进行查重 */
export const uploadForDuplication = async (file: File): Promise<DuplicationUploadResponse> => {
const formData = new FormData()
formData.append("file", file)
const response = await apiClient.post("/duplication/upload", formData, {
headers: { "Content-Type": "multipart/form-data" },
})
return response.data
}
/** 获取查重记录列表 */
export const getDuplicationRecords = async (): Promise<DuplicationRecord[]> => {
const response = await apiClient.get("/duplication/records")
return response.data
}
/** 获取查重详情 */
export const getDuplicationDetail = async (recordId: string): Promise<DuplicationDetail> => {
const response = await apiClient.get(`/duplication/records/${recordId}`)
return response.data
}
/** 删除查重记录 */
export const deleteDuplicationRecord = async (recordId: string): Promise<void> => {
await apiClient.delete(`/duplication/records/${recordId}`)
}
/** 重新查重 */
export const retryDuplication = async (recordId: string): Promise<DuplicationUploadResponse> => {
const response = await apiClient.post(`/duplication/records/${recordId}/retry`)
return response.data
}
@@ -1,38 +0,0 @@
/**
* 查重 API 函数
*/
import apiClient from "../client"
import type { DuplicationDetail, DuplicationRecord, DuplicationUploadResponse } from "./types"
/** 上传视频进行查重 */
export const uploadForDuplication = async (file: File): Promise<DuplicationUploadResponse> => {
const formData = new FormData()
formData.append("file", file)
const response = await apiClient.post("/duplication/upload", formData, {
headers: { "Content-Type": "multipart/form-data" },
})
return response.data
}
/** 获取查重记录列表 */
export const getDuplicationRecords = async (): Promise<DuplicationRecord[]> => {
const response = await apiClient.get("/duplication/records")
return response.data
}
/** 获取查重详情 */
export const getDuplicationDetail = async (recordId: string): Promise<DuplicationDetail> => {
const response = await apiClient.get(`/duplication/records/${recordId}`)
return response.data
}
/** 删除查重记录 */
export const deleteDuplicationRecord = async (recordId: string): Promise<void> => {
await apiClient.delete(`/duplication/records/${recordId}`)
}
/** 重新查重 */
export const retryDuplication = async (recordId: string): Promise<DuplicationUploadResponse> => {
const response = await apiClient.post(`/duplication/records/${recordId}/retry`)
return response.data
}
-22
View File
@@ -1,22 +0,0 @@
/**
* 查重 API — 目录化入口
* 保持与原 duplication.ts 相同导出,向后兼容
*/
// 类型
export type {
DuplicationStatus,
DuplicationRecord,
DuplicateSegment,
DuplicationDetail,
DuplicationUploadResponse,
} from "./types"
// API 函数
export {
uploadForDuplication,
getDuplicationRecords,
getDuplicationDetail,
deleteDuplicationRecord,
retryDuplication,
} from "./duplication"
+243
View File
@@ -0,0 +1,243 @@
/**
* 模板编辑器 API
* 对接后端 /api/v1/templates 路由
*/
import apiClient from "./client"
import type {
WatermarkConfig,
IntroOutroConfig,
PipConfig,
FilterConfig,
ChromaKeyConfig,
StickerConfig,
CoverConfig,
} from "@/pages/editing-planner/types"
/* ──────────── 类型定义 ──────────── */
/** 模板模式(后端枚举值) */
export type TemplateMode = "pip" | "voice_over" | "one_take" | "voice_pip"
/** 模式显示名称映射 */
export const MODE_LABELS: Record<TemplateMode, string> = {
pip: "混剪",
voice_over: "人物口播",
one_take: "一镜到底",
voice_pip: "口播+混剪",
}
/** 模式颜色映射 */
export const MODE_COLORS: Record<TemplateMode, string> = {
pip: "blue",
voice_over: "green",
one_take: "orange",
voice_pip: "purple",
}
/** 标题配置 */
export interface TitleConfig {
ai_auto_select: boolean
content: string
font_preset: string
font_color: string
font_size: number
position: string
}
/** 字幕配置 */
export interface SubtitleConfig {
enabled: boolean
position: string
font: string
color: string
size: number
animation: string
}
/** BGM 配置 */
export interface BgmConfig {
enabled: boolean
music_id: string
}
/** 模板片段 */
export interface TemplateSegment {
id?: string
segment_order: number
duration_min: number
duration_max: number
material_type: string | null
}
/** 剪辑模板 */
export interface EditingTemplate {
id: string
name: string
mode: TemplateMode
category: string
tags: string[]
title_config: TitleConfig
subtitle_config: SubtitleConfig
bgm_config: BgmConfig
estimated_duration: number
segments: TemplateSegment[]
/** 水印配置(后端就绪后启用) */
watermark_config?: WatermarkConfig
/** 片头片尾配置(后端就绪后启用) */
intro_outro_config?: IntroOutroConfig
/** 混剪配置 */
pip_config?: PipConfig
/** 滤镜调色配置 */
filter_config?: FilterConfig
/** 绿幕抠像配置 */
green_screen_config?: ChromaKeyConfig
/** 贴纸配置 */
sticker_config?: StickerConfig
/** 封面配置 */
cover_config?: CoverConfig
is_active?: boolean
created_at: string
updated_at: string
}
/** 模板分类 */
export interface TemplateCategory {
id: string
name: string
created_at?: string
}
/** 创建/更新模板请求体 */
export interface SaveTemplatePayload {
name: string
mode: TemplateMode
category: string
tags: string[]
title_config: TitleConfig
subtitle_config: SubtitleConfig
bgm_config: BgmConfig
estimated_duration: number
segments: Omit<TemplateSegment, "id">[]
/** 水印配置(后端就绪后启用) */
watermark_config?: WatermarkConfig
/** 片头片尾配置(后端就绪后启用) */
intro_outro_config?: IntroOutroConfig
/** 混剪配置 */
pip_config?: PipConfig
/** 滤镜调色配置 */
filter_config?: FilterConfig
/** 绿幕抠像配置 */
green_screen_config?: ChromaKeyConfig
/** 贴纸配置 */
sticker_config?: StickerConfig
/** 封面配置 */
cover_config?: CoverConfig
}
/** 使用模板生成请求体 */
export interface GenerateFromTemplatePayload {
voiceover_duration: number
}
/** 验证警告详情 */
export interface ValidationWarningDetails {
/** 相关字段名 */
field?: string
/** 期望值 */
expected?: string | number
/** 实际值 */
actual?: string | number
/** 建议值 */
suggested?: string | number
}
/** 验证/生成响应 */
export interface ValidateWarning {
code: string
message: string
details?: ValidationWarningDetails
}
/** 使用模板生成响应 */
export interface GenerateFromTemplateResponse {
template: EditingTemplate
warnings: ValidateWarning[]
}
/** 列表响应(带分页) */
export interface ListTemplatesResponse {
items: EditingTemplate[]
total: number
}
/** 分类列表响应 */
export interface ListCategoriesResponse {
items: TemplateCategory[]
}
// ============ API 函数 ============
/** 获取模板列表 */
export const getEditingTemplates = async (params?: {
category?: string
tag?: string
skip?: number
limit?: number
}): Promise<EditingTemplate[]> => {
const response = await apiClient.get<ListTemplatesResponse>("/templates", {
params: {
skip: params?.skip ?? 0,
limit: params?.limit ?? 50,
},
})
let list = response.data.items
if (params?.category) list = list.filter((t) => t.category === params.category)
if (params?.tag) list = list.filter((t) => t.tags.includes(params.tag!))
return list
}
/** 获取模板详情 */
export const getEditingTemplate = async (id: string): Promise<EditingTemplate> => {
const response = await apiClient.get<EditingTemplate>(`/templates/${id}`)
return response.data
}
/** 创建模板 */
export const createEditingTemplate = async (
data: SaveTemplatePayload,
): Promise<EditingTemplate> => {
const response = await apiClient.post<EditingTemplate>("/templates", data)
return response.data
}
/** 更新模板 */
export const updateEditingTemplate = async (
id: string,
data: SaveTemplatePayload,
): Promise<EditingTemplate> => {
const response = await apiClient.patch<EditingTemplate>(`/templates/${id}`, data)
return response.data
}
/** 删除模板 */
export const deleteEditingTemplate = async (id: string): Promise<void> => {
await apiClient.delete(`/templates/${id}`)
}
/** 获取模板分类列表 */
export const getTemplateCategories = async (): Promise<TemplateCategory[]> => {
const response = await apiClient.get<ListCategoriesResponse>("/templates/categories/list")
return response.data.items
}
/** 使用模板生成视频(调用 validate 端点) */
export const generateFromTemplate = async (
templateId: string,
data: GenerateFromTemplatePayload,
): Promise<GenerateFromTemplateResponse> => {
const response = await apiClient.post<GenerateFromTemplateResponse>(
`/templates/${templateId}/validate`,
data,
)
return response.data
}
@@ -1,20 +0,0 @@
/**
* 模板编辑器常量
*/
import type { TemplateMode } from "./types"
/** 模式显示名称映射 */
export const MODE_LABELS: Record<TemplateMode, string> = {
pip: "混剪",
voice_over: "人物口播",
one_take: "一镜到底",
voice_pip: "口播+混剪",
}
/** 模式颜色映射 */
export const MODE_COLORS: Record<TemplateMode, string> = {
pip: "blue",
voice_over: "green",
one_take: "orange",
voice_pip: "purple",
}
-36
View File
@@ -1,36 +0,0 @@
/**
* 模板编辑器 API — 目录化入口
* 保持与原 editing-planner.ts 相同导出,向后兼容
*/
// 类型
export type {
TemplateMode,
TitleConfig,
SubtitleConfig,
BgmConfig,
TemplateSegment,
EditingTemplate,
TemplateCategory,
SaveTemplatePayload,
GenerateFromTemplatePayload,
ValidationWarningDetails,
ValidateWarning,
GenerateFromTemplateResponse,
ListTemplatesResponse,
ListCategoriesResponse,
} from "./types"
// 常量
export { MODE_LABELS, MODE_COLORS } from "./constants"
// API 函数
export {
getEditingTemplates,
getEditingTemplate,
createEditingTemplate,
updateEditingTemplate,
deleteEditingTemplate,
getTemplateCategories,
generateFromTemplate,
} from "./templates"
@@ -1,78 +0,0 @@
/**
* 模板编辑器 API 函数
*/
import apiClient from "../client"
import type {
EditingTemplate,
TemplateCategory,
SaveTemplatePayload,
GenerateFromTemplatePayload,
GenerateFromTemplateResponse,
ListTemplatesResponse,
ListCategoriesResponse,
} from "./types"
/** 获取模板列表 */
export const getEditingTemplates = async (params?: {
category?: string
tag?: string
skip?: number
limit?: number
}): Promise<EditingTemplate[]> => {
const response = await apiClient.get<ListTemplatesResponse>("/templates", {
params: {
skip: params?.skip ?? 0,
limit: params?.limit ?? 50,
},
})
let list = response.data.items
if (params?.category) list = list.filter((t) => t.category === params.category)
if (params?.tag) list = list.filter((t) => t.tags.includes(params.tag!))
return list
}
/** 获取模板详情 */
export const getEditingTemplate = async (id: string): Promise<EditingTemplate> => {
const response = await apiClient.get<EditingTemplate>(`/templates/${id}`)
return response.data
}
/** 创建模板 */
export const createEditingTemplate = async (
data: SaveTemplatePayload,
): Promise<EditingTemplate> => {
const response = await apiClient.post<EditingTemplate>("/templates", data)
return response.data
}
/** 更新模板 */
export const updateEditingTemplate = async (
id: string,
data: SaveTemplatePayload,
): Promise<EditingTemplate> => {
const response = await apiClient.patch<EditingTemplate>(`/templates/${id}`, data)
return response.data
}
/** 删除模板 */
export const deleteEditingTemplate = async (id: string): Promise<void> => {
await apiClient.delete(`/templates/${id}`)
}
/** 获取模板分类列表 */
export const getTemplateCategories = async (): Promise<TemplateCategory[]> => {
const response = await apiClient.get<ListCategoriesResponse>("/templates/categories/list")
return response.data.items
}
/** 使用模板生成视频(调用 validate 端点) */
export const generateFromTemplate = async (
templateId: string,
data: GenerateFromTemplatePayload,
): Promise<GenerateFromTemplateResponse> => {
const response = await apiClient.post<GenerateFromTemplateResponse>(
`/templates/${templateId}/validate`,
data,
)
return response.data
}
-138
View File
@@ -1,138 +0,0 @@
/**
* 模板编辑器类型定义
*/
import type {
WatermarkConfig,
IntroOutroConfig,
PipConfig,
FilterConfig,
ChromaKeyConfig,
StickerConfig,
CoverConfig,
} from "@/pages/editing-planner/types"
/** 模板模式(后端枚举值) */
export type TemplateMode = "pip" | "voice_over" | "one_take" | "voice_pip"
/** 标题配置 */
export interface TitleConfig {
ai_auto_select: boolean
content: string
font_preset: string
font_color: string
font_size: number
position: string
}
/** 字幕配置 */
export interface SubtitleConfig {
enabled: boolean
position: string
font: string
color: string
size: number
animation: string
}
/** BGM 配置 */
export interface BgmConfig {
enabled: boolean
music_id: string
}
/** 模板片段 */
export interface TemplateSegment {
id?: string
segment_order: number
duration_min: number
duration_max: number
material_type: string | null
}
/** 剪辑模板 */
export interface EditingTemplate {
id: string
name: string
mode: TemplateMode
category: string
tags: string[]
title_config: TitleConfig
subtitle_config: SubtitleConfig
bgm_config: BgmConfig
estimated_duration: number
segments: TemplateSegment[]
watermark_config?: WatermarkConfig
intro_outro_config?: IntroOutroConfig
pip_config?: PipConfig
filter_config?: FilterConfig
green_screen_config?: ChromaKeyConfig
sticker_config?: StickerConfig
cover_config?: CoverConfig
is_active?: boolean
created_at: string
updated_at: string
}
/** 模板分类 */
export interface TemplateCategory {
id: string
name: string
created_at?: string
}
/** 创建/更新模板请求体 */
export interface SaveTemplatePayload {
name: string
mode: TemplateMode
category: string
tags: string[]
title_config: TitleConfig
subtitle_config: SubtitleConfig
bgm_config: BgmConfig
estimated_duration: number
segments: Omit<TemplateSegment, "id">[]
watermark_config?: WatermarkConfig
intro_outro_config?: IntroOutroConfig
pip_config?: PipConfig
filter_config?: FilterConfig
green_screen_config?: ChromaKeyConfig
sticker_config?: StickerConfig
cover_config?: CoverConfig
}
/** 使用模板生成请求体 */
export interface GenerateFromTemplatePayload {
voiceover_duration: number
}
/** 验证警告详情 */
export interface ValidationWarningDetails {
field?: string
expected?: string | number
actual?: string | number
suggested?: string | number
}
/** 验证警告 */
export interface ValidateWarning {
code: string
message: string
details?: ValidationWarningDetails
}
/** 使用模板生成响应 */
export interface GenerateFromTemplateResponse {
template: EditingTemplate
warnings: ValidateWarning[]
}
/** 列表响应(带分页) */
export interface ListTemplatesResponse {
items: EditingTemplate[]
total: number
}
/** 分类列表响应 */
export interface ListCategoriesResponse {
items: TemplateCategory[]
}
+174
View File
@@ -0,0 +1,174 @@
/**
* 成品 / 视频相关 API
* 后端实际接口:/videos
*/
import apiClient from "./client"
/** 复核状态 */
export type ReviewStatus = "pending_review" | "approved" | "rejected"
/** 成品条目 */
export interface ProductItem {
id: string
title: string
video_url?: string
thumbnail_url?: string
duration_seconds?: number
file_size?: number
resolution?: string
status: "processing" | "completed" | "failed"
/** 复核状态 */
review_status?: ReviewStatus
/** 所属项目 ID */
project_id?: string
/** 所属项目名称 */
project_name?: string
/** 查重率(百分比) */
duplicate_rate?: number
created_at?: string
updated_at?: string
}
/** 列表查询参数 */
export interface ProductListParams {
page?: number
page_size?: number
project_id?: string
review_status?: ReviewStatus | "all"
}
/** 分页响应 */
export interface ProductListResponse {
items: ProductItem[]
total: number
page: number
page_size: number
}
/** 批量下载任务状态 */
export interface BatchDownloadStatus {
job_id: string
status: "processing" | "completed" | "failed"
/** 完成后返回的下载 URL */
download_url?: string
/** 进度百分比 */
progress?: number
}
/** 后端 /videos 接口返回的原始视频条目 */
interface VideoItem {
id: string
project_id: string
generation_task_id: string
name: string
file_url: string
file_size: number
duration: number
thumbnail_url: string | null
width: number
height: number
fps: number
status: string
review_status: ReviewStatus
generation_params: Record<string, unknown>
download_url: string
generated_at: string
}
/**
* 将后端 VideoItem 映射为 ProductItem 格式
*/
function mapVideoToProductItem(video: VideoItem): ProductItem {
return {
id: video.id,
title: video.name || "未命名视频",
// 优先用 download_url(带签名)播放,file_url 无签名无法访问
video_url: video.download_url || video.file_url,
thumbnail_url: video.thumbnail_url || undefined,
duration_seconds: video.duration,
file_size: video.file_size,
resolution: video.width && video.height ? `${video.width}x${video.height}` : undefined,
status:
video.status === "completed"
? "completed"
: video.status === "failed"
? "failed"
: "processing",
review_status: video.review_status,
project_id: video.project_id,
// 后端 /videos 接口暂无 project_name 字段
project_name: undefined,
// 后端字段名为 generated_at,映射为 created_at 供前端统一使用
created_at: video.generated_at,
updated_at: video.generated_at,
// 后端 /videos 接口暂无 duplicate_rate 字段
duplicate_rate: undefined,
}
}
/** 获取成品列表(支持分页和筛选) */
export const getProducts = async (params?: ProductListParams): Promise<ProductItem[]> => {
const response = await apiClient.get("/videos", { params })
const data = response.data
const videos: VideoItem[] = Array.isArray(data?.items)
? data.items
: Array.isArray(data)
? data
: []
return videos.map(mapVideoToProductItem)
}
/** 获取单个成品详情 */
export const getProduct = async (productId: string): Promise<ProductItem> => {
const response = await apiClient.get(`/videos/${productId}`)
return mapVideoToProductItem(response.data as VideoItem)
}
/**
* 删除成品
* 注意:后端暂未实现 /videos DELETE 接口,调用会返回 405
* 待后端实现后自动生效
*/
export const deleteProduct = async (productId: string): Promise<void> => {
await apiClient.delete(`/videos/${productId}`)
}
/**
* 获取成品下载链接
* 直接使用列表返回的 download_url(带OSS签名)
*/
export const getProductDownloadUrl = async (
productId: string,
): Promise<{ url: string; expires_at: string }> => {
// 优先从列表缓存取;如果没有则调详情接口
const product = await getProduct(productId)
if (!product.video_url) throw new Error("下载链接不可用")
return { url: product.video_url, expires_at: "" }
}
/** 更新复核状态 — TODO: 后端暂无对应端点,暂存本地状态 */
export const updateReviewStatus = async (
productId: string,
status: ReviewStatus,
): Promise<ProductItem> => {
// 后端暂无 /videos/{id}/review 端点
// 暂时返回当前状态,后续可扩展
const product = await getProduct(productId)
return { ...product, review_status: status }
}
/** 发起批量下载 — TODO: 后端暂无对应端点 */
export const batchDownload = async (videoIds: string[]): Promise<{ job_id: string }> => {
// 后端暂无 /videos/batch-download 端点
// 暂时返回模拟 job_id,后续可扩展
console.warn("[batchDownload] 后端暂无批量下载端点", videoIds)
return { job_id: `mock-${Date.now()}` }
}
/** 查询批量下载状态 — TODO: 后端暂无对应端点 */
export const getBatchDownloadStatus = async (jobId: string): Promise<BatchDownloadStatus> => {
// 后端暂无 /videos/batch-download/{jobId} 端点
// 暂时返回模拟状态,后续可扩展
console.warn("[getBatchDownloadStatus] 后端暂无批量下载状态端点", jobId)
return { job_id: jobId, status: "processing", progress: 0 }
}
-28
View File
@@ -1,28 +0,0 @@
/**
* 成品 / 视频相关 API — 目录化入口
* 保持与原 products.ts 相同导出,向后兼容
*/
// 类型
export type {
ReviewStatus,
ProductItem,
ProductListParams,
ProductListResponse,
BatchDownloadStatus,
VideoItem,
} from "./types"
// 工具函数
export { mapVideoToProductItem } from "./utils"
// API 函数
export {
getProducts,
getProduct,
deleteProduct,
getProductDownloadUrl,
updateReviewStatus,
batchDownload,
getBatchDownloadStatus,
} from "./products"
-80
View File
@@ -1,80 +0,0 @@
/**
* 成品 / 视频相关 API 函数
* 后端实际接口:/videos
*/
import apiClient from "../client"
import type {
BatchDownloadStatus,
ProductItem,
ProductListParams,
VideoItem,
ReviewStatus,
} from "./types"
import { mapVideoToProductItem } from "./utils"
/** 获取成品列表(支持分页和筛选) */
export const getProducts = async (params?: ProductListParams): Promise<ProductItem[]> => {
const response = await apiClient.get("/videos", { params })
const data = response.data
const videos: VideoItem[] = Array.isArray(data?.items)
? data.items
: Array.isArray(data)
? data
: []
return videos.map(mapVideoToProductItem)
}
/** 获取单个成品详情 */
export const getProduct = async (productId: string): Promise<ProductItem> => {
const response = await apiClient.get(`/videos/${productId}`)
return mapVideoToProductItem(response.data as VideoItem)
}
/**
* 删除成品
* 注意:后端暂未实现 /videos DELETE 接口,调用会返回 405
* 待后端实现后自动生效
*/
export const deleteProduct = async (productId: string): Promise<void> => {
await apiClient.delete(`/videos/${productId}`)
}
/**
* 获取成品下载链接
* 直接使用列表返回的 download_url(带OSS签名)
*/
export const getProductDownloadUrl = async (
productId: string,
): Promise<{ url: string; expires_at: string }> => {
// 优先从列表缓存取;如果没有则调详情接口
const product = await getProduct(productId)
if (!product.video_url) throw new Error("下载链接不可用")
return { url: product.video_url, expires_at: "" }
}
/** 更新复核状态 — TODO: 后端暂无对应端点,暂存本地状态 */
export const updateReviewStatus = async (
productId: string,
status: ReviewStatus,
): Promise<ProductItem> => {
// 后端暂无 /videos/{id}/review 端点
// 暂时返回当前状态,后续可扩展
const product = await getProduct(productId)
return { ...product, review_status: status }
}
/** 发起批量下载 — TODO: 后端暂无对应端点 */
export const batchDownload = async (videoIds: string[]): Promise<{ job_id: string }> => {
// 后端暂无 /videos/batch-download 端点
// 暂时返回模拟 job_id,后续可扩展
console.warn("[batchDownload] 后端暂无批量下载端点", videoIds)
return { job_id: `mock-${Date.now()}` }
}
/** 查询批量下载状态 — TODO: 后端暂无对应端点 */
export const getBatchDownloadStatus = async (jobId: string): Promise<BatchDownloadStatus> => {
// 后端暂无 /videos/batch-download/{jobId} 端点
// 暂时返回模拟状态,后续可扩展
console.warn("[getBatchDownloadStatus] 后端暂无批量下载状态端点", jobId)
return { job_id: jobId, status: "processing", progress: 0 }
}
-74
View File
@@ -1,74 +0,0 @@
/**
* 成品 / 视频相关类型定义
*/
/** 复核状态 */
export type ReviewStatus = "pending_review" | "approved" | "rejected"
/** 成品条目 */
export interface ProductItem {
id: string
title: string
video_url?: string
thumbnail_url?: string
duration_seconds?: number
file_size?: number
resolution?: string
status: "processing" | "completed" | "failed"
/** 复核状态 */
review_status?: ReviewStatus
/** 所属项目 ID */
project_id?: string
/** 所属项目名称 */
project_name?: string
/** 查重率(百分比) */
duplicate_rate?: number
created_at?: string
updated_at?: string
}
/** 列表查询参数 */
export interface ProductListParams {
page?: number
page_size?: number
project_id?: string
review_status?: ReviewStatus | "all"
}
/** 分页响应 */
export interface ProductListResponse {
items: ProductItem[]
total: number
page: number
page_size: number
}
/** 批量下载任务状态 */
export interface BatchDownloadStatus {
job_id: string
status: "processing" | "completed" | "failed"
/** 完成后返回的下载 URL */
download_url?: string
/** 进度百分比 */
progress?: number
}
/** 后端 /videos 接口返回的原始视频条目 */
export interface VideoItem {
id: string
project_id: string
generation_task_id: string
name: string
file_url: string
file_size: number
duration: number
thumbnail_url: string | null
width: number
height: number
fps: number
status: string
review_status: ReviewStatus
generation_params: Record<string, unknown>
download_url: string
generated_at: string
}
-35
View File
@@ -1,35 +0,0 @@
/**
* 成品数据转换工具函数
*/
import type { ProductItem, VideoItem } from "./types"
/**
* 将后端 VideoItem 映射为 ProductItem 格式
*/
export function mapVideoToProductItem(video: VideoItem): ProductItem {
return {
id: video.id,
title: video.name || "未命名视频",
// 优先用 download_url(带签名)播放,file_url 无签名无法访问
video_url: video.download_url || video.file_url,
thumbnail_url: video.thumbnail_url || undefined,
duration_seconds: video.duration,
file_size: video.file_size,
resolution: video.width && video.height ? `${video.width}x${video.height}` : undefined,
status:
video.status === "completed"
? "completed"
: video.status === "failed"
? "failed"
: "processing",
review_status: video.review_status,
project_id: video.project_id,
// 后端 /videos 接口暂无 project_name 字段
project_name: undefined,
// 后端字段名为 generated_at,映射为 created_at 供前端统一使用
created_at: video.generated_at,
updated_at: video.generated_at,
// 后端 /videos 接口暂无 duplicate_rate 字段
duplicate_rate: undefined,
}
}
@@ -1,10 +1,32 @@
/**
* API
* API
* project_id
*/
import apiClient from "../client"
import type { BackendListProjectsResponse, BackendProjectResponse, ProjectItem } from "./types"
import { toProjectItem } from "./utils"
import apiClient from "./client"
export interface ProjectItem {
id: string
name: string
description: string
}
/** 后端 ProjectResponse 只返回 id, name, description */
interface BackendProjectResponse {
id: string
name: string
description: string
}
/** 后端 ListProjectsResponse 返回 { items: [...] } */
interface BackendListProjectsResponse {
items: BackendProjectResponse[]
}
const toProjectItem = (item: BackendProjectResponse): ProjectItem => ({
id: item.id,
name: item.name,
description: item.description,
})
/** 获取当前用户的项目列表 */
export const getProjects = async (): Promise<ProjectItem[]> => {
-13
View File
@@ -1,13 +0,0 @@
/**
* 项目 API — 目录化入口
* 保持与原 projects.ts 相同导出,向后兼容
*/
// 类型
export type { ProjectItem, BackendProjectResponse, BackendListProjectsResponse } from "./types"
// 工具函数
export { toProjectItem } from "./utils"
// API 函数
export { getProjects, createProject, getOrCreateDefaultProject } from "./projects"
-21
View File
@@ -1,21 +0,0 @@
/**
* 项目相关类型定义
*/
export interface ProjectItem {
id: string
name: string
description: string
}
/** 后端 ProjectResponse 只返回 id, name, description */
export interface BackendProjectResponse {
id: string
name: string
description: string
}
/** 后端 ListProjectsResponse 返回 { items: [...] } */
export interface BackendListProjectsResponse {
items: BackendProjectResponse[]
}
-10
View File
@@ -1,10 +0,0 @@
/**
* 项目相关工具函数
*/
import type { BackendProjectResponse, ProjectItem } from "./types"
export const toProjectItem = (item: BackendProjectResponse): ProjectItem => ({
id: item.id,
name: item.name,
description: item.description,
})
@@ -1,6 +1,8 @@
/**
*
* API
*
*/
import apiClient from "./client"
/** 套餐类型 */
export type PlanType = "free" | "standard" | "pro" | "enterprise"
@@ -63,3 +65,42 @@ export interface ChangePlanResponse {
message: string
new_subscription?: SubscriptionInfo
}
// ============ API 函数 ============
/** 获取当前订阅信息 */
export const getCurrentSubscription = async (): Promise<SubscriptionInfo> => {
const response = await apiClient.get("/subscription/current")
return response.data
}
/** 获取账单记录列表 */
export const getBillingRecords = async (): Promise<BillingRecord[]> => {
const response = await apiClient.get("/subscription/billing-records")
return response.data
}
/** 升级/降级套餐 */
export const changePlan = async (request: ChangePlanRequest): Promise<ChangePlanResponse> => {
const response = await apiClient.post("/subscription/change-plan", request)
return response.data
}
/** 取消订阅 */
export const cancelSubscription = async (): Promise<{
success: boolean
message: string
}> => {
const response = await apiClient.post("/subscription/cancel")
return response.data
}
/** 切换自动续费 */
export const toggleAutoRenew = async (
enabled: boolean,
): Promise<{ success: boolean; message: string }> => {
const response = await apiClient.post("/subscription/toggle-auto-renew", {
enabled,
})
return response.data
}
-26
View File
@@ -1,26 +0,0 @@
/**
* 订阅 API — 目录化入口
* 保持与原 subscription.ts 相同导出,向后兼容
*/
// 类型
export type {
PlanType,
SubscriptionStatus,
BillingStatus,
BillingCycle,
Plan,
SubscriptionInfo,
BillingRecord,
ChangePlanRequest,
ChangePlanResponse,
} from "./types"
// API 函数
export {
getCurrentSubscription,
getBillingRecords,
changePlan,
cancelSubscription,
toggleAutoRenew,
} from "./subscription"
@@ -1,47 +0,0 @@
/**
* 订阅相关 API 函数
*/
import apiClient from "../client"
import type {
BillingRecord,
ChangePlanRequest,
ChangePlanResponse,
SubscriptionInfo,
} from "./types"
/** 获取当前订阅信息 */
export const getCurrentSubscription = async (): Promise<SubscriptionInfo> => {
const response = await apiClient.get("/subscription/current")
return response.data
}
/** 获取账单记录列表 */
export const getBillingRecords = async (): Promise<BillingRecord[]> => {
const response = await apiClient.get("/subscription/billing-records")
return response.data
}
/** 升级/降级套餐 */
export const changePlan = async (request: ChangePlanRequest): Promise<ChangePlanResponse> => {
const response = await apiClient.post("/subscription/change-plan", request)
return response.data
}
/** 取消订阅 */
export const cancelSubscription = async (): Promise<{
success: boolean
message: string
}> => {
const response = await apiClient.post("/subscription/cancel")
return response.data
}
/** 切换自动续费 */
export const toggleAutoRenew = async (
enabled: boolean,
): Promise<{ success: boolean; message: string }> => {
const response = await apiClient.post("/subscription/toggle-auto-renew", {
enabled,
})
return response.data
}
@@ -1,9 +1,15 @@
/**
* CRUD API
* CRUD API
* P3
*/
import apiClient from "../client"
import type { TagItem } from "./types"
import apiClient from "./client"
export interface TagItem {
id: string
name: string
created_at?: string
usage_count?: number
}
/** 获取当前用户所有标签 */
export const getTags = async (): Promise<TagItem[]> => {
-10
View File
@@ -1,10 +0,0 @@
/**
* 标签 API — 目录化入口
* 保持与原 tags.ts 相同导出,向后兼容
*/
// 类型
export type { TagItem } from "./types"
// API 函数
export { getTags, createTag, deleteTag, tagAsset, untagAsset } from "./tags"
-10
View File
@@ -1,10 +0,0 @@
/**
* 标签相关类型定义
*/
export interface TagItem {
id: string
name: string
created_at?: string
usage_count?: number
}
@@ -1,6 +1,14 @@
/**
*
* API
* API
* - POST /api/v1/generation/tasks
* - GET /api/v1/tasks /
* - GET /api/v1/tasks/{task_id} error_info
* - POST /api/v1/tasks/{task_id}/retry
*/
import apiClient from "./client"
/* ──────────── 类型定义 ──────────── */
/** 任务状态 */
export type TaskStatus = "pending" | "waiting" | "running" | "completed" | "failed" | "cancelled"
@@ -77,3 +85,39 @@ export interface CreateGenerationTaskResponse {
result_count: number
error_message: string
}
/* ──────────── API 函数 ──────────── */
/** 创建生成任务(智能剪辑) */
export const createGenerationTask = async (
params: CreateGenerationTaskRequest,
): Promise<CreateGenerationTaskResponse> => {
const { data } = await apiClient.post<CreateGenerationTaskResponse>("/generation/tasks", params)
return data
}
/** 获取任务列表(支持分页和筛选) */
export const getTasks = async (params?: TaskListParams): Promise<TaskListResponse> => {
const { data } = await apiClient.get<TaskListResponse>("/tasks", {
params,
})
return data
}
/** 获取当前用户的所有任务(兼容旧接口,跨 project) */
export const getUserTasks = async (): Promise<TaskItem[]> => {
const { data } = await apiClient.get("/tasks")
return data.items || data || []
}
/** 获取单个任务详情(含 error_info */
export const getTask = async (taskId: string): Promise<TaskItem> => {
const { data } = await apiClient.get(`/tasks/${taskId}`)
return data
}
/** 重试失败的任务 */
export const retryTask = async (taskId: string): Promise<TaskItem> => {
const { data } = await apiClient.post(`/tasks/${taskId}/retry`)
return data
}
-19
View File
@@ -1,19 +0,0 @@
/**
* 任务相关 API — 目录化入口
* 保持与原 tasks.ts 相同导出,向后兼容
*/
// 类型
export type {
TaskStatus,
TaskType,
TaskErrorInfo,
TaskItem,
TaskListParams,
TaskListResponse,
CreateGenerationTaskRequest,
CreateGenerationTaskResponse,
} from "./types"
// API 函数
export { createGenerationTask, getTasks, getUserTasks, getTask, retryTask } from "./tasks"
-46
View File
@@ -1,46 +0,0 @@
/**
* 任务相关 API 函数
* 对接后端任务中心 API
*/
import apiClient from "../client"
import type {
CreateGenerationTaskRequest,
CreateGenerationTaskResponse,
TaskItem,
TaskListParams,
TaskListResponse,
} from "./types"
/** 创建生成任务(智能剪辑) */
export const createGenerationTask = async (
params: CreateGenerationTaskRequest,
): Promise<CreateGenerationTaskResponse> => {
const { data } = await apiClient.post<CreateGenerationTaskResponse>("/generation/tasks", params)
return data
}
/** 获取任务列表(支持分页和筛选) */
export const getTasks = async (params?: TaskListParams): Promise<TaskListResponse> => {
const { data } = await apiClient.get<TaskListResponse>("/tasks", {
params,
})
return data
}
/** 获取当前用户的所有任务(兼容旧接口,跨 project) */
export const getUserTasks = async (): Promise<TaskItem[]> => {
const { data } = await apiClient.get("/tasks")
return data.items || data || []
}
/** 获取单个任务详情(含 error_info */
export const getTask = async (taskId: string): Promise<TaskItem> => {
const { data } = await apiClient.get(`/tasks/${taskId}`)
return data
}
/** 重试失败的任务 */
export const retryTask = async (taskId: string): Promise<TaskItem> => {
const { data } = await apiClient.post(`/tasks/${taskId}/retry`)
return data
}
+744
View File
@@ -0,0 +1,744 @@
/**
* 模板草稿 API — 对接后端 Template Editor Schema
* 字段名严格匹配后端 API 响应
*/
import apiClient from "./client"
import type { AssetItem } from "./assets"
import type {
WatermarkConfig,
IntroOutroConfig,
PipConfig,
FilterConfig,
ChromaKeyConfig,
StickerConfig,
CoverConfig,
} from "@/pages/editing-planner/types"
/* ============================================================
* 后端 API 类型(严格匹配后端 Schema)
* ============================================================ */
/** 模板草稿状态枚举 */
export type EditPlanStatus =
"draft" | "editing" | "rendering" | "completed" | "failed" | "cancelled"
/** 标题配置(对齐后端 title_config */
export interface TitleConfig {
ai_auto_select: boolean
content: string
font_preset: string
font_color: string
font_size: number
position: string
}
/** 字幕配置 */
export interface SubtitleConfig {
enabled: boolean
position: string
font: string
color: string
size: number
animation: string
}
/** BGM 配置 */
export interface BgmConfig {
enabled: boolean
music_id: string
}
/** 片段 TTS 配置 */
export interface SegmentTtsConfig {
mode: string
text: string
voice_id: string
speed: number
pitch: number
volume: number
subtitle_sync: boolean
}
/** 片段裁剪配置 */
export interface SegmentTrimConfig {
start_time: number
end_time: number
}
/** 片段转场配置 */
export interface SegmentTransitionConfig {
type: string
duration: number
}
/** 模板草稿中的单个片段(config 内部 segments 项) */
export interface EditPlanSegment {
segment_order: number
duration_min: number
duration_max: number
material_type: string
transition?: SegmentTransitionConfig
playback_speed?: number
tts_config?: SegmentTtsConfig
trim_config?: SegmentTrimConfig
}
/** 模板草稿 config 完整类型(对齐后端 config JSON 结构) */
export interface EditPlanConfig {
title_config?: TitleConfig
subtitle_config?: SubtitleConfig
bgm_config?: BgmConfig
estimated_duration?: number
segments?: EditPlanSegment[]
watermark_config?: WatermarkConfig
intro_outro_config?: IntroOutroConfig
pip_config?: PipConfig
filter_config?: FilterConfig
green_screen_config?: ChromaKeyConfig
sticker_config?: StickerConfig
cover_config?: CoverConfig
/** 前端扩展:关联的素材 ID 列表 */
asset_ids?: string[]
/** 配音 ID */
voice_id?: string
/** 克隆音色档案 ID */
voice_clone_profile_id?: string
/** 自定义配音音频 URL */
custom_audio_url?: string
/** 自定义配音文本 */
custom_text?: string
/** 视频比例 */
ratio?: string
/** 视频风格 */
style?: string
/** 目标时长(秒) */
duration?: number
/** 是否自动生成字幕 */
auto_subtitles?: boolean
/** 是否启用 BGM */
bgm?: boolean
/** 生成数量 */
generate_count?: number
/** 素材模式 */
material_mode?: string
}
/** 模板草稿(后端响应) */
export interface EditPlan {
id: string
template_id: string
name: string
status: EditPlanStatus
total_duration: number
/** 生成视频数量(后端 EditPlanResponse.result_count */
result_count: number
config: EditPlanConfig
created_at: string
updated_at: string
}
/** 创建模板草稿请求(后端要求 template_id + name 必填) */
export interface CreateEditPlanRequest {
template_id: string
name: string
config?: EditPlanConfig
total_duration?: number
/** 来源模板草稿 ID(从模板编辑器跳转到智能剪辑时关联) */
source_edit_plan_id?: string
}
/** 更新模板草稿请求 */
export interface UpdateEditPlanRequest {
name?: string
config?: EditPlanConfig
total_duration?: number
status?: EditPlanStatus
}
/** 生成响应 */
export interface GenerateResponse {
plan_id: string
plan_status: EditPlanStatus
generation_task_id: string
clip_count: number
}
/** 模板草稿关联的生成记录(实际是 GenerationTask 对象) */
export interface EditPlanGeneration {
id: string // 即 generation_task_id
source_edit_plan_id: string
template_id: string
asset_ids: string[]
status: EditPlanStatus
progress: number
result_count: number
error_message: string
error_info: Record<string, unknown>
logs: Array<Record<string, unknown>>
retry_count: number
created_at?: string
updated_at?: string
}
/** 片段生成状态 */
export interface ClipStatusItem {
clip_id: string
clip_type: string
order: number
status: string
asset_id?: string
text_content?: string
duration?: number
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
}
/** 生成视频详情(对应后端 GeneratedVideoResponse */
export interface GeneratedVideo {
id: string
project_id?: string
generation_task_id?: string
name: string
file_url: string
file_size?: number
duration?: number
thumbnail_url?: string
width?: number
height?: number
fps?: number
status: string
review_status?: string
download_url?: string
created_at?: string
updated_at?: string
}
/* ============================================================
* AI 推荐 & 封面生成(任务 3.09
* ============================================================ */
/** AI 推荐请求 */
export interface AIRecommendRequest {
asset_ids: string[]
editing_mode?: string
target_duration?: number
}
/** AI 推荐单个片段 */
export interface AIRecommendClipItem {
clip_type: string
order: number
text_content: string
duration: number
transition_effect: string
asset_id: string
start_time: number
config: EditPlanConfig
}
/** AI 推荐响应 */
export interface AIRecommendResponse {
plan_id: string
clips: AIRecommendClipItem[]
config: EditPlanConfig
total_duration: number
confidence: number
}
/** AI 封面生成请求 */
export interface GenerateCoverRequest {
asset_ids: string[]
cover_type?: "ai_frame" | "manual" | "upload" | "ai_regenerate"
frame_time?: number
}
/** AI 封面生成响应 */
export interface GenerateCoverResponse {
plan_id: string
cover: CoverResult
}
/** 封面生成结果 */
export interface CoverResult {
scheme?: string
asset_id?: string
frame_time?: number
thumbnail_url?: string
}
/* ============================================================
* 前端 UI 类型(EditingPlanner 组件依赖,保留兼容)
* ============================================================ */
/** 转场效果(14 种预设) */
export interface TransitionEffect {
type:
| "none"
| "cut"
| "fade"
| "dissolve"
| "zoom"
| "slide_left"
| "slide_right"
| "slide_up"
| "slide_down"
| "wipe_left"
| "wipe_right"
| "wipe_up"
| "wipe_down"
| "circlecrop"
| "rectcrop"
duration: number // 转场时长(秒)
/** 播放速度倍率 */
playback_speed?: number
}
/** 素材库资产(UI 层类型,映射自后端 AssetResponse */
export interface MediaAsset {
id: string
name: string
type: "video" | "image" | "audio"
/** 缩略图 URL */
thumbnail_url?: string
/** 时长(秒),仅 video/audio */
duration?: number
/** 文件大小(字节) */
size?: number
/** 标签 */
tags: string[]
created_at: string
/** 质量分 0-100 */
quality_score?: number
/** 分类状态 */
classification_status?: "pending" | "processing" | "completed" | "failed"
}
/* ============================================================
* API 函数 — 严格对接后端
* ============================================================ */
/** 模板草稿列表查询参数 */
export interface EditPlanListParams {
page?: number
page_size?: number
template_id?: string
status?: string
}
/** 模板草稿列表分页响应 */
export interface EditPlanListResponse {
items: EditPlan[]
total: number
page: number
page_size: number
}
/** 获取模板草稿列表(支持分页和筛选) */
export async function getEditPlans(params?: EditPlanListParams): Promise<EditPlanListResponse> {
const response = await apiClient.get<EditPlanListResponse>("/templates/drafts", {
params,
})
return response.data
}
/** 获取单个模板草稿 */
export async function getEditPlan(templateId: string): Promise<EditPlan> {
const response = await apiClient.get(`/templates/${templateId}/editor`)
return response.data
}
/** 创建模板草稿 */
export async function createEditPlan(data: CreateEditPlanRequest): Promise<EditPlan> {
const response = await apiClient.post("/templates/drafts", data)
return response.data
}
/** 更新模板草稿 */
export async function updateEditPlan(
templateId: string,
data: UpdateEditPlanRequest,
): Promise<EditPlan> {
const response = await apiClient.put(`/templates/${templateId}/editor`, data)
return response.data
}
/** 删除模板草稿 */
export async function deleteEditPlan(templateId: string): Promise<void> {
await apiClient.delete(`/templates/${templateId}/editor`)
}
/** 触发生成 */
export async function generateEditPlan(templateId: string): Promise<GenerateResponse> {
const response = await apiClient.post(`/templates/${templateId}/editor/generate`)
return response.data
}
/** 获取生成状态(轮询用) */
export async function getGenerationStatus(templateId: string): Promise<GenerationStatusResponse> {
const response = await apiClient.get(`/templates/${templateId}/editor/generation-status`)
return response.data
}
/** AI 推荐片段方案 */
export async function aiRecommendClips(
templateId: string,
data: AIRecommendRequest,
): Promise<AIRecommendResponse> {
const response = await apiClient.post(`/templates/${templateId}/editor/ai-recommend`, data)
return response.data
}
/** AI 生成封面 */
export async function generateCover(
templateId: string,
data: GenerateCoverRequest,
): Promise<GenerateCoverResponse> {
const response = await apiClient.post(`/templates/${templateId}/editor/generate-cover`, data)
return response.data
}
/** 获取模板草稿关联的生成记录 */
export async function getEditPlanGenerations(templateId: string): Promise<EditPlanGeneration[]> {
const response = await apiClient.get(`/templates/${templateId}/editor/generations`)
return response.data.items || []
}
/** 获取生成任务的视频结果列表 */
export async function getGenerationTaskResults(taskId: string): Promise<GeneratedVideo[]> {
const response = await apiClient.get(`/generation/tasks/${taskId}/results`)
return response.data.items || response.data || []
}
/** 取消生成任务 */
export async function cancelGeneration(templateId: string): Promise<void> {
await apiClient.post(`/templates/${templateId}/editor/cancel`)
}
/* ============================================================
* 片段 CRUD(后端 EditPlanClip 独立表)
* ============================================================ */
/** 片段状态 */
export type EditPlanClipStatus = "pending" | "processing" | "ready" | "failed"
/** 剪辑片段(后端响应) */
export interface EditPlanClip {
id: string
plan_id: string
clip_type: string // main / intro / outro / overlay / background / b_roll 等
order: number
asset_id: string
text_content: string
start_time: number
duration: number
transition_effect: string
transition_duration: number
playback_speed: number
status: EditPlanClipStatus
config: Record<string, unknown>
created_at?: string
updated_at?: string
}
/** 创建片段请求 */
export interface CreateEditPlanClipRequest {
clip_type: string
order: number
asset_id?: string
text_content?: string
start_time?: number
duration?: number
transition_effect?: string
transition_duration?: number
playback_speed?: number
config?: Record<string, unknown>
}
/** 更新片段请求 */
export interface UpdateEditPlanClipRequest {
clip_type?: string
order?: number
asset_id?: string
text_content?: string
start_time?: number
duration?: number
transition_effect?: string
transition_duration?: number
playback_speed?: number
config?: Record<string, unknown>
}
/** 片段列表响应 */
export interface EditPlanClipListResponse {
items: EditPlanClip[]
total: number
}
/** 片段列表查询参数 */
export interface EditPlanClipListParams {
status?: string
skip?: number
limit?: number
}
/** 获取片段列表 */
export async function getEditPlanClips(
templateId: string,
params?: EditPlanClipListParams,
): Promise<EditPlanClipListResponse> {
const response = await apiClient.get<EditPlanClipListResponse>(
`/templates/${templateId}/editor/clips`,
{
params,
},
)
return response.data
}
/** 获取单个片段详情 */
export async function getEditPlanClip(templateId: string, clipId: string): Promise<EditPlanClip> {
const response = await apiClient.get<EditPlanClip>(
`/templates/${templateId}/editor/clips/${clipId}`,
)
return response.data
}
/** 创建片段 */
export async function createEditPlanClip(
templateId: string,
data: CreateEditPlanClipRequest,
): Promise<EditPlanClip> {
const response = await apiClient.post<EditPlanClip>(`/templates/${templateId}/editor/clips`, data)
return response.data
}
/** 更新片段 */
export async function updateEditPlanClip(
templateId: string,
clipId: string,
data: UpdateEditPlanClipRequest,
): Promise<EditPlanClip> {
const response = await apiClient.put<EditPlanClip>(
`/templates/${templateId}/editor/clips/${clipId}`,
data,
)
return response.data
}
/** 删除片段 */
export async function deleteEditPlanClip(templateId: string, clipId: string): Promise<void> {
await apiClient.delete(`/templates/${templateId}/editor/clips/${clipId}`)
}
/* ============================================================
* 片段批量操作
* ============================================================ */
/** 重排序条目 */
export interface ClipReorderItem {
clip_id: string
new_order: number
}
/** 重排序响应 */
export interface ClipReorderResponse {
success: boolean
updated_count: number
message: string
}
/** 批量删除响应 */
export interface ClipBatchDeleteResponse {
success: boolean
deleted_count: number
message: string
}
/** 从素材批量创建响应 */
export interface ClipsFromAssetsResponse {
success: boolean
created_count: number
message: string
clip_ids: string[]
}
/** 片段重排序(拖拽排序后一次性提交) */
export async function reorderEditPlanClips(
templateId: string,
items: ClipReorderItem[],
): Promise<ClipReorderResponse> {
const response = await apiClient.post<ClipReorderResponse>(
`/templates/${templateId}/editor/clips/reorder`,
{ items },
)
return response.data
}
/** 批量删除片段 */
export async function batchDeleteEditPlanClips(
templateId: string,
clipIds: string[],
): Promise<ClipBatchDeleteResponse> {
const response = await apiClient.post<ClipBatchDeleteResponse>(
`/templates/${templateId}/editor/clips/batch-delete`,
{ clip_ids: clipIds },
)
return response.data
}
/** 从素材批量创建片段(追加到时间线末尾) */
export async function createClipsFromAssets(
templateId: string,
assetIds: string[],
clipType = "main",
): Promise<ClipsFromAssetsResponse> {
const response = await apiClient.post<ClipsFromAssetsResponse>(
`/templates/${templateId}/editor/clips/from-assets`,
{ asset_ids: assetIds, clip_type: clipType },
)
return response.data
}
/* ============================================================
* 复制计划
* ============================================================ */
/** 复制计划请求 */
export interface CopyEditPlanRequest {
name?: string
project_id?: string
}
/** 复制模板草稿(含所有片段配置) */
export async function copyEditPlan(
templateId: string,
data?: CopyEditPlanRequest,
): Promise<EditPlan> {
const response = await apiClient.post<EditPlan>(
`/templates/${templateId}/editor/copy`,
data || {},
)
return response.data
}
/**
* 获取素材库列表 — 调用 GET /api/v1/assets?library_id=xxx
* 将后端 AssetResponse 映射为前端 MediaAsset 类型
*/
export async function getMediaAssets(libraryId?: string): Promise<MediaAsset[]> {
const response = await apiClient.get("/assets", {
params: libraryId ? { library_id: libraryId } : undefined,
})
const items: AssetItem[] = response.data.items || []
return items.map(mapAssetToMediaAsset)
}
/** 获取单个素材 */
export async function getMediaAsset(id: string): Promise<MediaAsset> {
const response = await apiClient.get(`/assets/${id}`)
return mapAssetToMediaAsset(response.data)
}
/* ============================================================
* 映射函数:AssetResponse → MediaAsset
* ============================================================ */
function inferMediaType(mimeType: string): "video" | "image" | "audio" {
if (mimeType.startsWith("video/")) return "video"
if (mimeType.startsWith("image/")) return "image"
return "audio"
}
function mapAssetToMediaAsset(asset: AssetItem): MediaAsset {
// 优先取顶层 duration,其次从 metadata 回退
const metaDuration =
typeof asset.metadata?.duration === "number" ? asset.metadata.duration : undefined
return {
id: asset.id,
name: asset.name,
type: inferMediaType(asset.mime_type || ""),
thumbnail_url: asset.thumbnail_url,
duration: asset.duration ?? metaDuration,
size: asset.file_size ?? undefined,
tags: [],
created_at: asset.created_at ?? "",
quality_score: asset.quality_score ?? undefined,
classification_status: asset.classification_status ?? undefined,
}
}
/* ============================================================
* 常量
* ============================================================ */
/** 转场效果选项(14 种预设) */
export const TRANSITION_OPTIONS: {
value: TransitionEffect["type"]
label: string
icon: string
}[] = [
{ value: "none", label: "无转场", icon: "⊘" },
{ value: "cut", label: "硬切", icon: "✂" },
{ value: "fade", label: "淡入淡出", icon: "◐" },
{ value: "dissolve", label: "溶解", icon: "◈" },
{ value: "zoom", label: "缩放", icon: "⊕" },
{ value: "slide_left", label: "左滑", icon: "←" },
{ value: "slide_right", label: "右滑", icon: "→" },
{ value: "slide_up", label: "上滑", icon: "↑" },
{ value: "slide_down", label: "下滑", icon: "↓" },
{ value: "wipe_left", label: "左擦除", icon: "▸|" },
{ value: "wipe_right", label: "右擦除", icon: "|◂" },
{ value: "wipe_up", label: "上擦除", icon: "▴̄" },
{ value: "wipe_down", label: "下擦除", icon: "▾̄" },
{ value: "circlecrop", label: "圆形裁切", icon: "●" },
{ value: "rectcrop", label: "矩形裁切", icon: "■" },
]
/** 素材类型标签 */
export const MATERIAL_TYPE_LABELS: Record<string, string> = {
video: "视频",
image: "图片",
audio: "音频",
voiceover: "配音",
}
/** 素材类型图标 */
export const MATERIAL_TYPE_ICONS: Record<string, string> = {
video: "🎬",
image: "🖼️",
audio: "🎵",
voiceover: "🎙️",
}
/** 计划状态标签 */
export const PLAN_STATUS_LABELS: Record<EditPlanStatus, string> = {
draft: "草稿",
editing: "编辑中",
rendering: "渲染中",
completed: "已完成",
failed: "失败",
cancelled: "已取消",
}
/** 质量分筛选选项 */
export const QUALITY_OPTIONS: {
value: string
label: string
min?: number
max?: number
}[] = [
{ value: "all", label: "全部质量" },
{ value: "high", label: "高质量 (80-100)", min: 80, max: 100 },
{ value: "medium", label: "中质量 (50-79)", min: 50, max: 79 },
{ value: "low", label: "低质量 (0-49)", min: 0, max: 49 },
]
@@ -1,28 +0,0 @@
/**
* AI 推荐 + 封面生成 API
*/
import apiClient from "../client"
import type {
AIRecommendRequest,
AIRecommendResponse,
GenerateCoverRequest,
GenerateCoverResponse,
} from "./types"
/** AI 推荐片段方案 */
export async function aiRecommendClips(
templateId: string,
data: AIRecommendRequest,
): Promise<AIRecommendResponse> {
const response = await apiClient.post(`/templates/${templateId}/editor/ai-recommend`, data)
return response.data
}
/** AI 生成封面 */
export async function generateCover(
templateId: string,
data: GenerateCoverRequest,
): Promise<GenerateCoverResponse> {
const response = await apiClient.post(`/templates/${templateId}/editor/generate-cover`, data)
return response.data
}
-99
View File
@@ -1,99 +0,0 @@
/**
* 片段 CRUD + 批量操作 API
*/
import apiClient from "../client"
import type {
EditPlanClip,
EditPlanClipListParams,
EditPlanClipListResponse,
CreateEditPlanClipRequest,
UpdateEditPlanClipRequest,
ClipReorderItem,
ClipReorderResponse,
ClipBatchDeleteResponse,
ClipsFromAssetsResponse,
} from "./types"
/** 获取片段列表 */
export async function getEditPlanClips(
templateId: string,
params?: EditPlanClipListParams,
): Promise<EditPlanClipListResponse> {
const response = await apiClient.get<EditPlanClipListResponse>(
`/templates/${templateId}/editor/clips`,
{ params },
)
return response.data
}
/** 获取单个片段详情 */
export async function getEditPlanClip(templateId: string, clipId: string): Promise<EditPlanClip> {
const response = await apiClient.get<EditPlanClip>(
`/templates/${templateId}/editor/clips/${clipId}`,
)
return response.data
}
/** 创建片段 */
export async function createEditPlanClip(
templateId: string,
data: CreateEditPlanClipRequest,
): Promise<EditPlanClip> {
const response = await apiClient.post<EditPlanClip>(`/templates/${templateId}/editor/clips`, data)
return response.data
}
/** 更新片段 */
export async function updateEditPlanClip(
templateId: string,
clipId: string,
data: UpdateEditPlanClipRequest,
): Promise<EditPlanClip> {
const response = await apiClient.put<EditPlanClip>(
`/templates/${templateId}/editor/clips/${clipId}`,
data,
)
return response.data
}
/** 删除片段 */
export async function deleteEditPlanClip(templateId: string, clipId: string): Promise<void> {
await apiClient.delete(`/templates/${templateId}/editor/clips/${clipId}`)
}
/** 片段重排序(拖拽排序后一次性提交) */
export async function reorderEditPlanClips(
templateId: string,
items: ClipReorderItem[],
): Promise<ClipReorderResponse> {
const response = await apiClient.post<ClipReorderResponse>(
`/templates/${templateId}/editor/clips/reorder`,
{ items },
)
return response.data
}
/** 批量删除片段 */
export async function batchDeleteEditPlanClips(
templateId: string,
clipIds: string[],
): Promise<ClipBatchDeleteResponse> {
const response = await apiClient.post<ClipBatchDeleteResponse>(
`/templates/${templateId}/editor/clips/batch-delete`,
{ clip_ids: clipIds },
)
return response.data
}
/** 从素材批量创建片段(追加到时间线末尾) */
export async function createClipsFromAssets(
templateId: string,
assetIds: string[],
clipType = "main",
): Promise<ClipsFromAssetsResponse> {
const response = await apiClient.post<ClipsFromAssetsResponse>(
`/templates/${templateId}/editor/clips/from-assets`,
{ asset_ids: assetIds, clip_type: clipType },
)
return response.data
}
@@ -1,66 +0,0 @@
/**
* 模板编辑器常量
*/
import type { TransitionEffect, EditPlanStatus } from "./types"
/** 转场效果选项(14 种预设) */
export const TRANSITION_OPTIONS: {
value: TransitionEffect["type"]
label: string
icon: string
}[] = [
{ value: "none", label: "无转场", icon: "⊘" },
{ value: "cut", label: "硬切", icon: "✂" },
{ value: "fade", label: "淡入淡出", icon: "◐" },
{ value: "dissolve", label: "溶解", icon: "◈" },
{ value: "zoom", label: "缩放", icon: "⊕" },
{ value: "slide_left", label: "左滑", icon: "←" },
{ value: "slide_right", label: "右滑", icon: "→" },
{ value: "slide_up", label: "上滑", icon: "↑" },
{ value: "slide_down", label: "下滑", icon: "↓" },
{ value: "wipe_left", label: "左擦除", icon: "▸|" },
{ value: "wipe_right", label: "右擦除", icon: "|◂" },
{ value: "wipe_up", label: "上擦除", icon: "▴̄" },
{ value: "wipe_down", label: "下擦除", icon: "▾̄" },
{ value: "circlecrop", label: "圆形裁切", icon: "●" },
{ value: "rectcrop", label: "矩形裁切", icon: "■" },
]
/** 素材类型标签 */
export const MATERIAL_TYPE_LABELS: Record<string, string> = {
video: "视频",
image: "图片",
audio: "音频",
voiceover: "配音",
}
/** 素材类型图标 */
export const MATERIAL_TYPE_ICONS: Record<string, string> = {
video: "🎬",
image: "🖼️",
audio: "🎵",
voiceover: "🎙️",
}
/** 计划状态标签 */
export const PLAN_STATUS_LABELS: Record<EditPlanStatus, string> = {
draft: "草稿",
editing: "编辑中",
rendering: "渲染中",
completed: "已完成",
failed: "失败",
cancelled: "已取消",
}
/** 质量分筛选选项 */
export const QUALITY_OPTIONS: {
value: string
label: string
min?: number
max?: number
}[] = [
{ value: "all", label: "全部质量" },
{ value: "high", label: "高质量 (80-100)", min: 80, max: 100 },
{ value: "medium", label: "中质量 (50-79)", min: 50, max: 79 },
{ value: "low", label: "低质量 (0-49)", min: 0, max: 49 },
]
@@ -1,91 +0,0 @@
/**
* 模板草稿 CRUD + 生成相关 API
*/
import apiClient from "../client"
import type {
EditPlan,
EditPlanListParams,
EditPlanListResponse,
CreateEditPlanRequest,
UpdateEditPlanRequest,
GenerateResponse,
GenerationStatusResponse,
EditPlanGeneration,
GeneratedVideo,
CopyEditPlanRequest,
} from "./types"
/** 获取模板草稿列表(支持分页和筛选) */
export async function getEditPlans(params?: EditPlanListParams): Promise<EditPlanListResponse> {
const response = await apiClient.get<EditPlanListResponse>("/templates/drafts", {
params,
})
return response.data
}
/** 获取单个模板草稿 */
export async function getEditPlan(templateId: string): Promise<EditPlan> {
const response = await apiClient.get(`/templates/${templateId}/editor`)
return response.data
}
/** 创建模板草稿 */
export async function createEditPlan(data: CreateEditPlanRequest): Promise<EditPlan> {
const response = await apiClient.post("/templates/drafts", data)
return response.data
}
/** 更新模板草稿 */
export async function updateEditPlan(
templateId: string,
data: UpdateEditPlanRequest,
): Promise<EditPlan> {
const response = await apiClient.put(`/templates/${templateId}/editor`, data)
return response.data
}
/** 删除模板草稿 */
export async function deleteEditPlan(templateId: string): Promise<void> {
await apiClient.delete(`/templates/${templateId}/editor`)
}
/** 触发生成 */
export async function generateEditPlan(templateId: string): Promise<GenerateResponse> {
const response = await apiClient.post(`/templates/${templateId}/editor/generate`)
return response.data
}
/** 获取生成状态(轮询用) */
export async function getGenerationStatus(templateId: string): Promise<GenerationStatusResponse> {
const response = await apiClient.get(`/templates/${templateId}/editor/generation-status`)
return response.data
}
/** 获取模板草稿关联的生成记录 */
export async function getEditPlanGenerations(templateId: string): Promise<EditPlanGeneration[]> {
const response = await apiClient.get(`/templates/${templateId}/editor/generations`)
return response.data.items || []
}
/** 获取生成任务的视频结果列表 */
export async function getGenerationTaskResults(taskId: string): Promise<GeneratedVideo[]> {
const response = await apiClient.get(`/generation/tasks/${taskId}/results`)
return response.data.items || response.data || []
}
/** 取消生成任务 */
export async function cancelGeneration(templateId: string): Promise<void> {
await apiClient.post(`/templates/${templateId}/editor/cancel`)
}
/** 复制模板草稿(含所有片段配置) */
export async function copyEditPlan(
templateId: string,
data?: CopyEditPlanRequest,
): Promise<EditPlan> {
const response = await apiClient.post<EditPlan>(
`/templates/${templateId}/editor/copy`,
data || {},
)
return response.data
}
-88
View File
@@ -1,88 +0,0 @@
/**
* 模板编辑器 API — 按模块拆分后的统一入口
* 保持与原 template-editor.ts 相同的导出结构,向后兼容
*/
// 类型
export type {
EditPlanStatus,
TitleConfig,
SubtitleConfig,
BgmConfig,
SegmentTtsConfig,
SegmentTrimConfig,
SegmentTransitionConfig,
EditPlanSegment,
EditPlanConfig,
EditPlan,
CreateEditPlanRequest,
UpdateEditPlanRequest,
EditPlanListParams,
EditPlanListResponse,
GenerateResponse,
EditPlanGeneration,
ClipStatusItem,
GenerationStatusResponse,
GeneratedVideo,
AIRecommendRequest,
AIRecommendClipItem,
AIRecommendResponse,
GenerateCoverRequest,
GenerateCoverResponse,
CoverResult,
EditPlanClipStatus,
EditPlanClip,
CreateEditPlanClipRequest,
UpdateEditPlanClipRequest,
EditPlanClipListResponse,
EditPlanClipListParams,
ClipReorderItem,
ClipReorderResponse,
ClipBatchDeleteResponse,
ClipsFromAssetsResponse,
CopyEditPlanRequest,
TransitionEffect,
MediaAsset,
} from "./types"
// 常量
export {
TRANSITION_OPTIONS,
MATERIAL_TYPE_LABELS,
MATERIAL_TYPE_ICONS,
PLAN_STATUS_LABELS,
QUALITY_OPTIONS,
} from "./constants"
// 模板草稿 CRUD + 生成
export {
getEditPlans,
getEditPlan,
createEditPlan,
updateEditPlan,
deleteEditPlan,
generateEditPlan,
getGenerationStatus,
getEditPlanGenerations,
getGenerationTaskResults,
cancelGeneration,
copyEditPlan,
} from "./editPlans"
// 片段 CRUD + 批量操作
export {
getEditPlanClips,
getEditPlanClip,
createEditPlanClip,
updateEditPlanClip,
deleteEditPlanClip,
reorderEditPlanClips,
batchDeleteEditPlanClips,
createClipsFromAssets,
} from "./clips"
// AI 推荐 + 封面生成
export { aiRecommendClips, generateCover } from "./aiFeatures"
// 素材库
export { getMediaAssets, getMediaAsset } from "./mediaAssets"
@@ -1,44 +0,0 @@
/**
* 素材库 API(对接 /assets 接口,映射为 MediaAsset 类型)
*/
import apiClient from "../client"
import type { AssetItem } from "../assets"
import type { MediaAsset } from "./types"
const inferMediaType = (mimeType: string): "video" | "image" | "audio" => {
if (mimeType.startsWith("video/")) return "video"
if (mimeType.startsWith("image/")) return "image"
return "audio"
}
const mapAssetToMediaAsset = (asset: AssetItem): MediaAsset => {
const metaDuration =
typeof asset.metadata?.duration === "number" ? asset.metadata.duration : undefined
return {
id: asset.id,
name: asset.name,
type: inferMediaType(asset.mime_type || ""),
thumbnail_url: asset.thumbnail_url,
duration: asset.duration ?? metaDuration,
size: asset.file_size ?? undefined,
tags: [],
created_at: asset.created_at ?? "",
quality_score: asset.quality_score ?? undefined,
classification_status: asset.classification_status ?? undefined,
}
}
/** 获取素材库列表 */
export async function getMediaAssets(libraryId?: string): Promise<MediaAsset[]> {
const response = await apiClient.get("/assets", {
params: libraryId ? { library_id: libraryId } : undefined,
})
const items: AssetItem[] = response.data.items || []
return items.map(mapAssetToMediaAsset)
}
/** 获取单个素材 */
export async function getMediaAsset(id: string): Promise<MediaAsset> {
const response = await apiClient.get(`/assets/${id}`)
return mapAssetToMediaAsset(response.data)
}
-440
View File
@@ -1,440 +0,0 @@
/**
* 模板编辑器 API 类型定义
* 字段名严格匹配后端 API 响应
*/
import type {
WatermarkConfig,
IntroOutroConfig,
PipConfig,
FilterConfig,
ChromaKeyConfig,
StickerConfig,
CoverConfig,
} from "@/pages/editing-planner/types"
/* ── 模板草稿状态 ── */
export type EditPlanStatus =
"draft" | "editing" | "rendering" | "completed" | "failed" | "cancelled"
/* ── 配置相关类型 ── */
/** 标题配置(对齐后端 title_config */
export interface TitleConfig {
ai_auto_select: boolean
content: string
font_preset: string
font_color: string
font_size: number
position: string
}
/** 字幕配置 */
export interface SubtitleConfig {
enabled: boolean
position: string
font: string
color: string
size: number
animation: string
}
/** BGM 配置 */
export interface BgmConfig {
enabled: boolean
music_id: string
}
/** 片段 TTS 配置 */
export interface SegmentTtsConfig {
mode: string
text: string
voice_id: string
speed: number
pitch: number
volume: number
subtitle_sync: boolean
}
/** 片段裁剪配置 */
export interface SegmentTrimConfig {
start_time: number
end_time: number
}
/** 片段转场配置 */
export interface SegmentTransitionConfig {
type: string
duration: number
}
/** 模板草稿中的单个片段 */
export interface EditPlanSegment {
segment_order: number
duration_min: number
duration_max: number
material_type: string
transition?: SegmentTransitionConfig
playback_speed?: number
tts_config?: SegmentTtsConfig
trim_config?: SegmentTrimConfig
}
/** 模板草稿 config 完整类型 */
export interface EditPlanConfig {
title_config?: TitleConfig
subtitle_config?: SubtitleConfig
bgm_config?: BgmConfig
estimated_duration?: number
segments?: EditPlanSegment[]
watermark_config?: WatermarkConfig
intro_outro_config?: IntroOutroConfig
pip_config?: PipConfig
filter_config?: FilterConfig
green_screen_config?: ChromaKeyConfig
sticker_config?: StickerConfig
cover_config?: CoverConfig
/** 前端扩展:关联的素材 ID 列表 */
asset_ids?: string[]
/** 配音 ID */
voice_id?: string
/** 克隆音色档案 ID */
voice_clone_profile_id?: string
/** 自定义配音音频 URL */
custom_audio_url?: string
/** 自定义配音文本 */
custom_text?: string
/** 视频比例 */
ratio?: string
/** 视频风格 */
style?: string
/** 目标时长(秒) */
duration?: number
/** 是否自动生成字幕 */
auto_subtitles?: boolean
/** 是否启用 BGM */
bgm?: boolean
/** 生成数量 */
generate_count?: number
/** 素材模式 */
material_mode?: string
}
/* ── 模板草稿主体 ── */
/** 模板草稿(后端响应) */
export interface EditPlan {
id: string
template_id: string
name: string
status: EditPlanStatus
total_duration: number
/** 生成视频数量 */
result_count: number
config: EditPlanConfig
created_at: string
updated_at: string
}
/** 创建模板草稿请求 */
export interface CreateEditPlanRequest {
template_id: string
name: string
config?: EditPlanConfig
total_duration?: number
/** 来源模板草稿 ID */
source_edit_plan_id?: string
}
/** 更新模板草稿请求 */
export interface UpdateEditPlanRequest {
name?: string
config?: EditPlanConfig
total_duration?: number
status?: EditPlanStatus
}
/** 模板草稿列表查询参数 */
export interface EditPlanListParams {
page?: number
page_size?: number
template_id?: string
status?: string
}
/** 模板草稿列表分页响应 */
export interface EditPlanListResponse {
items: EditPlan[]
total: number
page: number
page_size: number
}
/* ── 生成相关 ── */
/** 生成响应 */
export interface GenerateResponse {
plan_id: string
plan_status: EditPlanStatus
generation_task_id: string
clip_count: number
}
/** 模板草稿关联的生成记录 */
export interface EditPlanGeneration {
id: string
source_edit_plan_id: string
template_id: string
asset_ids: string[]
status: EditPlanStatus
progress: number
result_count: number
error_message: string
error_info: Record<string, unknown>
logs: Array<Record<string, unknown>>
retry_count: number
created_at?: string
updated_at?: string
}
/** 片段生成状态 */
export interface ClipStatusItem {
clip_id: string
clip_type: string
order: number
status: string
asset_id?: string
text_content?: string
duration?: number
error_message?: string
}
/** 生成状态轮询响应 */
export interface GenerationStatusResponse {
plan_id: string
plan_status: EditPlanStatus
generation_task_id?: string
error_message?: string
clips: ClipStatusItem[]
error?: string
message?: string
}
/** 生成视频详情 */
export interface GeneratedVideo {
id: string
project_id?: string
generation_task_id?: string
name: string
file_url: string
file_size?: number
duration?: number
thumbnail_url?: string
width?: number
height?: number
fps?: number
status: string
review_status?: string
download_url?: string
created_at?: string
updated_at?: string
}
/* ── AI 推荐 & 封面生成 ── */
/** AI 推荐请求 */
export interface AIRecommendRequest {
asset_ids: string[]
editing_mode?: string
target_duration?: number
}
/** AI 推荐单个片段 */
export interface AIRecommendClipItem {
clip_type: string
order: number
text_content: string
duration: number
transition_effect: string
asset_id: string
start_time: number
config: EditPlanConfig
}
/** AI 推荐响应 */
export interface AIRecommendResponse {
plan_id: string
clips: AIRecommendClipItem[]
config: EditPlanConfig
total_duration: number
confidence: number
}
/** AI 封面生成请求 */
export interface GenerateCoverRequest {
asset_ids: string[]
cover_type?: "ai_frame" | "manual" | "upload" | "ai_regenerate"
frame_time?: number
}
/** AI 封面生成响应 */
export interface GenerateCoverResponse {
plan_id: string
cover: CoverResult
}
/** 封面生成结果 */
export interface CoverResult {
scheme?: string
asset_id?: string
frame_time?: number
thumbnail_url?: string
}
/* ── 片段 CRUD 相关 ── */
/** 片段状态 */
export type EditPlanClipStatus = "pending" | "processing" | "ready" | "failed"
/** 剪辑片段(后端响应) */
export interface EditPlanClip {
id: string
plan_id: string
clip_type: string
order: number
asset_id: string
text_content: string
start_time: number
duration: number
transition_effect: string
transition_duration: number
playback_speed: number
status: EditPlanClipStatus
config: Record<string, unknown>
created_at?: string
updated_at?: string
}
/** 创建片段请求 */
export interface CreateEditPlanClipRequest {
clip_type: string
order: number
asset_id?: string
text_content?: string
start_time?: number
duration?: number
transition_effect?: string
transition_duration?: number
playback_speed?: number
config?: Record<string, unknown>
}
/** 更新片段请求 */
export interface UpdateEditPlanClipRequest {
clip_type?: string
order?: number
asset_id?: string
text_content?: string
start_time?: number
duration?: number
transition_effect?: string
transition_duration?: number
playback_speed?: number
config?: Record<string, unknown>
}
/** 片段列表响应 */
export interface EditPlanClipListResponse {
items: EditPlanClip[]
total: number
}
/** 片段列表查询参数 */
export interface EditPlanClipListParams {
status?: string
skip?: number
limit?: number
}
/* ── 片段批量操作 ── */
/** 重排序条目 */
export interface ClipReorderItem {
clip_id: string
new_order: number
}
/** 重排序响应 */
export interface ClipReorderResponse {
success: boolean
updated_count: number
message: string
}
/** 批量删除响应 */
export interface ClipBatchDeleteResponse {
success: boolean
deleted_count: number
message: string
}
/** 从素材批量创建响应 */
export interface ClipsFromAssetsResponse {
success: boolean
created_count: number
message: string
clip_ids: string[]
}
/* ── 复制计划 ── */
/** 复制计划请求 */
export interface CopyEditPlanRequest {
name?: string
project_id?: string
}
/* ── 前端 UI 类型 ── */
/** 转场效果(14 种预设) */
export interface TransitionEffect {
type:
| "none"
| "cut"
| "fade"
| "dissolve"
| "zoom"
| "slide_left"
| "slide_right"
| "slide_up"
| "slide_down"
| "wipe_left"
| "wipe_right"
| "wipe_up"
| "wipe_down"
| "circlecrop"
| "rectcrop"
duration: number
/** 播放速度倍率 */
playback_speed?: number
}
/** 素材库资产(UI 层类型) */
export interface MediaAsset {
id: string
name: string
type: "video" | "image" | "audio"
/** 缩略图 URL */
thumbnail_url?: string
/** 时长(秒),仅 video/audio */
duration?: number
/** 文件大小(字节) */
size?: number
/** 标签 */
tags: string[]
created_at: string
/** 质量分 0-100 */
quality_score?: number
/** 分类状态 */
classification_status?: "pending" | "processing" | "completed" | "failed"
}
+168
View File
@@ -0,0 +1,168 @@
/**
* 模板相关 API
* 对接后端模板管理接口:
* - GET /api/v1/templates — 模板列表(分页/筛选)
* - GET /api/v1/templates/{id} — 模板详情
* - POST /api/v1/templates/{id}/copy — 复制模板
* - POST /api/v1/templates/{id}/generate — 从模板生成
* - POST /api/v1/templates/{id}/toggle-favorite — 收藏/取消收藏
*/
import apiClient from "./client"
import type { TitleConfig, SubtitleConfig, BgmConfig } from "./editing-planner"
import type { EditPlanConfig } from "./template-editor"
/* ──────────── 类型定义 ──────────── */
/** 模板条目(后端 TemplateResponse */
export interface TemplateItem {
id: string
user_id?: string
name: string
description?: string
mode?: string
category: string
tags?: string[]
/** 预估时长(后端字段名 estimated_duration */
estimated_duration?: number
/** @deprecated 后端已改名为 estimated_duration,保留兼容 */
target_duration?: number
clip_count?: number
/** 使用次数 */
usage_count?: number
thumbnail_url?: string
preview_url?: string
is_active?: boolean
is_favorite?: boolean
/** 素材规则(片段配置) */
segments?: TemplateSegment[]
/** 字幕样式 */
subtitle_config?: SubtitleConfig
/** BGM 配置 */
bgm_config?: BgmConfig
/** 标题配置 */
title_config?: TitleConfig
/** 视频比例 */
aspect_ratio?: string
created_at?: string
updated_at?: string
}
/** 模板片段(素材规则) */
export interface TemplateSegment {
id?: string
segment_order: number
duration_min: number
duration_max: number
material_type: string | null
description?: string
}
/** 模板列表查询参数 */
export interface TemplateListParams {
page?: number
page_size?: number
category?: string
tags?: string
keyword?: string
/** 时长筛选(秒):short < 30, medium 30-120, long > 120 */
duration_range?: "short" | "medium" | "long"
}
/** 模板列表分页响应 */
export interface TemplateListResponse {
items: TemplateItem[]
total: number
page: number
page_size: number
}
/** 从模板生成请求 */
export interface GenerateFromTemplateRequest {
asset_ids?: string[]
name?: string
config?: EditPlanConfig
}
/** 从模板生成响应 */
export interface GenerateFromTemplateResponse {
plan_id: string
template_id: string
status: string
name: string
}
/** 复制模板响应 */
export interface CopyTemplateResponse {
id: string
name: string
source_template_id: string
}
/* ──────────── API 函数 ──────────── */
/** 获取模板列表(支持分页和筛选) */
export const getTemplates = async (params?: TemplateListParams): Promise<TemplateListResponse> => {
const { data } = await apiClient.get<TemplateListResponse>("/templates", {
params,
})
return data
}
/** 获取模板列表(兼容旧接口,返回数组) */
export const getTemplatesList = async (): Promise<TemplateItem[]> => {
const response = await apiClient.get("/templates")
return response.data.items || response.data || []
}
/** 获取单个模板详情 */
export const getTemplate = async (templateId: string): Promise<TemplateItem> => {
const response = await apiClient.get(`/templates/${templateId}`)
return response.data
}
/** 收藏 / 取消收藏模板 */
export const toggleFavoriteTemplate = async (
templateId: string,
): Promise<{ is_favorite: boolean }> => {
const response = await apiClient.post(`/templates/${templateId}/toggle-favorite`)
return response.data
}
/** 复制模板(创建副本到我的模板) */
export const copyTemplate = async (templateId: string): Promise<CopyTemplateResponse> => {
const response = await apiClient.post<CopyTemplateResponse>(`/templates/${templateId}/copy`)
return response.data
}
/** 从模板生成 */
export const generateFromTemplate = async (
templateId: string,
data?: GenerateFromTemplateRequest,
): Promise<GenerateFromTemplateResponse> => {
const response = await apiClient.post<GenerateFromTemplateResponse>(
`/templates/${templateId}/generate`,
data,
)
return response.data
}
/* ──────────── 常量 ──────────── */
/** 模板分类选项 */
export const TEMPLATE_CATEGORY_OPTIONS = [
{ value: "", label: "全部分类" },
{ value: "口播", label: "口播" },
{ value: "种草", label: "种草" },
{ value: "产品", label: "产品" },
{ value: "品牌", label: "品牌" },
{ value: "混剪", label: "混剪" },
{ value: "Vlog", label: "Vlog" },
]
/** 时长筛选选项 */
export const TEMPLATE_DURATION_OPTIONS = [
{ value: "", label: "全部时长" },
{ value: "short", label: "30秒以内" },
{ value: "medium", label: "30秒-2分钟" },
{ value: "long", label: "2分钟以上" },
]
-22
View File
@@ -1,22 +0,0 @@
/**
* 模板相关常量
*/
/** 模板分类选项 */
export const TEMPLATE_CATEGORY_OPTIONS = [
{ value: "", label: "全部分类" },
{ value: "口播", label: "口播" },
{ value: "种草", label: "种草" },
{ value: "产品", label: "产品" },
{ value: "品牌", label: "品牌" },
{ value: "混剪", label: "混剪" },
{ value: "Vlog", label: "Vlog" },
]
/** 时长筛选选项 */
export const TEMPLATE_DURATION_OPTIONS = [
{ value: "", label: "全部时长" },
{ value: "short", label: "30秒以内" },
{ value: "medium", label: "30秒-2分钟" },
{ value: "long", label: "2分钟以上" },
]
-28
View File
@@ -1,28 +0,0 @@
/**
* 模板相关 API — 目录化入口
* 保持与原 templates.ts 相同导出,向后兼容
*/
// 类型
export type {
TemplateItem,
TemplateSegment,
TemplateListParams,
TemplateListResponse,
GenerateFromTemplateRequest,
GenerateFromTemplateResponse,
CopyTemplateResponse,
} from "./types"
// 常量
export { TEMPLATE_CATEGORY_OPTIONS, TEMPLATE_DURATION_OPTIONS } from "./constants"
// API 函数
export {
getTemplates,
getTemplatesList,
getTemplate,
toggleFavoriteTemplate,
copyTemplate,
generateFromTemplate,
} from "./templates"
-59
View File
@@ -1,59 +0,0 @@
/**
* 模板相关 API 函数
* 对接后端模板管理接口
*/
import apiClient from "../client"
import type {
CopyTemplateResponse,
GenerateFromTemplateRequest,
GenerateFromTemplateResponse,
TemplateItem,
TemplateListParams,
TemplateListResponse,
} from "./types"
/** 获取模板列表(支持分页和筛选) */
export const getTemplates = async (params?: TemplateListParams): Promise<TemplateListResponse> => {
const { data } = await apiClient.get<TemplateListResponse>("/templates", {
params,
})
return data
}
/** 获取模板列表(兼容旧接口,返回数组) */
export const getTemplatesList = async (): Promise<TemplateItem[]> => {
const response = await apiClient.get("/templates")
return response.data.items || response.data || []
}
/** 获取单个模板详情 */
export const getTemplate = async (templateId: string): Promise<TemplateItem> => {
const response = await apiClient.get(`/templates/${templateId}`)
return response.data
}
/** 收藏 / 取消收藏模板 */
export const toggleFavoriteTemplate = async (
templateId: string,
): Promise<{ is_favorite: boolean }> => {
const response = await apiClient.post(`/templates/${templateId}/toggle-favorite`)
return response.data
}
/** 复制模板(创建副本到我的模板) */
export const copyTemplate = async (templateId: string): Promise<CopyTemplateResponse> => {
const response = await apiClient.post<CopyTemplateResponse>(`/templates/${templateId}/copy`)
return response.data
}
/** 从模板生成 */
export const generateFromTemplate = async (
templateId: string,
data?: GenerateFromTemplateRequest,
): Promise<GenerateFromTemplateResponse> => {
const response = await apiClient.post<GenerateFromTemplateResponse>(
`/templates/${templateId}/generate`,
data,
)
return response.data
}
-90
View File
@@ -1,90 +0,0 @@
/**
* 模板相关类型定义
*/
import type { TitleConfig, SubtitleConfig, BgmConfig } from "../editing-planner"
import type { EditPlanConfig } from "../template-editor"
/** 模板条目(后端 TemplateResponse */
export interface TemplateItem {
id: string
user_id?: string
name: string
description?: string
mode?: string
category: string
tags?: string[]
/** 预估时长(后端字段名 estimated_duration */
estimated_duration?: number
/** @deprecated 后端已改名为 estimated_duration,保留兼容 */
target_duration?: number
clip_count?: number
/** 使用次数 */
usage_count?: number
thumbnail_url?: string
preview_url?: string
is_active?: boolean
is_favorite?: boolean
/** 素材规则(片段配置) */
segments?: TemplateSegment[]
/** 字幕样式 */
subtitle_config?: SubtitleConfig
/** BGM 配置 */
bgm_config?: BgmConfig
/** 标题配置 */
title_config?: TitleConfig
/** 视频比例 */
aspect_ratio?: string
created_at?: string
updated_at?: string
}
/** 模板片段(素材规则) */
export interface TemplateSegment {
id?: string
segment_order: number
duration_min: number
duration_max: number
material_type: string | null
description?: string
}
/** 模板列表查询参数 */
export interface TemplateListParams {
page?: number
page_size?: number
category?: string
tags?: string
keyword?: string
/** 时长筛选(秒):short < 30, medium 30-120, long > 120 */
duration_range?: "short" | "medium" | "long"
}
/** 模板列表分页响应 */
export interface TemplateListResponse {
items: TemplateItem[]
total: number
page: number
page_size: number
}
/** 从模板生成请求 */
export interface GenerateFromTemplateRequest {
asset_ids?: string[]
name?: string
config?: EditPlanConfig
}
/** 从模板生成响应 */
export interface GenerateFromTemplateResponse {
plan_id: string
template_id: string
status: string
name: string
}
/** 复制模板响应 */
export interface CopyTemplateResponse {
id: string
name: string
source_template_id: string
}
@@ -1,17 +1,70 @@
/**
* API
* API
* Phase 1
* schema 使 name + text UI content
*/
import apiClient from "../client"
import type {
BackendCreateTitleRequest,
BackendTitleResponse,
BackendUpdateTitleRequest,
CreateTitleRequest,
TitleItem,
} from "./types"
import { toTitleItem } from "./utils"
import apiClient from "./client"
/** 标题条目(前端展示用) */
export interface TitleItem {
id: string
content: string
category?: string
source?: string
word_count?: number
is_favorite?: boolean
created_at?: string
updated_at?: string
}
/** 后端标题响应格式 */
interface BackendTitleResponse {
id: string
user_id: string
name: string
text: string
category: string
description: string
tags: string[]
usage_count: number
is_active: boolean
created_at: string
updated_at: string
}
/** 后端创建标题请求格式 */
interface BackendCreateTitleRequest {
name: string
text: string
category: string
description?: string
tags?: string[]
}
/** 后端更新标题请求格式 */
interface BackendUpdateTitleRequest {
name?: string
text?: string
category?: string
description?: string
tags?: string[]
}
/** 将后端响应映射为前端 TitleItem */
const toTitleItem = (item: BackendTitleResponse): TitleItem => ({
id: item.id,
content: item.text,
category: item.category,
word_count: item.text?.length || 0,
created_at: item.created_at,
updated_at: item.updated_at,
})
/** 创建标题请求(前端接口,保持向后兼容) */
export interface CreateTitleRequest {
content: string
category?: string
}
/** 获取当前用户的所有标题 */
export const getTitles = async (): Promise<TitleItem[]> => {
-19
View File
@@ -1,19 +0,0 @@
/**
* 标题相关 API — 目录化入口
* 保持与原 titles.ts 相同导出,向后兼容
*/
// 类型
export type {
TitleItem,
BackendTitleResponse,
BackendCreateTitleRequest,
BackendUpdateTitleRequest,
CreateTitleRequest,
} from "./types"
// 工具函数
export { toTitleItem } from "./utils"
// API 函数
export { getTitles, createTitle, updateTitle, deleteTitle, batchImportTitles } from "./titles"
-54
View File
@@ -1,54 +0,0 @@
/**
* 标题相关类型定义
*/
/** 标题条目(前端展示用) */
export interface TitleItem {
id: string
content: string
category?: string
source?: string
word_count?: number
is_favorite?: boolean
created_at?: string
updated_at?: string
}
/** 后端标题响应格式 */
export interface BackendTitleResponse {
id: string
user_id: string
name: string
text: string
category: string
description: string
tags: string[]
usage_count: number
is_active: boolean
created_at: string
updated_at: string
}
/** 后端创建标题请求格式 */
export interface BackendCreateTitleRequest {
name: string
text: string
category: string
description?: string
tags?: string[]
}
/** 后端更新标题请求格式 */
export interface BackendUpdateTitleRequest {
name?: string
text?: string
category?: string
description?: string
tags?: string[]
}
/** 创建标题请求(前端接口,保持向后兼容) */
export interface CreateTitleRequest {
content: string
category?: string
}
-14
View File
@@ -1,14 +0,0 @@
/**
* 标题数据转换工具函数
*/
import type { BackendTitleResponse, TitleItem } from "./types"
/** 将后端响应映射为前端 TitleItem */
export const toTitleItem = (item: BackendTitleResponse): TitleItem => ({
id: item.id,
content: item.text,
category: item.category,
word_count: item.text?.length || 0,
created_at: item.created_at,
updated_at: item.updated_at,
})
+188
View File
@@ -0,0 +1,188 @@
/**
* TTS 语音合成 API
* 对接后端 /api/v1/tts/* 端点
*
* 任务 3.14 新增
*/
import apiClient from "./client"
/* ── 类型定义 ──────────────────────────────────── */
/** TTS 元数据(合成时附带的扩展信息) */
export interface TTSMetadata {
/** 语音时长(秒) */
duration?: number
/** 采样率(Hz */
sample_rate?: number
/** 语言 */
language?: string
/** 其他扩展字段 */
[key: string]: unknown
}
/** TTS 合成请求参数 */
export interface TTSSynthesizeRequest {
text: string
voice_id?: string
output_name?: string
language?: string
speed?: number
voice_model?: string
voice_clone_profile_id?: string
format?: string
metadata?: TTSMetadata
}
/** TTS 合成创建响应 */
export interface TTSSynthesizeResponse {
job_id: string
status: string
message: string
}
/** TTS 任务详情 */
export interface TTSJob {
id: string
user_id: string
project_id: string | null
text: string
voice_id: string | null
voice_model: string | null
voice_clone_profile_id: string | null
language: string
speed: number
output_name: string | null
output_audio_url: string | null
output_format: string
duration_seconds: number | null
file_size_bytes: number | null
sample_rate: number | null
status: string
error_message: string | null
retry_count: number
max_retries: number
metadata_: TTSMetadata | null
created_at: string
updated_at: string
}
/** TTS 任务状态(轻量轮询用) */
export interface TTSJobStatus {
id: string
status: string
output_audio_url: string | null
error_message: string | null
duration_seconds: number | null
retry_count: number
}
/** TTS 任务列表响应 */
export interface TTSJobListResponse {
items: TTSJob[]
total: number
skip: number
limit: number
}
/** TTS 任务列表查询参数 */
export interface TTSJobListParams {
status?: string
skip?: number
limit?: number
}
/* ── API 函数 ──────────────────────────────────── */
/** 创建 TTS 合成任务 */
export const synthesizeSpeech = async (
data: TTSSynthesizeRequest,
): Promise<TTSSynthesizeResponse> => {
const response = await apiClient.post<TTSSynthesizeResponse>("/tts/synthesize", data)
return response.data
}
/** 获取 TTS 任务详情 */
export const getTTSJob = async (jobId: string): Promise<TTSJob> => {
const response = await apiClient.get<TTSJob>(`/tts/jobs/${jobId}`)
return response.data
}
/** 获取 TTS 任务状态(轻量轮询) */
export const getTTSJobStatus = async (jobId: string): Promise<TTSJobStatus> => {
const response = await apiClient.get<TTSJobStatus>(`/tts/jobs/${jobId}/status`)
return response.data
}
/** 获取 TTS 任务列表 */
export const getTTSJobs = async (params?: TTSJobListParams): Promise<TTSJobListResponse> => {
const searchParams = new URLSearchParams()
if (params?.status) searchParams.set("status", params.status)
if (params?.skip !== undefined) searchParams.set("skip", String(params.skip))
if (params?.limit !== undefined) searchParams.set("limit", String(params.limit))
const qs = searchParams.toString()
const response = await apiClient.get<TTSJobListResponse>(`/tts/jobs${qs ? `?${qs}` : ""}`)
return response.data
}
/** 存为素材请求参数 */
export interface SaveTtsToLibraryRequest {
name?: string
tag_ids?: string[]
}
/** 将 TTS 合成结果保存到配音库 */
export const saveTtsToLibrary = async (
jobId: string,
data?: SaveTtsToLibraryRequest,
): Promise<void> => {
await apiClient.post(`/tts/jobs/${jobId}/save-to-library`, data ?? {})
}
/** 删除 TTS 任务 */
export const deleteTTSJob = async (jobId: string): Promise<void> => {
await apiClient.delete(`/tts/jobs/${jobId}`)
}
/* ── 音色列表 ──────────────────────────────────── */
/** TTS 音色 */
export interface TTSVoice {
id: string
name: string
/** 音色分类标签:male/female/young/service/news/emotion */
category?: string
/** 语言 */
language?: string
/** 试听 URL */
preview_url?: string
/** 描述 */
description?: string
}
/** 获取 TTS 音色列表 */
export const getTtsVoices = async (): Promise<TTSVoice[]> => {
const response = await apiClient.get<TTSVoice[]>("/tts/voices")
return response.data
}
/* ── TTS 试听 ──────────────────────────────────── */
/** TTS 试听请求参数 */
export interface TTSPreviewRequest {
text: string
voice_id: string
speed?: number
pitch?: number
}
/** TTS 试听响应 */
export interface TTSPreviewResponse {
audio_url: string
duration?: number
}
/** TTS 试听 */
export const previewTts = async (data: TTSPreviewRequest): Promise<TTSPreviewResponse> => {
const response = await apiClient.post<TTSPreviewResponse>("/tts/preview", data)
return response.data
}
-31
View File
@@ -1,31 +0,0 @@
/**
* TTS 语音合成 API — 目录化入口
* 保持与原 tts.ts 相同导出,向后兼容
*/
// 类型
export type {
TTSMetadata,
TTSSynthesizeRequest,
TTSSynthesizeResponse,
TTSJob,
TTSJobStatus,
TTSJobListResponse,
TTSJobListParams,
SaveTtsToLibraryRequest,
TTSVoice,
TTSPreviewRequest,
TTSPreviewResponse,
} from "./types"
// API 函数
export {
synthesizeSpeech,
getTTSJob,
getTTSJobStatus,
getTTSJobs,
saveTtsToLibrary,
deleteTTSJob,
getTtsVoices,
previewTts,
} from "./jobs"
-72
View File
@@ -1,72 +0,0 @@
/**
* TTS 语音合成 API 函数
*/
import apiClient from "../client"
import type {
TTSSynthesizeRequest,
TTSSynthesizeResponse,
TTSJob,
TTSJobStatus,
TTSJobListResponse,
TTSJobListParams,
SaveTtsToLibraryRequest,
TTSVoice,
TTSPreviewRequest,
TTSPreviewResponse,
} from "./types"
/** 创建 TTS 合成任务 */
export const synthesizeSpeech = async (
data: TTSSynthesizeRequest,
): Promise<TTSSynthesizeResponse> => {
const response = await apiClient.post<TTSSynthesizeResponse>("/tts/synthesize", data)
return response.data
}
/** 获取 TTS 任务详情 */
export const getTTSJob = async (jobId: string): Promise<TTSJob> => {
const response = await apiClient.get<TTSJob>(`/tts/jobs/${jobId}`)
return response.data
}
/** 获取 TTS 任务状态(轻量轮询) */
export const getTTSJobStatus = async (jobId: string): Promise<TTSJobStatus> => {
const response = await apiClient.get<TTSJobStatus>(`/tts/jobs/${jobId}/status`)
return response.data
}
/** 获取 TTS 任务列表 */
export const getTTSJobs = async (params?: TTSJobListParams): Promise<TTSJobListResponse> => {
const searchParams = new URLSearchParams()
if (params?.status) searchParams.set("status", params.status)
if (params?.skip !== undefined) searchParams.set("skip", String(params.skip))
if (params?.limit !== undefined) searchParams.set("limit", String(params.limit))
const qs = searchParams.toString()
const response = await apiClient.get<TTSJobListResponse>(`/tts/jobs${qs ? `?${qs}` : ""}`)
return response.data
}
/** 将 TTS 合成结果保存到配音库 */
export const saveTtsToLibrary = async (
jobId: string,
data?: SaveTtsToLibraryRequest,
): Promise<void> => {
await apiClient.post(`/tts/jobs/${jobId}/save-to-library`, data ?? {})
}
/** 删除 TTS 任务 */
export const deleteTTSJob = async (jobId: string): Promise<void> => {
await apiClient.delete(`/tts/jobs/${jobId}`)
}
/** 获取 TTS 音色列表 */
export const getTtsVoices = async (): Promise<TTSVoice[]> => {
const response = await apiClient.get<TTSVoice[]>("/tts/voices")
return response.data
}
/** TTS 试听 */
export const previewTts = async (data: TTSPreviewRequest): Promise<TTSPreviewResponse> => {
const response = await apiClient.post<TTSPreviewResponse>("/tts/preview", data)
return response.data
}
-112
View File
@@ -1,112 +0,0 @@
/**
* TTS 语音合成类型定义
*/
/** TTS 元数据 */
export interface TTSMetadata {
duration?: number
sample_rate?: number
language?: string
[key: string]: unknown
}
/** TTS 合成请求参数 */
export interface TTSSynthesizeRequest {
text: string
voice_id?: string
output_name?: string
language?: string
speed?: number
voice_model?: string
voice_clone_profile_id?: string
format?: string
metadata?: TTSMetadata
}
/** TTS 合成创建响应 */
export interface TTSSynthesizeResponse {
job_id: string
status: string
message: string
}
/** TTS 任务详情 */
export interface TTSJob {
id: string
user_id: string
project_id: string | null
text: string
voice_id: string | null
voice_model: string | null
voice_clone_profile_id: string | null
language: string
speed: number
output_name: string | null
output_audio_url: string | null
output_format: string
duration_seconds: number | null
file_size_bytes: number | null
sample_rate: number | null
status: string
error_message: string | null
retry_count: number
max_retries: number
metadata_: TTSMetadata | null
created_at: string
updated_at: string
}
/** TTS 任务状态(轻量轮询用) */
export interface TTSJobStatus {
id: string
status: string
output_audio_url: string | null
error_message: string | null
duration_seconds: number | null
retry_count: number
}
/** TTS 任务列表响应 */
export interface TTSJobListResponse {
items: TTSJob[]
total: number
skip: number
limit: number
}
/** TTS 任务列表查询参数 */
export interface TTSJobListParams {
status?: string
skip?: number
limit?: number
}
/** 存为素材请求参数 */
export interface SaveTtsToLibraryRequest {
name?: string
tag_ids?: string[]
}
/** TTS 音色 */
export interface TTSVoice {
id: string
name: string
category?: string
language?: string
preview_url?: string
description?: string
}
/** TTS 试听请求参数 */
export interface TTSPreviewRequest {
text: string
voice_id: string
speed?: number
pitch?: number
}
/** TTS 试听响应 */
export interface TTSPreviewResponse {
audio_url: string
duration?: number
}
+208
View File
@@ -0,0 +1,208 @@
/**
* 音色克隆 API
* 任务 3.11:替换 Mock 数据,对接后端真实 API(3.05)
* 任务 3.15:新增 progress 字段用于进度展示
*/
import apiClient from "./client"
/* ── 前端兼容类型 ─────────────────────────────────────── */
/** 克隆音色状态(前端展示用) */
export type VoiceCloneStatus = "ready" | "processing" | "failed"
/** 克隆音色条目(前端展示用) */
export interface VoiceClone {
id: string
name: string
description: string
duration_seconds: number
status: VoiceCloneStatus
/** 克隆进度 0-100,仅 processing 状态时有值 */
progress: number
sample_url?: string
language: string
gender: string
error_message: string | null
created_at: string
updated_at: string
}
/** 创建克隆请求(前端简化版) */
export interface CreateVoiceCloneRequest {
name: string
audio_url: string
description?: string
}
/* ── 后端 API 类型 ────────────────────────────────────── */
/** 音色克隆元数据(克隆时附带的扩展信息) */
export interface VoiceCloneMetadata {
/** 语音时长(秒) */
duration?: number
/** 采样率(Hz */
sample_rate?: number
/** 音色 ID(克隆完成后分配) */
voice_id?: string
/** 其他扩展字段 */
[key: string]: unknown
}
/** 后端克隆档案响应 */
export interface VoiceCloneProfile {
id: string
user_id: string
name: string
description: string
source_audio_url: string
voice_id: string | null
voice_model: string
language: string
gender: string
status: "pending" | "processing" | "ready" | "failed"
error_message: string | null
retry_count: number
max_retries: number
metadata_: VoiceCloneMetadata | null
created_at: string
updated_at: string
}
/** 后端克隆列表响应 */
export interface ListVoiceCloneResponse {
items: VoiceCloneProfile[]
total: number
}
/** 后端克隆状态响应 */
export interface VoiceCloneStatusResponse {
id: string
status: "pending" | "processing" | "ready" | "failed"
error_message: string | null
voice_id: string | null
retry_count: number
}
/** 后端创建克隆请求(完整版) */
export interface CreateVoiceCloneRequestFull {
name: string
description?: string
source_audio_url: string
voice_model?: string
language?: string
gender?: string
max_retries?: number
metadata_?: VoiceCloneMetadata
}
/* ── 辅助函数 ─────────────────────────────────────────── */
/**
* 将后端 VoiceCloneProfile 转换为前端 VoiceClone
* 后端 status "pending" 映射为前端 "processing"
*/
export const toVoiceClone = (profile: VoiceCloneProfile): VoiceClone => ({
id: profile.id,
name: profile.name,
description: profile.description || "",
duration_seconds: 0,
status: profile.status === "pending" ? "processing" : profile.status,
progress: 0,
sample_url: profile.source_audio_url || undefined,
language: profile.language || "",
gender: profile.gender || "",
error_message: profile.error_message || null,
created_at: profile.created_at,
updated_at: profile.updated_at,
})
/** 格式化时长 */
export const formatDuration = (seconds: number): string => {
const m = Math.floor(seconds / 60)
const s = seconds % 60
return `${m}:${String(s).padStart(2, "0")}`
}
/* ── 查询参数 ─────────────────────────────────────────── */
export interface VoiceCloneListParams {
status?: string
skip?: number
limit?: number
}
/* ── API 函数 ─────────────────────────────────────────── */
/** 获取克隆音色列表(返回前端兼容数组) */
export const getVoiceClones = async (params?: VoiceCloneListParams): Promise<VoiceClone[]> => {
const searchParams = new URLSearchParams()
if (params?.status) searchParams.set("status", params.status)
if (params?.skip !== undefined) searchParams.set("skip", String(params.skip))
if (params?.limit !== undefined) searchParams.set("limit", String(params.limit))
const qs = searchParams.toString()
const response = await apiClient.get<ListVoiceCloneResponse>(`/voice-clones${qs ? `?${qs}` : ""}`)
return response.data.items.map(toVoiceClone)
}
/** 获取克隆音色列表(返回完整响应含 total) */
export const getVoiceClonesWithTotal = async (
params?: VoiceCloneListParams,
): Promise<ListVoiceCloneResponse> => {
const searchParams = new URLSearchParams()
if (params?.status) searchParams.set("status", params.status)
if (params?.skip !== undefined) searchParams.set("skip", String(params.skip))
if (params?.limit !== undefined) searchParams.set("limit", String(params.limit))
const qs = searchParams.toString()
const response = await apiClient.get<ListVoiceCloneResponse>(`/voice-clones${qs ? `?${qs}` : ""}`)
return response.data
}
/** 获取单个克隆音色详情 */
export const getVoiceCloneDetail = async (id: string): Promise<VoiceCloneProfile> => {
const response = await apiClient.get<VoiceCloneProfile>(`/voice-clones/${id}`)
return response.data
}
/** 创建克隆音色 */
export const createVoiceClone = async (
data: CreateVoiceCloneRequest,
): Promise<VoiceCloneProfile> => {
const payload: CreateVoiceCloneRequestFull = {
name: data.name,
description: data.description,
source_audio_url: data.audio_url,
}
const response = await apiClient.post<VoiceCloneProfile>("/voice-clones", payload)
return response.data
}
/** 删除克隆音色 */
export const deleteVoiceClone = async (id: string): Promise<void> => {
await apiClient.delete(`/voice-clones/${id}`)
}
/** 更新克隆音色名称(stub — 后端暂无 PATCH 端点) */
export const updateVoiceClone = async (
id: string,
data: Partial<Pick<VoiceClone, "name">>,
): Promise<VoiceClone> => {
// 后端暂未提供更新端点,暂用详情接口模拟
const response = await apiClient.get<VoiceCloneProfile>(`/voice-clones/${id}`)
return toVoiceClone({
...response.data,
...data,
updated_at: new Date().toISOString(),
})
}
/** 获取克隆状态 */
export const getVoiceCloneStatus = async (id: string): Promise<VoiceCloneStatusResponse> => {
const response = await apiClient.get<VoiceCloneStatusResponse>(`/voice-clones/${id}/status`)
return response.data
}
/** 重试克隆 */
export const retryVoiceClone = async (id: string): Promise<VoiceCloneProfile> => {
const response = await apiClient.post<VoiceCloneProfile>(`/voice-clones/${id}/retry`)
return response.data
}
-87
View File
@@ -1,87 +0,0 @@
/**
* 音色克隆 API 函数
*/
import apiClient from "../client"
import { toVoiceClone } from "./utils"
import type {
VoiceClone,
VoiceCloneProfile,
CreateVoiceCloneRequest,
CreateVoiceCloneRequestFull,
VoiceCloneListParams,
ListVoiceCloneResponse,
VoiceCloneStatusResponse,
} from "./types"
/** 获取克隆音色列表(返回前端兼容数组) */
export const getVoiceClones = async (params?: VoiceCloneListParams): Promise<VoiceClone[]> => {
const searchParams = new URLSearchParams()
if (params?.status) searchParams.set("status", params.status)
if (params?.skip !== undefined) searchParams.set("skip", String(params.skip))
if (params?.limit !== undefined) searchParams.set("limit", String(params.limit))
const qs = searchParams.toString()
const response = await apiClient.get<ListVoiceCloneResponse>(`/voice-clones${qs ? `?${qs}` : ""}`)
return response.data.items.map(toVoiceClone)
}
/** 获取克隆音色列表(返回完整响应含 total) */
export const getVoiceClonesWithTotal = async (
params?: VoiceCloneListParams,
): Promise<ListVoiceCloneResponse> => {
const searchParams = new URLSearchParams()
if (params?.status) searchParams.set("status", params.status)
if (params?.skip !== undefined) searchParams.set("skip", String(params.skip))
if (params?.limit !== undefined) searchParams.set("limit", String(params.limit))
const qs = searchParams.toString()
const response = await apiClient.get<ListVoiceCloneResponse>(`/voice-clones${qs ? `?${qs}` : ""}`)
return response.data
}
/** 获取单个克隆音色详情 */
export const getVoiceCloneDetail = async (id: string): Promise<VoiceCloneProfile> => {
const response = await apiClient.get<VoiceCloneProfile>(`/voice-clones/${id}`)
return response.data
}
/** 创建克隆音色 */
export const createVoiceClone = async (
data: CreateVoiceCloneRequest,
): Promise<VoiceCloneProfile> => {
const payload: CreateVoiceCloneRequestFull = {
name: data.name,
description: data.description,
source_audio_url: data.audio_url,
}
const response = await apiClient.post<VoiceCloneProfile>("/voice-clones", payload)
return response.data
}
/** 删除克隆音色 */
export const deleteVoiceClone = async (id: string): Promise<void> => {
await apiClient.delete(`/voice-clones/${id}`)
}
/** 更新克隆音色名称(stub — 后端暂无 PATCH 端点) */
export const updateVoiceClone = async (
id: string,
data: Partial<Pick<VoiceClone, "name">>,
): Promise<VoiceClone> => {
const response = await apiClient.get<VoiceCloneProfile>(`/voice-clones/${id}`)
return toVoiceClone({
...response.data,
...data,
updated_at: new Date().toISOString(),
})
}
/** 获取克隆状态 */
export const getVoiceCloneStatus = async (id: string): Promise<VoiceCloneStatusResponse> => {
const response = await apiClient.get<VoiceCloneStatusResponse>(`/voice-clones/${id}/status`)
return response.data
}
/** 重试克隆 */
export const retryVoiceClone = async (id: string): Promise<VoiceCloneProfile> => {
const response = await apiClient.post<VoiceCloneProfile>(`/voice-clones/${id}/retry`)
return response.data
}
-32
View File
@@ -1,32 +0,0 @@
/**
* 音色克隆 API — 目录化入口
* 保持与原 voice-clone.ts 相同导出,向后兼容
*/
// 类型
export type {
VoiceCloneStatus,
VoiceClone,
CreateVoiceCloneRequest,
VoiceCloneMetadata,
VoiceCloneProfile,
ListVoiceCloneResponse,
VoiceCloneStatusResponse,
CreateVoiceCloneRequestFull,
VoiceCloneListParams,
} from "./types"
// 工具函数
export { toVoiceClone, formatDuration } from "./utils"
// API 函数
export {
getVoiceClones,
getVoiceClonesWithTotal,
getVoiceCloneDetail,
createVoiceClone,
deleteVoiceClone,
updateVoiceClone,
getVoiceCloneStatus,
retryVoiceClone,
} from "./clones"
-92
View File
@@ -1,92 +0,0 @@
/**
* 音色克隆类型定义
*/
/** 克隆音色状态(前端展示用) */
export type VoiceCloneStatus = "ready" | "processing" | "failed"
/** 克隆音色条目(前端展示用) */
export interface VoiceClone {
id: string
name: string
description: string
duration_seconds: number
status: VoiceCloneStatus
/** 克隆进度 0-100,仅 processing 状态时有值 */
progress: number
sample_url?: string
language: string
gender: string
error_message: string | null
created_at: string
updated_at: string
}
/** 创建克隆请求(前端简化版) */
export interface CreateVoiceCloneRequest {
name: string
audio_url: string
description?: string
}
/** 音色克隆元数据 */
export interface VoiceCloneMetadata {
duration?: number
sample_rate?: number
voice_id?: string
[key: string]: unknown
}
/** 后端克隆档案响应 */
export interface VoiceCloneProfile {
id: string
user_id: string
name: string
description: string
source_audio_url: string
voice_id: string | null
voice_model: string
language: string
gender: string
status: "pending" | "processing" | "ready" | "failed"
error_message: string | null
retry_count: number
max_retries: number
metadata_: VoiceCloneMetadata | null
created_at: string
updated_at: string
}
/** 后端克隆列表响应 */
export interface ListVoiceCloneResponse {
items: VoiceCloneProfile[]
total: number
}
/** 后端克隆状态响应 */
export interface VoiceCloneStatusResponse {
id: string
status: "pending" | "processing" | "ready" | "failed"
error_message: string | null
voice_id: string | null
retry_count: number
}
/** 后端创建克隆请求(完整版) */
export interface CreateVoiceCloneRequestFull {
name: string
description?: string
source_audio_url: string
voice_model?: string
language?: string
gender?: string
max_retries?: number
metadata_?: VoiceCloneMetadata
}
/** 查询参数 */
export interface VoiceCloneListParams {
status?: string
skip?: number
limit?: number
}
-30
View File
@@ -1,30 +0,0 @@
/**
* 音色克隆工具函数
*/
import type { VoiceCloneProfile, VoiceClone } from "./types"
/**
* 将后端 VoiceCloneProfile 转换为前端 VoiceClone
* 后端 status "pending" 映射为前端 "processing"
*/
export const toVoiceClone = (profile: VoiceCloneProfile): VoiceClone => ({
id: profile.id,
name: profile.name,
description: profile.description || "",
duration_seconds: 0,
status: profile.status === "pending" ? "processing" : profile.status,
progress: 0,
sample_url: profile.source_audio_url || undefined,
language: profile.language || "",
gender: profile.gender || "",
error_message: profile.error_message || null,
created_at: profile.created_at,
updated_at: profile.updated_at,
})
/** 格式化时长 */
export const formatDuration = (seconds: number): string => {
const m = Math.floor(seconds / 60)
const s = seconds % 60
return `${m}:${String(s).padStart(2, "0")}`
}
@@ -1,15 +1,68 @@
/**
* API
* API
* Phase 1
*
* 3.11 API 3.04
*/
import apiClient from "../client"
import type {
CreateVoiceRequest,
PresetVoiceListResponse,
UnifiedVoiceListParams,
UnifiedVoiceListResponse,
VoiceItem,
} from "./types"
import apiClient from "./client"
/* ── 统一音色 API(后端 3.04) ─────────────────────────── */
/** 统一音色条目(preset + clone 混合) */
export interface UnifiedVoiceItem {
id: string
type: "preset" | "clone"
name: string
description: string
gender: string
language: string
voice_id: string
voice_provider: string
audio_url: string | null
preview_url: string | null
duration: number | null
file_size: number | null
status: string
tags: string[]
user_id: string | null
project_id: string | null
voice_clone_profile_id: string | null
created_at: string | null
updated_at: string | null
}
/** 统一音色列表响应 */
export interface UnifiedVoiceListResponse {
items: UnifiedVoiceItem[]
total: number
preset_count: number
clone_count: number
}
/** 预设音色条目 */
export interface PresetVoiceItem {
voice_id: string
name: string
description: string
gender: string
language: string
preview_url: string | null
tags: string[]
}
/** 预设音色列表响应 */
export interface PresetVoiceListResponse {
items: PresetVoiceItem[]
total: number
}
/** 统一列表查询参数 */
export interface UnifiedVoiceListParams {
type?: "preset" | "clone"
status?: string
skip?: number
limit?: number
}
/** 获取统一音色列表(推荐) */
export const fetchVoices = async (
@@ -33,6 +86,28 @@ export const fetchPresetVoices = async (): Promise<PresetVoiceListResponse> => {
/* ── 向后兼容(旧接口) ────────────────────────────────── */
/** 配音条目(旧) */
export interface VoiceItem {
id: string
name: string
text: string
voice_type?: string
duration_seconds?: number
storage_key?: string
audio_url?: string
status?: string
is_favorite?: boolean
created_at?: string
updated_at?: string
}
/** 创建配音请求(旧) */
export interface CreateVoiceRequest {
name: string
text: string
voice_type?: string
}
/** 获取当前用户的所有配音(旧 → /voices/legacy */
export const getVoices = async (): Promise<VoiceItem[]> => {
const response = await apiClient.get("/voices/legacy")
-26
View File
@@ -1,26 +0,0 @@
/**
* 配音相关 API — 目录化入口
* 保持与原 voices.ts 相同导出,向后兼容
*/
// 类型
export type {
UnifiedVoiceItem,
UnifiedVoiceListResponse,
PresetVoiceItem,
PresetVoiceListResponse,
UnifiedVoiceListParams,
VoiceItem,
CreateVoiceRequest,
} from "./types"
// API 函数
export {
fetchVoices,
fetchPresetVoices,
getVoices,
createVoice,
updateVoice,
deleteVoice,
generateAIVoice,
} from "./voices"
-81
View File
@@ -1,81 +0,0 @@
/**
* 配音相关类型定义
*/
/** 统一音色条目(preset + clone 混合) */
export interface UnifiedVoiceItem {
id: string
type: "preset" | "clone"
name: string
description: string
gender: string
language: string
voice_id: string
voice_provider: string
audio_url: string | null
preview_url: string | null
duration: number | null
file_size: number | null
status: string
tags: string[]
user_id: string | null
project_id: string | null
voice_clone_profile_id: string | null
created_at: string | null
updated_at: string | null
}
/** 统一音色列表响应 */
export interface UnifiedVoiceListResponse {
items: UnifiedVoiceItem[]
total: number
preset_count: number
clone_count: number
}
/** 预设音色条目 */
export interface PresetVoiceItem {
voice_id: string
name: string
description: string
gender: string
language: string
preview_url: string | null
tags: string[]
}
/** 预设音色列表响应 */
export interface PresetVoiceListResponse {
items: PresetVoiceItem[]
total: number
}
/** 统一列表查询参数 */
export interface UnifiedVoiceListParams {
type?: "preset" | "clone"
status?: string
skip?: number
limit?: number
}
/** 配音条目(旧) */
export interface VoiceItem {
id: string
name: string
text: string
voice_type?: string
duration_seconds?: number
storage_key?: string
audio_url?: string
status?: string
is_favorite?: boolean
created_at?: string
updated_at?: string
}
/** 创建配音请求(旧) */
export interface CreateVoiceRequest {
name: string
text: string
voice_type?: string
}
@@ -1,110 +0,0 @@
import React from "react"
import type { MediaAsset } from "@/api/template-editor"
import { MATERIAL_TYPE_LABELS, MATERIAL_TYPE_ICONS } from "@/api/template-editor"
import { formatSize, formatDuration, getQualityLevel } from "./utils"
interface AssetCardProps {
asset: MediaAsset
isSelected: boolean
isDragging: boolean
isDragOver: boolean
showCheckbox: boolean
compact?: boolean
onToggleSelect: (asset: MediaAsset, shiftKey: boolean) => void
onCardClick: (asset: MediaAsset, e: React.MouseEvent) => void
onDragStart: (e: React.DragEvent) => void
onDragOver: (e: React.DragEvent) => void
onDrop: (e: React.DragEvent) => void
onDragEnd: () => void
onMouseEnter: (e: React.MouseEvent) => void
onMouseLeave: () => void
}
const AssetCard: React.FC<AssetCardProps> = ({
asset,
isSelected,
isDragging,
isDragOver,
showCheckbox,
onToggleSelect,
onCardClick,
onDragStart,
onDragOver,
onDrop,
onDragEnd,
onMouseEnter,
onMouseLeave,
}) => {
const qualityLevel = getQualityLevel(asset.quality_score)
return (
<div
className={[
"as-card",
isSelected ? "selected" : "",
isDragging ? "dragging" : "",
isDragOver ? "drag-over" : "",
showCheckbox ? "has-checkbox" : "",
]
.filter(Boolean)
.join(" ")}
draggable
onDragStart={onDragStart}
onDragOver={onDragOver}
onDrop={onDrop}
onDragEnd={onDragEnd}
onClick={(e) => onCardClick(asset, e)}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
>
{/* 缩略图 */}
<div className="as-card-thumb">
{asset.thumbnail_url ? (
<img src={asset.thumbnail_url} alt={asset.name} loading="lazy" />
) : (
<span className="as-card-thumb-icon">{MATERIAL_TYPE_ICONS[asset.type]}</span>
)}
{/* Checkbox */}
{showCheckbox && (
<span
data-checkbox
className={`as-card-checkbox${isSelected ? " checked" : ""}`}
onClick={(e) => {
e.stopPropagation()
onToggleSelect(asset, e.shiftKey)
}}
/>
)}
{/* 类型角标 */}
<span className="as-card-type-badge">{MATERIAL_TYPE_LABELS[asset.type]}</span>
{/* 时长角标 */}
{asset.duration != null && (
<span className="as-card-duration">{formatDuration(asset.duration)}</span>
)}
{/* 质量分角标 */}
{asset.quality_score != null && (
<span
className={`as-card-quality ${qualityLevel}`}
title={`质量分: ${asset.quality_score}`}
>
{asset.quality_score}
</span>
)}
</div>
{/* 信息 */}
<div className="as-card-info">
<p className="as-card-name" title={asset.name}>
{asset.name}
</p>
<div className="as-card-meta">{formatSize(asset.size)}</div>
</div>
</div>
)
}
export default AssetCard
@@ -1,99 +0,0 @@
import React from "react"
import type { MediaAsset } from "@/api/template-editor"
import { MATERIAL_TYPE_LABELS, MATERIAL_TYPE_ICONS } from "@/api/template-editor"
import { formatSize, formatDuration, getQualityColor } from "./utils"
interface AssetListItemProps {
asset: MediaAsset
isSelected: boolean
isDragging: boolean
isDragOver: boolean
showCheckbox: boolean
onToggleSelect: (asset: MediaAsset, shiftKey: boolean) => void
onCardClick: (asset: MediaAsset, e: React.MouseEvent) => void
onDragStart: (e: React.DragEvent) => void
onDragOver: (e: React.DragEvent) => void
onDrop: (e: React.DragEvent) => void
onDragEnd: () => void
onMouseEnter: (e: React.MouseEvent) => void
onMouseLeave: () => void
}
const AssetListItem: React.FC<AssetListItemProps> = ({
asset,
isSelected,
isDragging,
isDragOver,
showCheckbox,
onToggleSelect,
onCardClick,
onDragStart,
onDragOver,
onDrop,
onDragEnd,
onMouseEnter,
onMouseLeave,
}) => {
return (
<div
className={[
"as-list-item",
isSelected ? "selected" : "",
isDragging ? "dragging" : "",
isDragOver ? "drag-over" : "",
]
.filter(Boolean)
.join(" ")}
draggable
onDragStart={onDragStart}
onDragOver={onDragOver}
onDrop={onDrop}
onDragEnd={onDragEnd}
onClick={(e) => onCardClick(asset, e)}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
>
{/* 拖拽手柄 */}
<span className="as-list-item-drag" title="拖拽排序">
</span>
{/* Checkbox */}
{showCheckbox && (
<span
data-checkbox
className={`as-list-item-checkbox${isSelected ? " checked" : ""}`}
onClick={(e) => {
e.stopPropagation()
onToggleSelect(asset, e.shiftKey)
}}
/>
)}
{/* 图标 */}
<span className="as-list-item-icon">{MATERIAL_TYPE_ICONS[asset.type]}</span>
{/* 信息 */}
<div className="as-list-item-info">
<div className="as-list-item-name">{asset.name}</div>
<div className="as-list-item-meta">
{MATERIAL_TYPE_LABELS[asset.type]}
{asset.duration != null && ` · ${formatDuration(asset.duration)}`}
{asset.size != null && ` · ${formatSize(asset.size)}`}
</div>
</div>
{/* 质量分 */}
{asset.quality_score != null && (
<span
className="as-list-item-quality"
style={{ color: getQualityColor(asset.quality_score) }}
>
{asset.quality_score}
</span>
)}
</div>
)
}
export default AssetListItem
@@ -9,20 +9,73 @@
* - 筛选增强:类型筛选 + 质量分筛选
* - 视图切换:网格视图 / 列表视图
*/
import React, { useCallback } from "react"
import React, { useState, useMemo, useCallback, useRef, useEffect } from "react"
import "./AssetSelector.css"
import type { AssetSelectorProps } from "./types"
import useAssetFilter from "./hooks/useAssetFilter"
import useAssetSelection from "./hooks/useAssetSelection"
import useDragReorder from "./hooks/useDragReorder"
import useAssetPreview from "./hooks/useAssetPreview"
import SelectorToolbar from "./SelectorToolbar"
import SelectorBatchBar from "./SelectorBatchBar"
import AssetCard from "./AssetCard"
import AssetListItem from "./AssetListItem"
import PreviewOverlay from "./PreviewOverlay"
import EmptyState from "./EmptyState"
import { Input, Select, Button } from "@/components/ui"
import type { MediaAsset } from "@/api/template-editor"
import { MATERIAL_TYPE_LABELS, MATERIAL_TYPE_ICONS, QUALITY_OPTIONS } from "@/api/template-editor"
/* ──────────── 类型 ──────────── */
export interface AssetSelectorProps {
assets: MediaAsset[]
selectedIds?: string[]
onSelectionChange?: (ids: string[]) => void
onAssetDragStart?: (asset: MediaAsset) => void
onReorder?: (fromIdx: number, toIdx: number) => void
showQualityFilter?: boolean
showBatchSelect?: boolean
compact?: boolean
}
type ViewMode = "grid" | "list"
/* ──────────── 工具函数 ──────────── */
/** 格式化文件大小 */
const formatSize = (bytes?: number): string => {
if (!bytes) return ""
if (bytes < 1024) return `${bytes}B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`
}
/** 格式化时长 */
const formatDuration = (seconds?: number): string => {
if (!seconds) return ""
const m = Math.floor(seconds / 60)
const s = Math.floor(seconds % 60)
return m > 0 ? `${m}:${s.toString().padStart(2, "0")}` : `${s}s`
}
/** 获取质量分等级 */
const getQualityLevel = (score?: number): string => {
if (score == null) return "none"
if (score >= 90) return "excellent"
if (score >= 70) return "good"
if (score >= 50) return "fair"
return "poor"
}
/** 质量分颜色 */
const getQualityColor = (score?: number): string => {
if (score == null) return "var(--text-secondary)"
if (score >= 90) return "var(--success-color, #10b981)"
if (score >= 70) return "var(--primary-color, #6366f1)"
if (score >= 50) return "var(--warning-color, #f59e0b)"
return "var(--error-color, #ef4444)"
}
/* ──────────── 类型筛选选项 ──────────── */
const TYPE_OPTIONS = [
{ value: "", label: "全部类型" },
{ value: "video", label: "🎬 视频" },
{ value: "image", label: "🖼️ 图片" },
{ value: "audio", label: "🎵 音频" },
]
/* ──────────── 组件 ──────────── */
const AssetSelector: React.FC<AssetSelectorProps> = ({
assets,
@@ -34,41 +87,162 @@ const AssetSelector: React.FC<AssetSelectorProps> = ({
showBatchSelect = true,
compact = false,
}) => {
// 搜索 & 筛选
const {
searchText,
filterType,
filterQuality,
viewMode,
filteredAssets,
setSearchText,
setFilterType,
setFilterQuality,
setViewMode,
} = useAssetFilter(assets)
/* ── 搜索 & 筛选 ── */
const [searchText, setSearchText] = useState("")
const [filterType, setFilterType] = useState("")
const [filterQuality, setFilterQuality] = useState("")
const [viewMode, setViewMode] = useState<ViewMode>("grid")
// 选中操作
const { selectedSet, toggleSelect, clearSelection } = useAssetSelection({
filteredAssets,
selectedIds,
onSelectionChange,
})
/* ── 拖拽状态 ── */
const [dragIdx, setDragIdx] = useState<number | null>(null)
const [dragOverIdx, setDragOverIdx] = useState<number | null>(null)
// 拖拽排序
const { dragIdx, dragOverIdx, handleDragStart, handleDragOver, handleDrop, handleDragEnd } =
useDragReorder({
filteredAssets,
selectedIds,
onAssetDragStart,
onReorder,
})
/* ── 悬浮预览 ── */
const [previewAsset, setPreviewAsset] = useState<MediaAsset | null>(null)
const [previewPos, setPreviewPos] = useState({ x: 0, y: 0 })
const previewTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
// 悬浮预览
const { previewAsset, previewPos, handleMouseEnter, handleMouseLeave } = useAssetPreview()
/* ── Shift 连选 ── */
const lastClickedIdx = useRef<number | null>(null)
/* ── 过滤后的素材列表 ── */
const filteredAssets = useMemo(() => {
let list = assets
if (searchText) {
const q = searchText.toLowerCase()
list = list.filter(
(a) => a.name.toLowerCase().includes(q) || a.tags.some((t) => t.toLowerCase().includes(q)),
)
}
if (filterType) {
list = list.filter((a) => a.type === filterType)
}
if (filterQuality) {
const opt = QUALITY_OPTIONS.find((o) => o.value === filterQuality)
if (opt?.min != null && opt?.max != null) {
list = list.filter(
(a) =>
a.quality_score != null && a.quality_score >= opt.min! && a.quality_score <= opt.max!,
)
}
}
return list
}, [assets, searchText, filterType, filterQuality])
/* ── 选中状态 ── */
const selectedSet = useMemo(() => new Set(selectedIds), [selectedIds])
/* ── 选择操作 ── */
const toggleSelect = useCallback(
(asset: MediaAsset, idx: number, shiftKey: boolean) => {
if (!onSelectionChange) return
if (shiftKey && lastClickedIdx.current !== null) {
// Shift 连选
const start = Math.min(lastClickedIdx.current, idx)
const end = Math.max(lastClickedIdx.current, idx)
const rangeIds = filteredAssets.slice(start, end + 1).map((a) => a.id)
const newSet = new Set(selectedIds)
rangeIds.forEach((id) => newSet.add(id))
onSelectionChange(Array.from(newSet))
} else {
const newSet = new Set(selectedIds)
if (newSet.has(asset.id)) {
newSet.delete(asset.id)
} else {
newSet.add(asset.id)
}
onSelectionChange(Array.from(newSet))
}
lastClickedIdx.current = idx
},
[onSelectionChange, selectedIds, filteredAssets],
)
const clearSelection = useCallback(() => {
onSelectionChange?.([])
}, [onSelectionChange])
/* ── 拖拽排序 ── */
const handleDragStart = useCallback(
(e: React.DragEvent, idx: number) => {
setDragIdx(idx)
e.dataTransfer.effectAllowed = "move"
e.dataTransfer.setData("text/plain", String(idx))
// 设置素材数据,供 TimelinePanel 接收(P1-2 修复)
e.dataTransfer.setData("application/x-media-asset", JSON.stringify(filteredAssets[idx]))
// 批量拖拽:如果有多个选中素材,一起携带
if (selectedIds.length > 1 && selectedIds.includes(filteredAssets[idx].id)) {
const batchAssets = filteredAssets.filter((a) => selectedIds.includes(a.id))
e.dataTransfer.setData("application/x-media-assets", JSON.stringify(batchAssets))
}
// 通知父组件素材拖拽开始
if (onAssetDragStart) {
onAssetDragStart(filteredAssets[idx])
}
},
[filteredAssets, selectedIds, onAssetDragStart],
)
const handleDragOver = useCallback(
(e: React.DragEvent, idx: number) => {
e.preventDefault()
e.dataTransfer.dropEffect = "move"
if (dragIdx === null || dragIdx === idx) return
setDragOverIdx(idx)
},
[dragIdx],
)
const handleDrop = useCallback(
(e: React.DragEvent, toIdx: number) => {
e.preventDefault()
if (dragIdx !== null && dragIdx !== toIdx && onReorder) {
onReorder(dragIdx, toIdx)
}
setDragIdx(null)
setDragOverIdx(null)
},
[dragIdx, onReorder],
)
const handleDragEnd = useCallback(() => {
setDragIdx(null)
setDragOverIdx(null)
}, [])
/* ── 悬浮预览 ── */
const handleMouseEnter = useCallback((asset: MediaAsset, e: React.MouseEvent) => {
if (previewTimer.current) clearTimeout(previewTimer.current)
previewTimer.current = setTimeout(() => {
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect()
setPreviewAsset(asset)
setPreviewPos({
x: rect.right + 12,
y: Math.max(8, rect.top - 20),
})
}, 400)
}, [])
const handleMouseLeave = useCallback(() => {
if (previewTimer.current) {
clearTimeout(previewTimer.current)
previewTimer.current = null
}
setPreviewAsset(null)
}, [])
// 清理定时器
useEffect(() => {
return () => {
if (previewTimer.current) clearTimeout(previewTimer.current)
}
}, [])
/* ── 点击卡片 ── */
const handleCardClick = useCallback(
(asset: MediaAsset, idx: number, e: React.MouseEvent) => {
// 如果点击的是 checkbox 区域,不触发卡片点击
const target = e.target as HTMLElement
if (target.closest("[data-checkbox]")) return
toggleSelect(asset, idx, e.shiftKey)
@@ -76,82 +250,253 @@ const AssetSelector: React.FC<AssetSelectorProps> = ({
[toggleSelect],
)
/* ──────────── 渲染 ──────────── */
const hasSelection = selectedIds.length > 0
return (
<div className="as-container">
{/* 工具栏 */}
<SelectorToolbar
searchText={searchText}
filterType={filterType}
filterQuality={filterQuality}
viewMode={viewMode}
showQualityFilter={showQualityFilter}
onSearchChange={setSearchText}
onTypeChange={setFilterType}
onQualityChange={setFilterQuality}
onViewModeChange={setViewMode}
/>
{/* ═══ 工具栏 ═══ */}
<div className="as-toolbar">
<div className="as-toolbar-left">
<div className="as-search">
<Input
placeholder="搜索素材..."
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
prefix="🔍"
/>
</div>
<Select
value={filterType}
onChange={(v: string) => setFilterType(v)}
options={TYPE_OPTIONS}
/>
{showQualityFilter && (
<Select
value={filterQuality}
onChange={(v: string) => setFilterQuality(v)}
options={QUALITY_OPTIONS}
/>
)}
</div>
<div className="as-toolbar-right">
<button
className={`as-view-btn${viewMode === "grid" ? " active" : ""}`}
onClick={() => setViewMode("grid")}
title="网格视图"
>
</button>
<button
className={`as-view-btn${viewMode === "list" ? " active" : ""}`}
onClick={() => setViewMode("list")}
title="列表视图"
>
</button>
</div>
</div>
{/* 批量操作栏 */}
{/* ═══ 批量操作栏 ═══ */}
{showBatchSelect && hasSelection && (
<SelectorBatchBar selectedCount={selectedIds.length} onClear={clearSelection} />
<div className="as-batch-bar">
<span className="as-batch-bar-count"> {selectedIds.length} </span>
<div className="as-batch-bar-actions">
<Button buttonType="ghost" buttonSize="sm" onClick={clearSelection}>
</Button>
</div>
</div>
)}
{/* 素材列表 */}
{/* ═══ 素材列表 ═══ */}
<div className="as-body">
{filteredAssets.length === 0 ? (
<EmptyState />
<div className="as-empty">
<div className="as-empty-icon">📂</div>
<p></p>
</div>
) : viewMode === "grid" ? (
/* 网格视图 */
/* ── 网格视图 ── */
<div className={`as-grid${compact ? " compact" : ""}`}>
{filteredAssets.map((asset, idx) => (
<AssetCard
key={asset.id}
asset={asset}
isSelected={selectedSet.has(asset.id)}
isDragging={dragIdx === idx}
isDragOver={dragOverIdx === idx}
showCheckbox={showBatchSelect}
compact={compact}
onToggleSelect={(a, shiftKey) => toggleSelect(a, idx, shiftKey)}
onCardClick={(a, e) => handleCardClick(a, idx, e)}
onDragStart={(e) => handleDragStart(e, idx)}
onDragOver={(e) => handleDragOver(e, idx)}
onDrop={(e) => handleDrop(e, idx)}
onDragEnd={handleDragEnd}
onMouseEnter={(e) => handleMouseEnter(asset, e)}
onMouseLeave={handleMouseLeave}
/>
))}
{filteredAssets.map((asset, idx) => {
const isSelected = selectedSet.has(asset.id)
const isDragging = dragIdx === idx
const isDragOver = dragOverIdx === idx
const qualityLevel = getQualityLevel(asset.quality_score)
return (
<div
key={asset.id}
className={[
"as-card",
isSelected ? "selected" : "",
isDragging ? "dragging" : "",
isDragOver ? "drag-over" : "",
showBatchSelect ? "has-checkbox" : "",
]
.filter(Boolean)
.join(" ")}
draggable
onDragStart={(e) => handleDragStart(e, idx)}
onDragOver={(e) => handleDragOver(e, idx)}
onDrop={(e) => handleDrop(e, idx)}
onDragEnd={handleDragEnd}
onClick={(e) => handleCardClick(asset, idx, e)}
onMouseEnter={(e) => handleMouseEnter(asset, e)}
onMouseLeave={handleMouseLeave}
>
{/* 缩略图 */}
<div className="as-card-thumb">
{asset.thumbnail_url ? (
<img src={asset.thumbnail_url} alt={asset.name} loading="lazy" />
) : (
<span className="as-card-thumb-icon">{MATERIAL_TYPE_ICONS[asset.type]}</span>
)}
{/* Checkbox */}
{showBatchSelect && (
<span
data-checkbox
className={`as-card-checkbox${isSelected ? " checked" : ""}`}
onClick={(e) => {
e.stopPropagation()
toggleSelect(asset, idx, e.shiftKey)
}}
/>
)}
{/* 类型角标 */}
<span className="as-card-type-badge">{MATERIAL_TYPE_LABELS[asset.type]}</span>
{/* 时长角标 */}
{asset.duration != null && (
<span className="as-card-duration">{formatDuration(asset.duration)}</span>
)}
{/* 质量分角标 */}
{asset.quality_score != null && (
<span
className={`as-card-quality ${qualityLevel}`}
title={`质量分: ${asset.quality_score}`}
>
{asset.quality_score}
</span>
)}
</div>
{/* 信息 */}
<div className="as-card-info">
<p className="as-card-name" title={asset.name}>
{asset.name}
</p>
<div className="as-card-meta">{formatSize(asset.size)}</div>
</div>
</div>
)
})}
</div>
) : (
/* 列表视图 */
/* ── 列表视图 ── */
<div className="as-list">
{filteredAssets.map((asset, idx) => (
<AssetListItem
key={asset.id}
asset={asset}
isSelected={selectedSet.has(asset.id)}
isDragging={dragIdx === idx}
isDragOver={dragOverIdx === idx}
showCheckbox={showBatchSelect}
onToggleSelect={(a, shiftKey) => toggleSelect(a, idx, shiftKey)}
onCardClick={(a, e) => handleCardClick(a, idx, e)}
onDragStart={(e) => handleDragStart(e, idx)}
onDragOver={(e) => handleDragOver(e, idx)}
onDrop={(e) => handleDrop(e, idx)}
onDragEnd={handleDragEnd}
onMouseEnter={(e) => handleMouseEnter(asset, e)}
onMouseLeave={handleMouseLeave}
/>
))}
{filteredAssets.map((asset, idx) => {
const isSelected = selectedSet.has(asset.id)
const isDragging = dragIdx === idx
const isDragOver = dragOverIdx === idx
return (
<div
key={asset.id}
className={[
"as-list-item",
isSelected ? "selected" : "",
isDragging ? "dragging" : "",
isDragOver ? "drag-over" : "",
]
.filter(Boolean)
.join(" ")}
draggable
onDragStart={(e) => handleDragStart(e, idx)}
onDragOver={(e) => handleDragOver(e, idx)}
onDrop={(e) => handleDrop(e, idx)}
onDragEnd={handleDragEnd}
onClick={(e) => handleCardClick(asset, idx, e)}
onMouseEnter={(e) => handleMouseEnter(asset, e)}
onMouseLeave={handleMouseLeave}
>
{/* 拖拽手柄 */}
<span className="as-list-item-drag" title="拖拽排序">
</span>
{/* Checkbox */}
{showBatchSelect && (
<span
data-checkbox
className={`as-list-item-checkbox${isSelected ? " checked" : ""}`}
onClick={(e) => {
e.stopPropagation()
toggleSelect(asset, idx, e.shiftKey)
}}
/>
)}
{/* 图标 */}
<span className="as-list-item-icon">{MATERIAL_TYPE_ICONS[asset.type]}</span>
{/* 信息 */}
<div className="as-list-item-info">
<div className="as-list-item-name">{asset.name}</div>
<div className="as-list-item-meta">
{MATERIAL_TYPE_LABELS[asset.type]}
{asset.duration != null && ` · ${formatDuration(asset.duration)}`}
{asset.size != null && ` · ${formatSize(asset.size)}`}
</div>
</div>
{/* 质量分 */}
{asset.quality_score != null && (
<span
className="as-list-item-quality"
style={{ color: getQualityColor(asset.quality_score) }}
>
{asset.quality_score}
</span>
)}
</div>
)
})}
</div>
)}
</div>
{/* 悬浮预览 */}
{previewAsset && <PreviewOverlay asset={previewAsset} position={previewPos} />}
{/* ═══ 悬浮预览 ═══ */}
{previewAsset && (
<div className="as-preview-overlay" style={{ left: previewPos.x, top: previewPos.y }}>
<div className="as-preview-overlay-thumb">
{previewAsset.thumbnail_url ? (
<img src={previewAsset.thumbnail_url} alt={previewAsset.name} />
) : (
<span className="as-preview-overlay-thumb-icon">
{MATERIAL_TYPE_ICONS[previewAsset.type]}
</span>
)}
</div>
<p className="as-preview-overlay-name">{previewAsset.name}</p>
<div className="as-preview-overlay-meta">
<span>: {MATERIAL_TYPE_LABELS[previewAsset.type]}</span>
{previewAsset.duration != null && (
<span>: {formatDuration(previewAsset.duration)}</span>
)}
{previewAsset.size != null && <span>: {formatSize(previewAsset.size)}</span>}
{previewAsset.quality_score != null && (
<span>: {previewAsset.quality_score}</span>
)}
{previewAsset.tags.length > 0 && <span>: {previewAsset.tags.join(", ")}</span>}
</div>
</div>
)}
</div>
)
}
@@ -1,12 +0,0 @@
import React from "react"
const EmptyState: React.FC = () => {
return (
<div className="as-empty">
<div className="as-empty-icon">📂</div>
<p></p>
</div>
)
}
export default EmptyState
@@ -1,33 +0,0 @@
import React from "react"
import type { MediaAsset } from "@/api/template-editor"
import { MATERIAL_TYPE_LABELS, MATERIAL_TYPE_ICONS } from "@/api/template-editor"
import { formatSize, formatDuration } from "./utils"
interface PreviewOverlayProps {
asset: MediaAsset
position: { x: number; y: number }
}
const PreviewOverlay: React.FC<PreviewOverlayProps> = ({ asset, position }) => {
return (
<div className="as-preview-overlay" style={{ left: position.x, top: position.y }}>
<div className="as-preview-overlay-thumb">
{asset.thumbnail_url ? (
<img src={asset.thumbnail_url} alt={asset.name} />
) : (
<span className="as-preview-overlay-thumb-icon">{MATERIAL_TYPE_ICONS[asset.type]}</span>
)}
</div>
<p className="as-preview-overlay-name">{asset.name}</p>
<div className="as-preview-overlay-meta">
<span>: {MATERIAL_TYPE_LABELS[asset.type]}</span>
{asset.duration != null && <span>: {formatDuration(asset.duration)}</span>}
{asset.size != null && <span>: {formatSize(asset.size)}</span>}
{asset.quality_score != null && <span>: {asset.quality_score}</span>}
{asset.tags.length > 0 && <span>: {asset.tags.join(", ")}</span>}
</div>
</div>
)
}
export default PreviewOverlay
@@ -1,22 +0,0 @@
import React from "react"
import { Button } from "@/components/ui"
interface SelectorBatchBarProps {
selectedCount: number
onClear: () => void
}
const SelectorBatchBar: React.FC<SelectorBatchBarProps> = ({ selectedCount, onClear }) => {
return (
<div className="as-batch-bar">
<span className="as-batch-bar-count"> {selectedCount} </span>
<div className="as-batch-bar-actions">
<Button buttonType="ghost" buttonSize="sm" onClick={onClear}>
</Button>
</div>
</div>
)
}
export default SelectorBatchBar
@@ -1,74 +0,0 @@
import React from "react"
import { Input, Select } from "@/components/ui"
import { TYPE_OPTIONS } from "./constants"
import { QUALITY_OPTIONS } from "@/api/template-editor"
import type { ViewMode } from "./types"
interface SelectorToolbarProps {
searchText: string
filterType: string
filterQuality: string
viewMode: ViewMode
showQualityFilter: boolean
onSearchChange: (value: string) => void
onTypeChange: (value: string) => void
onQualityChange: (value: string) => void
onViewModeChange: (mode: ViewMode) => void
}
const SelectorToolbar: React.FC<SelectorToolbarProps> = ({
searchText,
filterType,
filterQuality,
viewMode,
showQualityFilter,
onSearchChange,
onTypeChange,
onQualityChange,
onViewModeChange,
}) => {
return (
<div className="as-toolbar">
<div className="as-toolbar-left">
<div className="as-search">
<Input
placeholder="搜索素材..."
value={searchText}
onChange={(e) => onSearchChange(e.target.value)}
prefix="🔍"
/>
</div>
<Select
value={filterType}
onChange={(v: string) => onTypeChange(v)}
options={TYPE_OPTIONS}
/>
{showQualityFilter && (
<Select
value={filterQuality}
onChange={(v: string) => onQualityChange(v)}
options={QUALITY_OPTIONS}
/>
)}
</div>
<div className="as-toolbar-right">
<button
className={`as-view-btn${viewMode === "grid" ? " active" : ""}`}
onClick={() => onViewModeChange("grid")}
title="网格视图"
>
</button>
<button
className={`as-view-btn${viewMode === "list" ? " active" : ""}`}
onClick={() => onViewModeChange("list")}
title="列表视图"
>
</button>
</div>
</div>
)
}
export default SelectorToolbar
@@ -1,7 +0,0 @@
/** 类型筛选选项 */
export const TYPE_OPTIONS = [
{ value: "", label: "全部类型" },
{ value: "video", label: "🎬 视频" },
{ value: "image", label: "🖼️ 图片" },
{ value: "audio", label: "🎵 音频" },
]
@@ -1,63 +0,0 @@
import { useState, useMemo } from "react"
import type { MediaAsset } from "@/api/template-editor"
import { QUALITY_OPTIONS } from "@/api/template-editor"
import type { ViewMode } from "../types"
interface UseAssetFilterReturn {
searchText: string
filterType: string
filterQuality: string
viewMode: ViewMode
filteredAssets: MediaAsset[]
setSearchText: (value: string) => void
setFilterType: (value: string) => void
setFilterQuality: (value: string) => void
setViewMode: (mode: ViewMode) => void
}
/**
* 素材筛选 Hook —— 搜索 + 类型 + 质量分 + 视图切换
*/
const useAssetFilter = (assets: MediaAsset[]): UseAssetFilterReturn => {
const [searchText, setSearchText] = useState("")
const [filterType, setFilterType] = useState("")
const [filterQuality, setFilterQuality] = useState("")
const [viewMode, setViewMode] = useState<ViewMode>("grid")
const filteredAssets = useMemo(() => {
let list = assets
if (searchText) {
const q = searchText.toLowerCase()
list = list.filter(
(a) => a.name.toLowerCase().includes(q) || a.tags.some((t) => t.toLowerCase().includes(q)),
)
}
if (filterType) {
list = list.filter((a) => a.type === filterType)
}
if (filterQuality) {
const opt = QUALITY_OPTIONS.find((o) => o.value === filterQuality)
if (opt?.min != null && opt?.max != null) {
list = list.filter(
(a) =>
a.quality_score != null && a.quality_score >= opt.min! && a.quality_score <= opt.max!,
)
}
}
return list
}, [assets, searchText, filterType, filterQuality])
return {
searchText,
filterType,
filterQuality,
viewMode,
filteredAssets,
setSearchText,
setFilterType,
setFilterQuality,
setViewMode,
}
}
export default useAssetFilter
@@ -1,54 +0,0 @@
import { useState, useRef, useCallback, useEffect } from "react"
import type { MediaAsset } from "@/api/template-editor"
interface UseAssetPreviewReturn {
previewAsset: MediaAsset | null
previewPos: { x: number; y: number }
handleMouseEnter: (asset: MediaAsset, e: React.MouseEvent) => void
handleMouseLeave: () => void
}
/**
* 悬浮预览 Hook —— 延迟 400ms 显示预览浮层
*/
const useAssetPreview = (): UseAssetPreviewReturn => {
const [previewAsset, setPreviewAsset] = useState<MediaAsset | null>(null)
const [previewPos, setPreviewPos] = useState({ x: 0, y: 0 })
const previewTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
const handleMouseEnter = useCallback((asset: MediaAsset, e: React.MouseEvent) => {
if (previewTimer.current) clearTimeout(previewTimer.current)
previewTimer.current = setTimeout(() => {
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect()
setPreviewAsset(asset)
setPreviewPos({
x: rect.right + 12,
y: Math.max(8, rect.top - 20),
})
}, 400)
}, [])
const handleMouseLeave = useCallback(() => {
if (previewTimer.current) {
clearTimeout(previewTimer.current)
previewTimer.current = null
}
setPreviewAsset(null)
}, [])
// 清理定时器
useEffect(() => {
return () => {
if (previewTimer.current) clearTimeout(previewTimer.current)
}
}, [])
return {
previewAsset,
previewPos,
handleMouseEnter,
handleMouseLeave,
}
}
export default useAssetPreview
@@ -1,65 +0,0 @@
import { useCallback, useRef, useMemo } from "react"
import type { MediaAsset } from "@/api/template-editor"
interface UseAssetSelectionOptions {
filteredAssets: MediaAsset[]
selectedIds: string[]
onSelectionChange?: (ids: string[]) => void
}
interface UseAssetSelectionReturn {
selectedSet: Set<string>
toggleSelect: (asset: MediaAsset, idx: number, shiftKey: boolean) => void
clearSelection: () => void
}
/**
* 素材选择 Hook —— 单选/多选/Shift 连选
*/
const useAssetSelection = ({
filteredAssets,
selectedIds,
onSelectionChange,
}: UseAssetSelectionOptions): UseAssetSelectionReturn => {
const lastClickedIdx = useRef<number | null>(null)
const selectedSet = useMemo(() => new Set(selectedIds), [selectedIds])
const toggleSelect = useCallback(
(asset: MediaAsset, idx: number, shiftKey: boolean) => {
if (!onSelectionChange) return
if (shiftKey && lastClickedIdx.current !== null) {
// Shift 连选
const start = Math.min(lastClickedIdx.current, idx)
const end = Math.max(lastClickedIdx.current, idx)
const rangeIds = filteredAssets.slice(start, end + 1).map((a) => a.id)
const newSet = new Set(selectedIds)
rangeIds.forEach((id) => newSet.add(id))
onSelectionChange(Array.from(newSet))
} else {
const newSet = new Set(selectedIds)
if (newSet.has(asset.id)) {
newSet.delete(asset.id)
} else {
newSet.add(asset.id)
}
onSelectionChange(Array.from(newSet))
}
lastClickedIdx.current = idx
},
[onSelectionChange, selectedIds, filteredAssets],
)
const clearSelection = useCallback(() => {
onSelectionChange?.([])
}, [onSelectionChange])
return {
selectedSet,
toggleSelect,
clearSelection,
}
}
export default useAssetSelection
@@ -1,89 +0,0 @@
import { useState, useCallback } from "react"
import type { MediaAsset } from "@/api/template-editor"
interface UseDragReorderOptions {
filteredAssets: MediaAsset[]
selectedIds: string[]
onAssetDragStart?: (asset: MediaAsset) => void
onReorder?: (fromIdx: number, toIdx: number) => void
}
interface UseDragReorderReturn {
dragIdx: number | null
dragOverIdx: number | null
handleDragStart: (e: React.DragEvent, idx: number) => void
handleDragOver: (e: React.DragEvent, idx: number) => void
handleDrop: (e: React.DragEvent, toIdx: number) => void
handleDragEnd: () => void
}
/**
* 拖拽排序 Hook —— HTML5 DnD,支持批量拖拽携带数据
*/
const useDragReorder = ({
filteredAssets,
selectedIds,
onAssetDragStart,
onReorder,
}: UseDragReorderOptions): UseDragReorderReturn => {
const [dragIdx, setDragIdx] = useState<number | null>(null)
const [dragOverIdx, setDragOverIdx] = useState<number | null>(null)
const handleDragStart = useCallback(
(e: React.DragEvent, idx: number) => {
setDragIdx(idx)
e.dataTransfer.effectAllowed = "move"
e.dataTransfer.setData("text/plain", String(idx))
// 设置素材数据,供 TimelinePanel 接收
e.dataTransfer.setData("application/x-media-asset", JSON.stringify(filteredAssets[idx]))
// 批量拖拽:如果有多个选中素材,一起携带
if (selectedIds.length > 1 && selectedIds.includes(filteredAssets[idx].id)) {
const batchAssets = filteredAssets.filter((a) => selectedIds.includes(a.id))
e.dataTransfer.setData("application/x-media-assets", JSON.stringify(batchAssets))
}
// 通知父组件素材拖拽开始
if (onAssetDragStart) {
onAssetDragStart(filteredAssets[idx])
}
},
[filteredAssets, selectedIds, onAssetDragStart],
)
const handleDragOver = useCallback(
(e: React.DragEvent, idx: number) => {
e.preventDefault()
e.dataTransfer.dropEffect = "move"
if (dragIdx === null || dragIdx === idx) return
setDragOverIdx(idx)
},
[dragIdx],
)
const handleDrop = useCallback(
(e: React.DragEvent, toIdx: number) => {
e.preventDefault()
if (dragIdx !== null && dragIdx !== toIdx && onReorder) {
onReorder(dragIdx, toIdx)
}
setDragIdx(null)
setDragOverIdx(null)
},
[dragIdx, onReorder],
)
const handleDragEnd = useCallback(() => {
setDragIdx(null)
setDragOverIdx(null)
}, [])
return {
dragIdx,
dragOverIdx,
handleDragStart,
handleDragOver,
handleDrop,
handleDragEnd,
}
}
export default useDragReorder
@@ -1,2 +1,2 @@
export { default as AssetSelector } from "./AssetSelector"
export type { AssetSelectorProps, ViewMode } from "./types"
export type { AssetSelectorProps } from "./AssetSelector"
@@ -1,14 +0,0 @@
import type { MediaAsset } from "@/api/template-editor"
export interface AssetSelectorProps {
assets: MediaAsset[]
selectedIds?: string[]
onSelectionChange?: (ids: string[]) => void
onAssetDragStart?: (asset: MediaAsset) => void
onReorder?: (fromIdx: number, toIdx: number) => void
showQualityFilter?: boolean
showBatchSelect?: boolean
compact?: boolean
}
export type ViewMode = "grid" | "list"
@@ -1,33 +0,0 @@
/** 格式化文件大小 */
export const formatSize = (bytes?: number): string => {
if (!bytes) return ""
if (bytes < 1024) return `${bytes}B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`
}
/** 格式化时长 */
export const formatDuration = (seconds?: number): string => {
if (!seconds) return ""
const m = Math.floor(seconds / 60)
const s = Math.floor(seconds % 60)
return m > 0 ? `${m}:${s.toString().padStart(2, "0")}` : `${s}s`
}
/** 获取质量分等级 */
export const getQualityLevel = (score?: number): string => {
if (score == null) return "none"
if (score >= 90) return "excellent"
if (score >= 70) return "good"
if (score >= 50) return "fair"
return "poor"
}
/** 质量分颜色 */
export const getQualityColor = (score?: number): string => {
if (score == null) return "var(--text-secondary)"
if (score >= 90) return "var(--success-color, #10b981)"
if (score >= 70) return "var(--primary-color, #6366f1)"
if (score >= 50) return "var(--warning-color, #f59e0b)"
return "var(--error-color, #ef4444)"
}
+567 -47
View File
@@ -10,35 +10,357 @@
*
* V21 Design System — 零 antd 直接导入
*/
import React from "react"
import { Modal } from "@/components/ui"
import type { CloneModalProps } from "./types/cloneModal"
import useCloneModal from "./hooks/useCloneModal"
import InputView from "./clone-modal/InputView"
import ProgressView from "./clone-modal/ProgressView"
import React, { useState, useCallback, useRef, useEffect } from "react"
import { Modal, Button } from "@/components/ui"
import { createVoiceClone, toVoiceClone } from "@/api/voice-clone"
import type { VoiceClone } from "@/api/voice-clone"
import { uploadAsset } from "@/api/assets"
import "./clone-modal.css"
/* ── 类型定义 ───────────────────────────────────────────── */
type ModalPhase = "input" | "uploading" | "cloning" | "done"
export interface CloneModalProps {
/** 弹窗是否可见 */
open: boolean
/** 关闭弹窗回调 */
onClose: () => void
/** 克隆成功回调(返回新创建的音色) */
onSuccess?: (voice: VoiceClone) => void
}
/* ── 进度阶段配置 ─────────────────────────────────────────── */
const PROGRESS_STEPS: { key: string; label: string; icon: string }[] = [
{ key: "uploading", label: "上传中", icon: "📤" },
{ key: "cloning", label: "克隆中", icon: "🧬" },
{ key: "done", label: "完成", icon: "✅" },
]
/* ── 常量 ───────────────────────────────────────────────── */
const ACCEPTED_EXTENSIONS = ["mp3", "wav", "m4a"]
const ACCEPTED_MIME = ".mp3,.wav,.m4a,audio/mpeg,audio/wav,audio/mp4"
const MAX_FILE_SIZE = 10 * 1024 * 1024 // 10MB
/** 最长录制时长:5 分钟(秒) */
const MAX_RECORD_SECONDS = 5 * 60
/* ── 组件 ───────────────────────────────────────────────── */
const CloneModal: React.FC<CloneModalProps> = ({ open, onClose, onSuccess }) => {
const {
phase,
voiceName,
voiceDescription,
selectedFile,
dragActive,
errorMessage,
isRecording,
recordTime,
recordedBlob,
canSubmit,
isProcessing,
setVoiceName,
setVoiceDescription,
setDragActive,
handleFileSelect,
handleRecordToggle,
handleClose,
handleSubmit,
} = useCloneModal({ open, onClose, onSuccess })
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, setIsRecording] = useState(false)
const [recordTime, setRecordTime] = useState(0)
const [recordedBlob, setRecordedBlob] = useState<Blob | null>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const recordTimerRef = useRef<ReturnType<typeof setInterval> | null>(null)
const mediaRecorderRef = useRef<MediaRecorder | null>(null)
const audioChunksRef = useRef<Blob[]>([])
/** 默认音色名称计数器(组件级 ref,避免多实例串号) */
const cloneCounterRef = useRef(1)
/** 生成下一个默认音色名称 */
const getNextDefaultName = useCallback((): string => {
const name = `我的声音 ${cloneCounterRef.current}`
cloneCounterRef.current += 1
return name
}, [])
/** 组件卸载时清理定时器和 MediaRecorder */
useEffect(() => {
return () => {
if (timerRef.current) clearTimeout(timerRef.current)
if (recordTimerRef.current) clearInterval(recordTimerRef.current)
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== "inactive") {
mediaRecorderRef.current.stop()
}
}
}, [])
/** 重置弹窗状态 */
const resetState = useCallback(() => {
setPhase("input")
setVoiceName(getNextDefaultName())
setVoiceDescription("")
setSelectedFile(null)
setDragActive(false)
setErrorMessage("")
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
}, [getNextDefaultName])
/** 关闭弹窗 */
const handleClose = useCallback(() => {
resetState()
onClose()
}, [resetState, onClose])
/** 弹窗打开时重置状态 */
useEffect(() => {
if (open) {
resetState()
}
}, [open, resetState])
/* ── 文件验证 ──────────────────────────────────────── */
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
}
/* ── 文件上传 ──────────────────────────────────────── */
const handleUploadClick = () => {
fileInputRef.current?.click()
}
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 {
setErrorMessage("")
setSelectedFile(file)
// 清除录音
setRecordedBlob(null)
setIsRecording(false)
setRecordTime(0)
}
}
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 handleDrop = (e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
setDragActive(false)
const file = e.dataTransfer.files?.[0]
if (file) {
const error = validateFile(file)
if (error) {
setErrorMessage(error)
setSelectedFile(null)
} else {
setErrorMessage("")
setSelectedFile(file)
setRecordedBlob(null)
setIsRecording(false)
setRecordTime(0)
}
}
}
/* ── 录音(真实 MediaRecorder) ───────────────────── */
const handleRecord = async () => {
if (isRecording) {
// 停止录制
setIsRecording(false)
if (recordTimerRef.current) {
clearInterval(recordTimerRef.current)
recordTimerRef.current = null
}
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== "inactive") {
mediaRecorderRef.current.stop()
}
} else {
// 开始录制
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)
// 清除上传的文件
setSelectedFile(null)
// 停止音轨
stream.getTracks().forEach((track) => track.stop())
}
mediaRecorder.start()
setIsRecording(true)
setRecordTime(0)
setRecordedBlob(null)
setErrorMessage("")
recordTimerRef.current = setInterval(() => {
setRecordTime((prev) => {
const next = prev + 1
if (next >= MAX_RECORD_SECONDS) {
// 达到 5 分钟上限,自动停止录制
setTimeout(() => {
setIsRecording(false)
if (recordTimerRef.current) {
clearInterval(recordTimerRef.current)
recordTimerRef.current = null
}
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== "inactive") {
mediaRecorderRef.current.stop()
}
setErrorMessage("已达最长录制时长(5分钟),已自动停止")
}, 0)
return MAX_RECORD_SECONDS
}
return next
})
}, 1000)
} catch {
setErrorMessage("无法访问麦克风,请检查浏览器权限设置")
}
}
}
/** 格式化录制时间 mm:ss */
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")}`
}
/* ── 表单验证 ──────────────────────────────────────── */
const hasAudio = selectedFile !== null || recordedBlob !== null
const validateForm = (): string | null => {
const name = voiceName.trim()
if (!name) {
return "请输入音色名称"
}
if (name.length < 2 || name.length > 20) {
return "音色名称需在 2-20 个字符之间"
}
if (!hasAudio) {
return "请上传音频文件或录制一段声音"
}
return null
}
/* ── 提交克隆 ──────────────────────────────────────── */
const handleSubmit = async () => {
const formError = validateForm()
if (formError) {
setErrorMessage(formError)
return
}
setErrorMessage("")
try {
// 阶段 1:上传音频
setPhase("uploading")
let fileToUpload: File
if (selectedFile) {
fileToUpload = selectedFile
} else {
// 将录音 Blob 转为 File
fileToUpload = new File([recordedBlob!], `recorded-${Date.now()}.webm`, {
type: "audio/webm",
})
}
const formData = new FormData()
formData.append("file", fileToUpload)
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))
handleClose()
}, 2000)
} catch (err) {
setPhase("input")
setErrorMessage(err instanceof Error ? err.message : "克隆失败,请重试")
}
}
/* ── 计算属性 ──────────────────────────────────────── */
const canSubmit = voiceName.trim().length >= 2 && voiceName.trim().length <= 20 && hasAudio
const isProcessing = phase === "uploading" || phase === "cloning"
/** 当前进度索引 */
const getProgressIndex = (): number => {
switch (phase) {
case "uploading":
return 0
case "cloning":
return 1
case "done":
return 2
default:
return -1
}
}
const progressIndex = getProgressIndex()
return (
<Modal
@@ -51,30 +373,228 @@ const CloneModal: React.FC<CloneModalProps> = ({ open, onClose, onSuccess }) =>
maskClosable={!isProcessing}
keyboard={!isProcessing}
>
{/* 输入阶段 */}
{/* ── 输入阶段 ──────────────────────────────────── */}
{phase === "input" && (
<InputView
voiceName={voiceName}
voiceDescription={voiceDescription}
selectedFile={selectedFile}
dragActive={dragActive}
isRecording={isRecording}
recordTime={recordTime}
recordedBlob={recordedBlob}
errorMessage={errorMessage}
canSubmit={canSubmit}
onVoiceNameChange={setVoiceName}
onVoiceDescChange={setVoiceDescription}
onDragActiveChange={setDragActive}
onFileSelect={handleFileSelect}
onRecordToggle={handleRecordToggle}
onClose={handleClose}
onSubmit={handleSubmit}
/>
<div className="xx-clonemodal-body">
{/* 步骤引导 */}
<div className="xx-clonemodal-steps">
<div className="xx-clonemodal-step xx-clonemodal-step--active">
<div className="xx-clonemodal-step-number">1</div>
<span className="xx-clonemodal-step-label">/</span>
</div>
<div className="xx-clonemodal-step-connector" />
<div className="xx-clonemodal-step">
<div className="xx-clonemodal-step-number">2</div>
<span className="xx-clonemodal-step-label"></span>
</div>
<div className="xx-clonemodal-step-connector" />
<div className="xx-clonemodal-step">
<div className="xx-clonemodal-step-number">3</div>
<span className="xx-clonemodal-step-label"></span>
</div>
</div>
{/* 音色名称 */}
<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) => setVoiceName(e.target.value)}
placeholder="输入音色名称(2-20字符)"
maxLength={20}
/>
<div className="xx-clonemodal-char-count">{voiceName.length}/20</div>
</div>
{/* 上传区域 */}
<div className="xx-clonemodal-field">
<label className="xx-clonemodal-label"></label>
<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>
</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>
<div className="xx-clonemodal-record-area">
<div className="xx-clonemodal-record-info">
<p className="xx-clonemodal-record-hint">
{isRecording
? `录制中 ${formatRecordTime(recordTime)}`
: recordedBlob
? `已录制 ${formatRecordTime(recordTime)}`
: "点击按钮开始录制(最长 5 分钟)"}
</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={handleRecord}
title={isRecording ? "停止录制" : "开始录制"}
>
{isRecording ? "⏹" : "🎙️"}
</button>
</div>
</div>
{/* 音色描述 */}
<div className="xx-clonemodal-field">
<label className="xx-clonemodal-label"></label>
<textarea
className="xx-clonemodal-textarea"
value={voiceDescription}
onChange={(e) => setVoiceDescription(e.target.value)}
placeholder="可选,描述这个音色的特点(最多100字符)"
maxLength={100}
rows={3}
/>
<div className="xx-clonemodal-char-count">{voiceDescription.length}/100</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={handleClose}>
</Button>
<Button buttonType="primary" disabled={!canSubmit} onClick={handleSubmit}>
🎤
</Button>
</div>
</div>
)}
{/* 进度 / 完成阶段 */}
{phase !== "input" && <ProgressView phase={phase} />}
{/* ── 进度阶段(上传中 / 克隆中) ──────────────── */}
{isProcessing && (
<div className="xx-clonemodal-progress-body">
{/* 步骤指示器 */}
<div className="xx-clonemodal-steps-progress">
{PROGRESS_STEPS.map((step, idx) => {
const isActive = idx === progressIndex
const isDone = idx < progressIndex
const stepClass = [
"xx-clonemodal-step-progress",
isActive ? "xx-clonemodal-step-progress--active" : "",
isDone ? "xx-clonemodal-step-progress--done" : "",
]
.filter(Boolean)
.join(" ")
return (
<React.Fragment key={step.key}>
{idx > 0 && (
<div
className={`xx-clonemodal-step-connector${isDone ? " xx-clonemodal-step-connector--done" : ""}`}
/>
)}
<div className={stepClass}>
<div className="xx-clonemodal-step-icon">{isDone ? "✓" : step.icon}</div>
<span className="xx-clonemodal-step-label">{step.label}</span>
</div>
</React.Fragment>
)
})}
</div>
{/* 当前阶段描述 */}
<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>
)}
{/* ── 完成阶段 ──────────────────────────────────── */}
{phase === "done" && (
<div className="xx-clonemodal-progress-body">
{/* 步骤指示器(全部完成) */}
<div className="xx-clonemodal-steps-progress">
{PROGRESS_STEPS.map((step, idx) => (
<React.Fragment key={step.key}>
{idx > 0 && (
<div className="xx-clonemodal-step-connector xx-clonemodal-step-connector--done" />
)}
<div className="xx-clonemodal-step-progress xx-clonemodal-step-progress--done">
<div className="xx-clonemodal-step-icon"></div>
<span className="xx-clonemodal-step-label">{step.label}</span>
</div>
</React.Fragment>
))}
</div>
<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>
</div>
)}
</Modal>
)
}
@@ -1,142 +0,0 @@
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

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