Compare commits

..

4 Commits

Author SHA1 Message Date
xiaoxia 5b42ff0e96 style: black格式化test_api_settings.py格式修复
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 15s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 51s
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 1m37s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m40s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 1m50s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 1m59s
Preview Deploy / Deploy Preview Environment (pull_request) Failing after 28s
CI/CD Pipeline / Frontend 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 / 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 / Validate - Code Quality (pull_request) Successful in 4m1s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 4m6s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m20s
AI Code Review / AI Code Review (pull_request) Successful in 3m34s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 2m5s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 5m17s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 29s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 49m52s
2026-07-24 18:54:03 +08:00
xiaoxia 38617515ee fix(ci): unit tests脚本兜底JWT_SECRET_KEY
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 40s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 55s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 1m36s
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 - Migration (alembic) (pull_request) Successful in 1m58s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m59s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 2m6s
Preview Deploy / Deploy Preview Environment (pull_request) Failing after 26s
CI/CD Pipeline / Frontend 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 / 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 / PR Build API Image (pull_request) Successful in 4m12s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m9s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 5m25s
AI Code Review / AI Code Review (pull_request) Successful in 5m50s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 5m53s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 6m4s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 3m12s
2026-07-24 17:53:20 +08:00
xiaoxia a1ba05d869 fix(ci): 补充unit/integration tests补充JWT_SECRET_KEY环境变量
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 4s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m19s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m24s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 28s
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 / PR Build Worker Image (pull_request) Successful in 39s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 4m29s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 1m51s
Preview Deploy / Deploy Preview Environment (pull_request) Failing after 23s
CI/CD Pipeline / Frontend 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
AI Code Review / AI Code Review (pull_request) Successful in 2m41s
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 / PR Build API Image (pull_request) Successful in 3m36s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m21s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 3m30s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 6m47s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 8m16s
2026-07-24 17:38:16 +08:00
xiaoxia 824222de87 perf(ci): Worker基础镜像三级缓存策略优化\n\n- L1 本地daemon缓存:DooD模式8runner共享宿主机daemon,命中即秒过\n- L2 Registry缓存:从Gitea registry拉取后重tag供Dockerfile使用\n- L3 本地构建:构建成功后推送回Registry供后续复用\n- 修复pre-build构建镜像tag与Dockerfile FROM不一致的bug\n- 普通docker build改用DOCKER_BUILDKIT=1加速\n- Worker Dockerfile层顺序优化,变化少的文件放前面\n- 移除builder阶段冗余的全量strip(base镜像已strip过)
Preview Deploy / Deploy Preview Environment (pull_request) Failing after 32s
AI Code Review / AI Code Review (pull_request) Successful in 3m36s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m40s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 15m41s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 30s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 42s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m27s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m42s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 55s
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 / 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 / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
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 / PR Build Web Image (pull_request) Successful in 1m42s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 4m12s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 3m33s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 2m44s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 5m49s
2026-07-24 16:57:52 +08:00
440 changed files with 24543 additions and 73866 deletions
-161
View File
@@ -1,161 +0,0 @@
name: ACR Cleanup
on:
schedule:
- cron: '0 19 * * *' # UTC 19:00 = 北京时间凌晨3:00
workflow_dispatch:
inputs:
pr_sha:
description: "PR commit SHA(仅清理指定PR镜像,留空则全量清理)"
required: false
default: ""
dry_run:
description: "预览模式(dry-run),不实际删除"
required: false
default: "true"
pull_request_target:
types: [closed]
branches: [develop, main]
concurrency:
group: acr-cleanup-${{ gitea.ref }}
cancel-in-progress: false
jobs:
cleanup:
name: ACR Image Cleanup
runs-on: ci-l2
timeout-minutes: 20
permissions:
contents: read
env:
ACR_REGISTRY: xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com
ACR_NAMESPACE: xiaoxiakeji
ACR_SERVICE: registry.aliyuncs.com:cn-hangzhou:china:cri-fvec8o9q4mmxrkaa
GITEA_URL: https://git.xiaoxiajianji.com
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
# ====== Cron模式:获取staging运行中镜像作为白名单 ======
- name: Get staging running images (whitelist)
id: protected_images
if: gitea.event_name != 'pull_request_target' && !gitea.event.inputs.pr_sha
env:
STAGING_SSH_KEY: ${{ secrets.PREVIEW_SSH_KEY }}
run: |
set +e
echo "获取staging服务器运行中镜像作为白名单..."
mkdir -p ~/.ssh
echo "$STAGING_SSH_KEY" > ~/.ssh/id_rsa
chmod 600 ~/.ssh/id_rsa
staging_host="${STAGING_SSH_HOST:-47.98.113.167}"
staging_port="${STAGING_SSH_PORT:-22222}"
ssh-keyscan -p "$staging_port" -H "$staging_host" >> ~/.ssh/known_hosts 2>/dev/null
# 获取所有运行容器的镜像,提取tag部分
IMAGES=$(ssh -p "$staging_port" -i ~/.ssh/id_rsa -o StrictHostKeyChecking=no \
"root@$staging_host" "docker ps --format '{{.Image}}' 2>/dev/null" 2>/dev/null | grep -v "^$" | sort -u)
PROTECTED_TAGS=""
if [ -n "$IMAGES" ]; then
while IFS= read -r img; do
# 从完整镜像名中提取tag(最后一个冒号后)
tag=$(echo "$img" | rev | cut -d: -f1 | rev)
if [ -n "$tag" ] && [ "$tag" != "latest" ] && [ ${#tag} -gt 5 ]; then
if [ -z "$PROTECTED_TAGS" ]; then
PROTECTED_TAGS="$tag"
else
PROTECTED_TAGS="$PROTECTED_TAGS,$tag"
fi
fi
done <<< "$IMAGES"
fi
echo "staging运行中镜像tag: ${PROTECTED_TAGS:-(无)}"
echo "protected_tags=$PROTECTED_TAGS" >> $GITEA_OUTPUT
# ====== Docker登录 ======
- name: Docker login to ACR
env:
ACR_USERNAME: ${{ secrets.ACR_USERNAME }}
ACR_PASSWORD: ${{ secrets.ACR_PASSWORD }}
run: |
printf '%s' "$ACR_PASSWORD" | docker login "$ACR_REGISTRY" -u "$ACR_USERNAME" --password-stdin
# ====== 模式1PR关闭时清理 ======
- name: Cleanup PR images (PR closed)
if: gitea.event_name == 'pull_request_target'
env:
ACR_USERNAME: ${{ secrets.ACR_USERNAME }}
ACR_PASSWORD: ${{ secrets.ACR_PASSWORD }}
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
PR_SHA: ${{ gitea.event.pull_request.head.sha }}
PR_NUMBER: ${{ gitea.event.pull_request.number }}
run: |
echo "============================================"
echo " PR #$PR_NUMBER 已关闭,清理对应镜像"
echo " Head SHA: ${PR_SHA::12}"
echo "============================================"
echo ""
python3 scripts/ci/acr_cleanup.py \
--pr-sha "$PR_SHA" \
--execute
# ====== 模式2Cron全量清理 ======
- name: Full cleanup (cron / manual)
if: gitea.event_name != 'pull_request_target' && !gitea.event.inputs.pr_sha
env:
ACR_USERNAME: ${{ secrets.ACR_USERNAME }}
ACR_PASSWORD: ${{ secrets.ACR_PASSWORD }}
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
PROTECTED_TAGS: ${{ steps.protected_images.outputs.protected_tags }}
DRY_RUN_INPUT: ${{ gitea.event.inputs.dry_run }}
run: |
echo "============================================"
echo " ACR 全量清理(${{ gitea.event_name }}"
echo "============================================"
echo ""
# 决定是否dry-run
DRY_RUN_FLAG=""
if [ "$DRY_RUN_INPUT" = "true" ]; then
DRY_RUN_FLAG="--dry-run"
echo "模式: 预览模式 (dry-run)"
else
echo "模式: 执行模式"
fi
echo ""
python3 scripts/ci/acr_cleanup.py \
--keep 20 \
--protected-tags "$PROTECTED_TAGS" \
$DRY_RUN_FLAG
# ====== 模式3:手动指定PR SHA清理 ======
- name: Cleanup specific PR image (manual)
if: gitea.event_name == 'workflow_dispatch' && gitea.event.inputs.pr_sha
env:
ACR_USERNAME: ${{ secrets.ACR_USERNAME }}
ACR_PASSWORD: ${{ secrets.ACR_PASSWORD }}
PR_SHA: ${{ gitea.event.inputs.pr_sha }}
DRY_RUN_INPUT: ${{ gitea.event.inputs.dry_run }}
run: |
echo "手动清理PR镜像: ${PR_SHA::12}"
echo ""
DRY_RUN_FLAG=""
if [ "$DRY_RUN_INPUT" = "true" ]; then
DRY_RUN_FLAG="--dry-run"
fi
python3 scripts/ci/acr_cleanup.py \
--pr-sha "$PR_SHA" \
$DRY_RUN_FLAG
+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
+17 -139
View File
@@ -26,7 +26,7 @@ concurrency:
cancel-in-progress: ${{ gitea.event_name == 'pull_request' }}
env:
CI_PG_HOST: host.docker.internal
CI_LOCAL_PG_PORT: "5432"
CI_PG_PORT: "5432"
CI_PG_USER: postgres
CI_PG_PASSWORD: postgres
CI_PG_DB: xiaoxia_saas
@@ -58,56 +58,24 @@ jobs:
run: |
set -eu
PR_NUMBER=$(echo "$GITHUB_REF" | sed 's|refs/pull/||; s|/.*||')
# 分页获取所有变更文件(修复>300文件时漏判)
ALL_FILES=""
PAGE=1
while true; do
API_URL="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/files?limit=300&page=${PAGE}"
PAGE_FILES=$(curl -s -H "Authorization: token ${GITHUB_TOKEN}" "$API_URL" | python3 -c "import sys,json; files=json.load(sys.stdin); [print(f['filename']) for f in files]; sys.exit(0 if len(files)==300 else 1)" 2>/dev/null || true)
PAGE_COUNT=$(echo "$PAGE_FILES" | wc -l)
if [ "$PAGE_COUNT" -lt 300 ]; then HAS_MORE=1; else HAS_MORE=0; fi
ALL_FILES="${ALL_FILES}${PAGE_FILES}"$'\n'
if [ "$HAS_MORE" != "0" ]; then
break
fi
PAGE=$((PAGE + 1))
done
FRONTEND_COUNT=$(echo "$ALL_FILES" | grep -c '^apps/web/' || true)
TOTAL=$(echo "$ALL_FILES" | grep -cv '^$' || true)
BACKEND_COUNT=$(( TOTAL - FRONTEND_COUNT ))
# 基础设施文件:改了就强制全量CI(不跳过任何检查)
INFRA_COUNT=$(echo "$ALL_FILES" | grep -cE '^(infra/|Dockerfile|\.gitea/workflows/|scripts/ci/|docker/)' || true)
echo "变更文件: ${TOTAL} 个 (前端: ${FRONTEND_COUNT}, 后端/公共: ${BACKEND_COUNT}, 基础设施: ${INFRA_COUNT})"
# 判定是否纯前端/纯后端
PURE_FRONTEND=false
PURE_BACKEND=false
if [ "$BACKEND_COUNT" = "0" ] && [ "$FRONTEND_COUNT" -gt "0" ] && [ "$INFRA_COUNT" = "0" ]; then
PURE_FRONTEND=true
elif [ "$FRONTEND_COUNT" = "0" ] && [ "$BACKEND_COUNT" -gt "0" ] && [ "$INFRA_COUNT" = "0" ]; then
PURE_BACKEND=true
fi
if [ "$PURE_FRONTEND" = "true" ]; then
API_URL="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/files?limit=300"
FILES=$(curl -s -H "Authorization: token ${GITHUB_TOKEN}" "$API_URL" | python3 -c "import sys,json; [print(f['filename']) for f in json.load(sys.stdin)]")
FRONTEND_COUNT=$(echo "$FILES" | grep -c '^apps/web/' || true)
BACKEND_COUNT=$(echo "$FILES" | grep -cv '^apps/web/' || true)
TOTAL=$(echo "$FILES" | grep -cv '^$' || true)
echo "变更文件: ${TOTAL} 个 (前端: ${FRONTEND_COUNT}, 后端/公共: ${BACKEND_COUNT})"
if [ "$BACKEND_COUNT" = "0" ] && [ "$FRONTEND_COUNT" -gt "0" ]; then
echo "skip_backend=true" >> $GITHUB_OUTPUT
echo "skip_frontend=false" >> $GITHUB_OUTPUT
echo "✅ 纯前端改动,跳过后端检查"
elif [ "$PURE_BACKEND" = "true" ]; then
elif [ "$FRONTEND_COUNT" = "0" ] && [ "$BACKEND_COUNT" -gt "0" ]; then
echo "skip_backend=false" >> $GITHUB_OUTPUT
echo "skip_frontend=true" >> $GITHUB_OUTPUT
echo "🔧 纯后端改动,跳过前端检查"
else
echo "skip_backend=false" >> $GITHUB_OUTPUT
echo "skip_frontend=false" >> $GITHUB_OUTPUT
if [ "$INFRA_COUNT" -gt "0" ]; then
echo "🏗️ 包含基础设施变更,强制运行完整CI"
else
echo "🔧 包含全栈变更,运行完整CI"
fi
echo "🔧 包含全栈变更,运行完整CI"
fi
- name: Report CI trace
@@ -294,7 +262,7 @@ jobs:
permissions:
contents: read
env:
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 }}
DATABASE_URL: postgresql+psycopg://${{ env.CI_PG_USER }}:${{ env.CI_PG_PASSWORD }}@${{ env.CI_PG_HOST }}:${{ env.CI_PG_PORT }}/${{ env.CI_PG_DB }}
USE_IN_MEMORY_DB: 'false'
CI_USE_SHARED_PG: 'true'
steps:
@@ -443,7 +411,7 @@ jobs:
- validate-type-check
- validate-migration
env:
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 }}
DATABASE_URL: postgresql+psycopg://${{ env.CI_PG_USER }}:${{ env.CI_PG_PASSWORD }}@${{ env.CI_PG_HOST }}:${{ env.CI_PG_PORT }}/${{ env.CI_PG_DB }}
USE_IN_MEMORY_DB: 'false'
CI_USE_SHARED_PG: 'true'
OSS_ACCESS_KEY_ID: placeholder
@@ -1262,7 +1230,7 @@ jobs:
runs-on: runtime-builder
timeout-minutes: ${{ matrix.timeout }}
needs:
if: startsWith(github.ref, 'refs/tags/v') || (github.event_name == 'push' && github.ref_name == 'main')
if: startsWith(github.ref, 'refs/tags/v')
strategy:
fail-fast: false
matrix:
@@ -1339,16 +1307,10 @@ jobs:
run: |
set -eu
REGISTRY="xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com/xiaoxiakeji"
# 根据ref类型设置镜像标签:tag用版本号,分支用分支名+sha
if [[ "$GITHUB_REF" == refs/tags/* ]]; then
TAG_NAME="${GITHUB_REF_NAME}"
else
TAG_NAME="${GITHUB_REF_NAME}-${GITHUB_SHA::8}"
fi
IMAGE_TAG="${REGISTRY}/${{ matrix.image_name }}:${TAG_NAME}"
IMAGE_TAG="${REGISTRY}/${{ matrix.image_name }}:${GITHUB_REF_NAME}"
CACHE_REF="${REGISTRY}/${{ matrix.cache_name }}:main"
EXTRA_BUILD_ARGS="APP_VERSION=\"${TAG_NAME}\""
EXTRA_BUILD_ARGS="APP_VERSION=\"${GITHUB_REF_NAME}\""
if [ "${{ matrix.service }}" = "web" ]; then
EXTRA_BUILD_ARGS="$EXTRA_BUILD_ARGS NGINX_CONF=infra/docker/nginx-production.conf"
fi
@@ -1620,6 +1582,7 @@ jobs:
set -eu
python3 scripts/ci/acr_cleanup.py \
--keep 20 \
--pr-days 7 \
--execute
- name: Job duration summary
@@ -1645,89 +1608,4 @@ jobs:
[ ${{ job.status }} = "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
canary-release:
name: Canary Release to Production
runs-on: runtime-builder
timeout-minutes: 120
concurrency:
group: canary-release-production
cancel-in-progress: false
if: github.event_name == 'push' && github.ref_name == 'main'
needs:
- build-production
- staging-api-tests
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: Record job start time
shell: sh
run: bash scripts/ci/step_timer_start.sh
- name: Notify canary release start
continue-on-error: true
shell: sh
env:
CI_NOTIFY_WEBHOOK: ${{ secrets.CI_NOTIFY_WEBHOOK }}
run: |
set +e
NOTIFY_MODE=start JOB_NAME="Canary Release" python3 scripts/ci_notify.py
- name: Install SSH client
shell: sh
run: |
set -eu
apt-get update -qq && apt-get install -y -qq openssh-client curl >/dev/null 2>&1
echo "openssh-client installed"
- name: Run canary release
shell: bash
env:
PRODUCTION_SSH_HOST: ${{ secrets.PRODUCTION_SSH_HOST }}
PRODUCTION_SSH_USER: ${{ secrets.PRODUCTION_SSH_USER }}
PRODUCTION_SSH_PORT: ${{ secrets.PRODUCTION_SSH_PORT }}
PRODUCTION_SSH_KEY: ${{ secrets.PRODUCTION_SSH_KEY }}
ACR_USERNAME: ${{ secrets.ACR_USERNAME }}
ACR_PASSWORD: ${{ secrets.ACR_PASSWORD }}
CI_NOTIFY_WEBHOOK: ${{ secrets.CI_NOTIFY_WEBHOOK }}
run: |
set -eu
IMAGE_TAG="main-${GITHUB_SHA::8}"
export IMAGE_TAG
echo "Canary release version: $IMAGE_TAG"
bash scripts/ci/canary_release.sh
- name: Job duration summary
if: always()
shell: sh
run: bash scripts/ci/step_timer_end.sh
- name: Notify on success
continue-on-error: true
if: success()
shell: sh
env:
CI_NOTIFY_WEBHOOK: ${{ secrets.CI_NOTIFY_WEBHOOK }}
run: |
set +e
NOTIFY_MODE=success JOB_NAME="Canary Release" python3 scripts/ci_notify.py
- name: Notify on failure
continue-on-error: true
if: failure()
shell: sh
env:
CI_NOTIFY_WEBHOOK: ${{ secrets.CI_NOTIFY_WEBHOOK }}
run: |
set +e
NOTIFY_MODE=failure JOB_NAME="Canary Release" python3 scripts/ci_notify.py
- name: Report CI trace
if: always()
shell: sh
env:
AGENTLOOP_LICENSE_KEY: ${{ secrets.AGENTLOOP_LICENSE_KEY }}
run: |
STATUS="ok"
[ ${{ job.status }} = "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
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:
+8 -6
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: |
+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 --include=dev; then
echo "npm ci failed, cleaning node_modules and retrying..."
rm -rf node_modules
mkdir -p node_modules
npm ci --include=dev
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
@@ -1,64 +0,0 @@
"""#642 - 生成任务新增 bgm_config 字段
Revision ID: 052_generation_task_bgm_config
Revises: 051_generation_task_resolution
Create Date: 2026-07-25
Changes:
1. generation_tasks 表新增 bgm_config 字段(JSON类型),存储用户自定义BGM配置
2. 为空时使用默认空字典
背景:
#642 一键生成支持自定义BGM 功能在 SQLAlchemy 模型中加了 bgm_config 字段,
但遗漏了 alembic migration,导致 staging 环境数据库没有该列,
创建生成任务时直接 500。
"""
import sqlalchemy as sa
from alembic import context, op
revision = "052_generation_task_bgm_config"
down_revision = "051_generation_task_resolution"
branch_labels = None
depends_on = None
def upgrade() -> None:
conn = op.get_bind()
if context.get_context().dialect.name == "postgresql":
# 检查列是否已存在(幂等)
result = conn.execute(
sa.text(
"SELECT column_name FROM information_schema.columns "
"WHERE table_name = 'generation_tasks' AND column_name = 'bgm_config'"
)
)
if result.scalar() is not None:
return
op.add_column(
"generation_tasks",
sa.Column(
"bgm_config",
sa.JSON,
nullable=False,
server_default=sa.text("'{}'::json"),
),
)
def downgrade() -> None:
conn = op.get_bind()
if context.get_context().dialect.name == "postgresql":
# 检查列是否存在(幂等)
result = conn.execute(
sa.text(
"SELECT column_name FROM information_schema.columns "
"WHERE table_name = 'generation_tasks' AND column_name = 'bgm_config'"
)
)
if result.scalar() is None:
return
op.drop_column("generation_tasks", "bgm_config")
+199 -12
View File
@@ -14,12 +14,10 @@ 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__)
@@ -66,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(
@@ -167,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)
-6117
View File
File diff suppressed because it is too large Load Diff
+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"
}
File diff suppressed because it is too large Load Diff
@@ -1,128 +0,0 @@
import React from "react"
import {
PlayCircleOutlined,
CheckOutlined,
DeleteOutlined,
ExperimentOutlined,
LoadingOutlined,
CloseCircleOutlined,
} from "@ant-design/icons"
import { Popconfirm } from "antd"
import type { AssetItem } from "@/pages/assets/types"
import { thumbGradient } from "@/pages/assets/utils/asset"
import { kindIcon } from "@/pages/assets/utils/kindIcon"
import { StatusPill } from "./AssetSkeleton"
/* ============================================================
* AssetCard — 素材卡片(网格视图)
* ============================================================ */
export interface AssetCardProps {
asset: AssetItem
selected: boolean
diagnosing?: boolean
onToggle: () => void
onDiagnose: () => void
onPlay: () => void
onDelete: () => void
}
const AssetCard: React.FC<AssetCardProps> = ({
asset,
selected,
diagnosing,
onToggle,
onDiagnose,
onPlay,
onDelete,
}) => (
<div className={`xx-asset-card${selected ? " xx-asset-card-selected" : ""}`} onClick={onToggle}>
{/* 缩略图区 */}
<div className="xx-asset-thumb" style={{ background: thumbGradient(asset.kind) }}>
{asset.thumbUrl ? (
<img src={asset.thumbUrl} alt={asset.name} />
) : (
<span className="xx-asset-thumb-placeholder">
{asset.loading ? <LoadingOutlined /> : kindIcon(asset.kind)}
</span>
)}
{/* 处理中遮罩 */}
{asset.loading && (
<div className="xx-asset-thumb-overlay xx-asset-thumb-processing">
<LoadingOutlined />
<span></span>
</div>
)}
{/* 失败状态标识 */}
{asset.status === "bad" && asset.statusLabel === "处理失败" && (
<div className="xx-asset-thumb-overlay xx-asset-thumb-failed">
<CloseCircleOutlined />
<span></span>
</div>
)}
{/* 视频/配音类显示播放按钮(处理中/失败不显示) */}
{asset.kind === "video" && !asset.loading && asset.status !== "bad" && (
<span
className="xx-asset-play"
onClick={(e) => {
e.stopPropagation()
onPlay()
}}
>
<PlayCircleOutlined />
</span>
)}
{/* 删除按钮 */}
<Popconfirm
title="确认删除"
description="删除后不可恢复,确定要删除这个素材吗?"
onConfirm={(e) => {
e?.stopPropagation()
onDelete()
}}
onCancel={(e) => e?.stopPropagation()}
okText="删除"
cancelText="取消"
okButtonProps={{ danger: true }}
>
<span className="xx-asset-delete" onClick={(e) => e.stopPropagation()}>
<DeleteOutlined />
</span>
</Popconfirm>
{/* 选中态勾选 */}
{selected && (
<span className="xx-asset-check">
<CheckOutlined />
</span>
)}
</div>
{/* 信息区 */}
<div className="xx-asset-info">
<p className="xx-asset-name" title={asset.name}>
{asset.name}
</p>
<div className="xx-asset-meta">
<StatusPill status={asset.status} label={asset.statusLabel} />
{asset.duration && <span>{asset.duration}</span>}
</div>
<button
className={`xx-asset-diagnose-btn${diagnosing ? " xx-asset-diagnose-btn-loading" : ""}`}
disabled={diagnosing || asset.loading || asset.status === "bad"}
onClick={(e) => {
e.stopPropagation()
onDiagnose()
}}
>
{diagnosing ? <LoadingOutlined /> : <ExperimentOutlined />}
{diagnosing ? "诊断中..." : "诊断"}
</button>
</div>
</div>
)
export default AssetCard
@@ -1,62 +0,0 @@
import React from "react"
import { SearchOutlined } from "@ant-design/icons"
import { Button, Input, Select } from "@/components/ui"
import { TYPE_FILTER_OPTIONS, TIME_FILTER_OPTIONS } from "@/pages/assets/constants"
/* ============================================================
* AssetFilterBar — 筛选栏(搜索/类型/时间 + 全选/计数)
* ============================================================ */
export interface AssetFilterBarProps {
searchText: string
onSearchChange: (value: string) => void
filterType: string
onFilterTypeChange: (value: string) => void
filterTime: string
onFilterTimeChange: (value: string) => void
onSelectAll: () => void
totalCount: number
}
const AssetFilterBar: React.FC<AssetFilterBarProps> = ({
searchText,
onSearchChange,
filterType,
onFilterTypeChange,
filterTime,
onFilterTimeChange,
onSelectAll,
totalCount,
}) => (
<div className="xx-assets-filters">
<div className="xx-assets-filters-left">
<Input
placeholder="搜索素材名称..."
prefix={<SearchOutlined />}
value={searchText}
onChange={(e) => onSearchChange(e.target.value)}
allowClear
style={{ width: 220 }}
/>
<Select
value={filterType}
onChange={onFilterTypeChange}
style={{ width: 120 }}
options={TYPE_FILTER_OPTIONS}
/>
<Select
value={filterTime}
onChange={onFilterTimeChange}
style={{ width: 120 }}
options={TIME_FILTER_OPTIONS}
/>
</div>
<div className="xx-assets-filters-right">
<Button buttonType="ghost" buttonSize="sm" onClick={onSelectAll}>
</Button>
<span className="xx-assets-filter-count"> {totalCount} </span>
</div>
</div>
)
export default AssetFilterBar
@@ -1,36 +0,0 @@
import React from "react"
import type { StatusType } from "@/pages/assets/types"
/* ============================================================
* StatusPill — 状态标签
* ============================================================ */
export interface StatusPillProps {
status: StatusType
label: string
}
export const StatusPill: React.FC<StatusPillProps> = ({ status, label }) => (
<span className={`xx-status-pill xx-status-pill-${status}`}>{label}</span>
)
/* ============================================================
* SkeletonCard — 骨架屏卡片(素材列表加载时占位)
* ============================================================ */
export const SkeletonCard: React.FC = () => (
<div className="xx-asset-card xx-asset-skeleton">
<div className="xx-asset-thumb xx-skeleton-pulse" />
<div className="xx-asset-info">
<div className="xx-skeleton-line xx-skeleton-pulse" style={{ width: "70%" }} />
<div className="xx-skeleton-line xx-skeleton-pulse" style={{ width: "40%", marginTop: 8 }} />
<div
className="xx-skeleton-line xx-skeleton-pulse"
style={{
width: "100%",
height: 28,
marginTop: 8,
borderRadius: "var(--radius-xs)",
}}
/>
</div>
</div>
)
@@ -1,49 +0,0 @@
import React from "react"
import { Modal as AntModal, Select as AntSelect } from "antd"
import { CATEGORY_OPTIONS } from "@/pages/assets/constants"
/* ============================================================
* BatchClassifyModal — 批量改分类弹窗
* ============================================================ */
export interface BatchClassifyModalProps {
open: boolean
selectedCount: number
onCancel: () => void
onOk: () => void
category: string
onCategoryChange: (value: string) => void
confirmLoading?: boolean
}
const BatchClassifyModal: React.FC<BatchClassifyModalProps> = ({
open,
selectedCount,
onCancel,
onOk,
category,
onCategoryChange,
confirmLoading,
}) => (
<AntModal
title={`批量改分类(${selectedCount} 个素材)`}
open={open}
onCancel={onCancel}
onOk={onOk}
confirmLoading={confirmLoading}
okText="确认修改"
cancelText="取消"
>
<div className="xx-batch-classify-modal">
<p className="xx-batch-classify-hint"> {selectedCount} </p>
<AntSelect
value={category || undefined}
onChange={(v) => onCategoryChange(v)}
placeholder="请选择分类"
style={{ width: "100%" }}
options={CATEGORY_OPTIONS}
/>
</div>
</AntModal>
)
export default BatchClassifyModal
@@ -1,67 +0,0 @@
import React from "react"
import { Modal as AntModal, Tag, Radio } from "antd"
/* ============================================================
* BatchMarkModal — 批量智能标记弹窗
* ============================================================ */
export type SmartViewType = "recommended" | "caution" | "high_risk"
export interface BatchMarkModalProps {
open: boolean
selectedCount: number
onCancel: () => void
onOk: () => void
smartView: SmartViewType
onSmartViewChange: (value: SmartViewType) => void
confirmLoading?: boolean
}
const BatchMarkModal: React.FC<BatchMarkModalProps> = ({
open,
selectedCount,
onCancel,
onOk,
smartView,
onSmartViewChange,
confirmLoading,
}) => (
<AntModal
title={`批量智能标记(${selectedCount} 个素材)`}
open={open}
onCancel={onCancel}
onOk={onOk}
confirmLoading={confirmLoading}
okText="确认标记"
cancelText="取消"
>
<div className="xx-batch-mark-modal">
<p className="xx-batch-mark-hint"> {selectedCount} </p>
<Radio.Group
value={smartView}
onChange={(e) => onSmartViewChange(e.target.value)}
className="xx-batch-mark-options"
>
<div className="xx-batch-mark-option">
<Radio value="recommended">
<Tag color="success"></Tag>
<span className="xx-batch-mark-desc"></span>
</Radio>
</div>
<div className="xx-batch-mark-option">
<Radio value="caution">
<Tag color="warning"></Tag>
<span className="xx-batch-mark-desc">使</span>
</Radio>
</div>
<div className="xx-batch-mark-option">
<Radio value="high_risk">
<Tag color="error"></Tag>
<span className="xx-batch-mark-desc">使</span>
</Radio>
</div>
</Radio.Group>
</div>
</AntModal>
)
export default BatchMarkModal
@@ -1,58 +0,0 @@
import React from "react"
import {
TagsOutlined,
FolderOutlined,
ThunderboltOutlined,
DeleteOutlined,
} from "@ant-design/icons"
import { Popconfirm } from "antd"
import { Button } from "@/components/ui"
/* ============================================================
* BatchOperationBar — 批量操作栏
* ============================================================ */
export interface BatchOperationBarProps {
selectedCount: number
onDeselectAll: () => void
onTagClick: () => void
onClassifyClick: () => void
onMarkClick: () => void
onBatchDelete: () => void
}
const BatchOperationBar: React.FC<BatchOperationBarProps> = ({
selectedCount,
onDeselectAll,
onTagClick,
onClassifyClick,
onMarkClick,
onBatchDelete,
}) => (
<div className="xx-assets-batch-bar">
<span className="xx-assets-batch-count"> {selectedCount} </span>
<Button buttonType="ghost" buttonSize="sm" onClick={onDeselectAll}>
</Button>
<Button buttonType="ghost" buttonSize="sm" icon={<TagsOutlined />} onClick={onTagClick}>
</Button>
<Button buttonType="ghost" buttonSize="sm" icon={<FolderOutlined />} onClick={onClassifyClick}>
</Button>
<Button buttonType="ghost" buttonSize="sm" icon={<ThunderboltOutlined />} onClick={onMarkClick}>
</Button>
<Popconfirm
title={`确定删除 ${selectedCount} 个素材?`}
onConfirm={onBatchDelete}
okText="删除"
cancelText="取消"
>
<Button buttonType="danger" buttonSize="sm" icon={<DeleteOutlined />}>
</Button>
</Popconfirm>
</div>
)
export default BatchOperationBar
@@ -1,81 +0,0 @@
import React from "react"
import { Modal as AntModal, Tag, Radio, Input as AntInput } from "antd"
import { ExclamationCircleOutlined } from "@ant-design/icons"
/* ============================================================
* BatchTagModal — 批量打标签弹窗
* ============================================================ */
export interface BatchTagModalProps {
open: boolean
selectedCount: number
onCancel: () => void
onOk: () => void
tags: string[]
onTagInputChange: (value: string) => void
onTagInputKeyDown: (e: React.KeyboardEvent) => void
onRemoveTag: (tag: string) => void
tagInput: string
tagMode: "add" | "replace"
onTagModeChange: (mode: "add" | "replace") => void
confirmLoading?: boolean
}
const BatchTagModal: React.FC<BatchTagModalProps> = ({
open,
selectedCount,
onCancel,
onOk,
tags,
onTagInputChange,
onTagInputKeyDown,
onRemoveTag,
tagInput,
tagMode,
onTagModeChange,
confirmLoading,
}) => (
<AntModal
title={`批量打标签(${selectedCount} 个素材)`}
open={open}
onCancel={onCancel}
onOk={onOk}
confirmLoading={confirmLoading}
okText="确认打标签"
cancelText="取消"
>
<div className="xx-batch-tag-modal">
<div className="xx-batch-tag-mode">
<span className="xx-batch-tag-mode-label"></span>
<Radio.Group value={tagMode} onChange={(e) => onTagModeChange(e.target.value)}>
<Radio value="add"></Radio>
<Radio value="replace"></Radio>
</Radio.Group>
</div>
<div className="xx-batch-tag-input-row">
<AntInput
placeholder="输入标签后按 Enter 添加"
value={tagInput}
onChange={(e) => onTagInputChange(e.target.value)}
onKeyDown={onTagInputKeyDown}
style={{ flex: 1 }}
/>
</div>
{tags.length > 0 && (
<div className="xx-batch-tag-list">
{tags.map((tag) => (
<Tag key={tag} closable onClose={() => onRemoveTag(tag)} color="blue">
{tag}
</Tag>
))}
</div>
)}
{tagMode === "replace" && tags.length > 0 && (
<div className="xx-batch-tag-warning">
<ExclamationCircleOutlined />
</div>
)}
</div>
</AntModal>
)
export default BatchTagModal
@@ -1,64 +0,0 @@
import React from "react"
import { Modal as AntModal } from "antd"
import { Input, Select } from "@/components/ui"
import type { AssetKind } from "@/pages/assets/types"
import { LIBRARY_KIND_OPTIONS } from "@/pages/assets/constants"
/* ============================================================
* CreateLibraryModal — 新建视频库弹窗
* ============================================================ */
export interface CreateLibraryModalProps {
open: boolean
onCancel: () => void
onOk: () => void
name: string
onNameChange: (value: string) => void
kind: AssetKind
onKindChange: (value: AssetKind) => void
confirmLoading?: boolean
}
const CreateLibraryModal: React.FC<CreateLibraryModalProps> = ({
open,
onCancel,
onOk,
name,
onNameChange,
kind,
onKindChange,
confirmLoading,
}) => (
<AntModal
title="新建视频库"
open={open}
onCancel={onCancel}
onOk={onOk}
okText="创建"
cancelText="取消"
destroyOnClose
confirmLoading={confirmLoading}
>
<div className="xx-asset-form-body">
<div>
<div className="xx-asset-form-label"></div>
<Input
placeholder="请输入视频库名称"
value={name}
onChange={(e) => onNameChange(e.target.value)}
maxLength={50}
/>
</div>
<div>
<div className="xx-asset-form-label"></div>
<Select
value={kind}
onChange={(v) => onKindChange(v)}
style={{ width: "100%" }}
options={LIBRARY_KIND_OPTIONS}
/>
</div>
</div>
</AntModal>
)
export default CreateLibraryModal
@@ -1,70 +0,0 @@
import React from "react"
import { PlusOutlined, DeleteOutlined } from "@ant-design/icons"
import { Popconfirm } from "antd"
import type { LibraryItem } from "@/pages/assets/types"
import { kindLabel } from "@/pages/assets/utils/asset"
import { kindIcon } from "@/pages/assets/utils/kindIcon"
/* ============================================================
* LibrarySidebar — 左侧素材库列表
* ============================================================ */
export interface LibrarySidebarProps {
libraries: LibraryItem[]
activeLibId: string
onSelect: (id: string) => void
onDelete: (id: string) => void
onCreateClick: () => void
}
const LibrarySidebar: React.FC<LibrarySidebarProps> = ({
libraries,
activeLibId,
onSelect,
onDelete,
onCreateClick,
}) => (
<div className="xx-asset-library-list">
{libraries.map((lib) => (
<div
key={lib.id}
className={`xx-asset-library-item${lib.id === activeLibId ? " active" : ""}`}
onClick={() => onSelect(lib.id)}
>
<div className="xx-asset-library-header">
<h4>
{kindIcon(lib.kind)} {lib.name}
</h4>
<Popconfirm
title={`确定删除视频库 "${lib.name}"`}
onConfirm={(e) => {
e?.stopPropagation()
onDelete(lib.id)
}}
onCancel={(e) => e?.stopPropagation()}
okText="删除"
cancelText="取消"
>
<button
className="xx-asset-library-delete"
onClick={(e) => e.stopPropagation()}
title="删除视频库"
>
<DeleteOutlined />
</button>
</Popconfirm>
</div>
<span>
{kindLabel(lib.kind)} · {lib.count}
</span>
</div>
))}
{/* 新建视频库 */}
<div className="xx-asset-library-add" onClick={onCreateClick}>
<PlusOutlined />
</div>
</div>
)
export default LibrarySidebar
@@ -1,34 +0,0 @@
import React from "react"
import { Modal as AntModal } from "antd"
import type { AssetItem } from "@/pages/assets/types"
/* ============================================================
* PlayModal — 视频/音频播放弹窗
* ============================================================ */
export interface PlayModalProps {
open: boolean
asset: AssetItem | null
onClose: () => void
}
const PlayModal: React.FC<PlayModalProps> = ({ open, asset, onClose }) => (
<AntModal
title={asset?.name ?? "播放"}
open={open}
onCancel={onClose}
footer={null}
width={640}
destroyOnClose
>
{asset?.fileUrl ? (
<video src={asset.fileUrl} controls autoPlay className="xx-asset-video-player" />
) : (
<div className="xx-asset-empty-fallback">
<p></p>
<p className="xx-asset-empty-fallback-id"> ID: {asset?.id}</p>
</div>
)}
</AntModal>
)
export default PlayModal
@@ -1,70 +0,0 @@
import React from "react"
import { Drawer } from "antd"
import { CheckCircleOutlined, CloseCircleOutlined } from "@ant-design/icons"
import type { BatchOperationResult } from "@/api/assets"
/* ============================================================
* ResultDrawer — 操作结果抽屉
* ============================================================ */
export interface ResultDrawerProps {
open: boolean
title: string
result: BatchOperationResult | null
onClose: () => void
}
const ResultDrawer: React.FC<ResultDrawerProps> = ({ open, title, result, onClose }) => (
<Drawer title={`${title} — 操作结果`} open={open} onClose={onClose} width={420}>
{result && (
<div className="xx-batch-result">
<div className="xx-batch-result-summary">
<div className="xx-batch-result-stat">
<span className="xx-batch-result-total"> {result.total} </span>
</div>
<div className="xx-batch-result-stat success">
<CheckCircleOutlined />
<span> {result.success_count} </span>
</div>
{result.failure_count > 0 && (
<div className="xx-batch-result-stat fail">
<CloseCircleOutlined />
<span> {result.failure_count} </span>
</div>
)}
</div>
{(result?.succeeded?.length ?? 0) > 0 && (
<div className="xx-batch-result-section">
<h4 className="xx-batch-result-section-title success">
<CheckCircleOutlined />
</h4>
<div className="xx-batch-result-ids">
{result?.succeeded?.map((id) => (
<div key={id} className="xx-batch-result-id">
{id}
</div>
))}
</div>
</div>
)}
{(result?.failed?.length ?? 0) > 0 && (
<div className="xx-batch-result-section">
<h4 className="xx-batch-result-section-title fail">
<CloseCircleOutlined />
</h4>
<div className="xx-batch-result-ids">
{result?.failed?.map((id) => (
<div key={id} className="xx-batch-result-id fail">
{id}
</div>
))}
</div>
</div>
)}
</div>
)}
</Drawer>
)
export default ResultDrawer
@@ -1,56 +0,0 @@
import React from "react"
import { Modal as AntModal } from "antd"
/* ============================================================
* UploadProgressModal — 上传进度弹窗(圆形动画 + 百分比)
* ============================================================ */
export interface UploadProgressModalProps {
open: boolean
progress: number
}
const UploadProgressModal: React.FC<UploadProgressModalProps> = ({ open, progress }) => (
<AntModal
open={open}
footer={null}
closable={false}
centered
width={260}
maskClosable={false}
className="xx-upload-progress-modal"
>
<div className="xx-upload-progress-body">
<svg className="xx-upload-progress-ring" viewBox="0 0 120 120" width={120} height={120}>
{/* 背景圆环 */}
<circle
cx="60"
cy="60"
r="52"
fill="none"
stroke="var(--border-primary, #e5e7eb)"
strokeWidth="8"
/>
{/* 进度圆弧 */}
<circle
cx="60"
cy="60"
r="52"
fill="none"
stroke="var(--primary-color, #6366f1)"
strokeWidth="8"
strokeLinecap="round"
strokeDasharray={`${2 * Math.PI * 52}`}
strokeDashoffset={`${2 * Math.PI * 52 * (1 - progress / 100)}`}
transform="rotate(-90 60 60)"
style={{ transition: "stroke-dashoffset 0.3s ease" }}
/>
</svg>
<div className="xx-upload-progress-text">
<span className="xx-upload-progress-pct">{progress}%</span>
<span className="xx-upload-progress-label"></span>
</div>
</div>
</AntModal>
)
export default UploadProgressModal
-66
View File
@@ -1,66 +0,0 @@
/**
* 素材库常量
*/
import type { AssetKind } from "./types"
/** 文件大小限制 */
export const MAX_FILE_SIZE = 2048 * 1024 * 1024
export const LARGE_FILE_THRESHOLD = 100 * 1024 * 1024
/** 素材类型标签 */
export const KIND_LABELS: Record<AssetKind, string> = {
video: "视频",
image: "图片",
voice: "配音",
}
/** 类型筛选选项 */
export const TYPE_FILTER_OPTIONS: { value: string; label: string }[] = [
{ value: "all", label: "全部类型" },
{ value: "video", label: "视频" },
{ value: "image", label: "图片" },
]
/** 时间筛选选项 */
export const TIME_FILTER_OPTIONS: { value: string; label: string }[] = [
{ value: "all", label: "全部时间" },
{ value: "today", label: "今天" },
{ value: "week", label: "近一周" },
{ value: "month", label: "近一月" },
]
/** 新建库类型选项(当前仅支持视频和图片) */
export const LIBRARY_KIND_OPTIONS: { value: AssetKind; label: string }[] = [
{ value: "video", label: "视频" },
{ value: "image", label: "图片" },
]
/** 分类选项 */
export const CATEGORY_OPTIONS: { value: string; label: string }[] = [
{ value: "person", label: "人物" },
{ value: "scenic", label: "风景" },
{ value: "product", label: "产品" },
{ value: "food", label: "美食" },
{ value: "animal", label: "动物" },
{ value: "architecture", label: "建筑" },
{ value: "other", label: "其他" },
]
/** 智能标记视图 */
export const SMART_VIEW_OPTIONS: {
value: "recommended" | "caution" | "high_risk"
label: string
color: string
}[] = [
{ value: "recommended", label: "推荐", color: "success" },
{ value: "caution", label: "慎用", color: "warning" },
{ value: "high_risk", label: "高风险", color: "error" },
]
/** 状态配置 */
export const STATUS_CONFIG: Record<string, { label: string; className: string }> = {
ok: { label: "合格", className: "xx-status-pill xx-status-pill-ok" },
warn: { label: "待优化", className: "xx-status-pill xx-status-pill-warn" },
bad: { label: "不合格", className: "xx-status-pill xx-status-pill-bad" },
info: { label: "待诊断", className: "xx-status-pill xx-status-pill-info" },
}
@@ -1,301 +0,0 @@
import { useState, useCallback } from "react"
import { useQueryClient } from "@tanstack/react-query"
import { message } from "antd"
import {
deleteAsset,
getAssetDiagnosis,
batchDeleteAssets,
batchTagAssets,
batchClassifyAssets,
batchMarkAssets,
type BatchOperationResult,
} from "@/api/assets"
import type { AssetItem } from "../types"
import type { SmartViewType } from "../components/BatchMarkModal"
/**
* 素材操作 Hook
* 封装素材的诊断、删除、批量打标签、批量改分类、批量智能标记等操作,
* 以及相关弹窗和结果展示的状态管理
*/
interface UseAssetOperationsProps {
selectedIds: Set<string>
setSelectedIds: (ids: Set<string>) => void
}
export function useAssetOperations({ selectedIds, setSelectedIds }: UseAssetOperationsProps) {
const queryClient = useQueryClient()
/* ── 诊断状态 ── */
const [diagnosingId, setDiagnosingId] = useState<string | null>(null)
/* ── 批量操作弹窗状态 ── */
const [tagModalOpen, setTagModalOpen] = useState(false)
const [classifyModalOpen, setClassifyModalOpen] = useState(false)
const [markModalOpen, setMarkModalOpen] = useState(false)
const [resultDrawerOpen, setResultDrawerOpen] = useState(false)
/* ── 批量打标签表单 ── */
const [batchTagInput, setBatchTagInput] = useState("")
const [batchTags, setBatchTags] = useState<string[]>([])
const [tagMode, setTagMode] = useState<"add" | "replace">("add")
/* ── 批量改分类表单 ── */
const [batchCategory, setBatchCategory] = useState("")
/* ── 批量智能标记表单 ── */
const [batchSmartView, setBatchSmartView] = useState<SmartViewType>("recommended")
/* ── 操作结果 ── */
const [operationResult, setOperationResult] = useState<BatchOperationResult | null>(null)
const [operationTitle, setOperationTitle] = useState("")
/* ── 批量操作 loading ── */
const [batchLoading, setBatchLoading] = useState(false)
/* ── 刷新数据辅助函数 ── */
const invalidateAssets = useCallback(() => {
queryClient.invalidateQueries({ queryKey: ["assets"] })
queryClient.invalidateQueries({ queryKey: ["asset-libraries"] })
}, [queryClient])
/* ── 诊断 ── */
const handleDiagnose = useCallback(
async (asset: AssetItem) => {
setDiagnosingId(asset.id)
try {
const result = await getAssetDiagnosis(asset.id)
const score = result.readiness_score ?? "-"
message.success(`"${asset.name}" 诊断完成,就绪分:${score}`)
queryClient.invalidateQueries({ queryKey: ["assets"] })
} catch {
message.error(`"${asset.name}" 诊断失败`)
} finally {
setDiagnosingId(null)
}
},
[queryClient],
)
/* ── 单个素材删除 ── */
const handleSingleDelete = useCallback(
async (assetId: string) => {
try {
await deleteAsset(assetId)
invalidateAssets()
// 从选中集合中移除
setSelectedIds(
(() => {
const next = new Set(selectedIds)
next.delete(assetId)
return next
})(),
)
message.success("素材已删除")
} catch {
message.error("删除失败,请重试")
}
},
[invalidateAssets, selectedIds, setSelectedIds],
)
/* ── 显示操作结果 ── */
const showOperationResult = useCallback(
(result: BatchOperationResult, title: string, clearSelection = true) => {
setOperationResult(result)
setOperationTitle(title)
setResultDrawerOpen(true)
if (clearSelection) setSelectedIds(new Set())
},
[setSelectedIds],
)
/* ── 批量删除 ── */
const handleBatchDelete = useCallback(async () => {
const ids = Array.from(selectedIds)
setBatchLoading(true)
try {
const result = await batchDeleteAssets(ids)
invalidateAssets()
showOperationResult(result, "批量删除")
if (result.failure_count === 0) {
message.success(`成功删除 ${result.success_count} 个素材`)
} else {
message.warning(
`删除完成:成功 ${result.success_count} 个,失败 ${result.failure_count}`,
)
}
} catch {
message.error("批量删除失败,请重试")
} finally {
setBatchLoading(false)
}
}, [selectedIds, invalidateAssets, showOperationResult])
/* ── 批量打标签 ── */
const handleBatchTag = useCallback(async () => {
if (batchTags.length === 0) {
message.warning("请至少输入一个标签")
return
}
const ids = Array.from(selectedIds)
setBatchLoading(true)
try {
const result = await batchTagAssets({
asset_ids: ids,
tags: batchTags,
mode: tagMode,
})
queryClient.invalidateQueries({ queryKey: ["assets"] })
showOperationResult(result, "批量打标签")
setTagModalOpen(false)
setBatchTags([])
setBatchTagInput("")
setTagMode("add")
if (result.failure_count === 0) {
message.success(`成功为 ${result.success_count} 个素材打标签`)
} else {
message.warning(
`打标签完成:成功 ${result.success_count} 个,失败 ${result.failure_count}`,
)
}
} catch {
message.error("批量打标签失败,请重试")
} finally {
setBatchLoading(false)
}
}, [batchTags, selectedIds, tagMode, queryClient, showOperationResult])
/* ── 标签输入处理 ── */
const handleTagInputKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "Enter" && batchTagInput.trim()) {
e.preventDefault()
const tag = batchTagInput.trim()
if (!batchTags.includes(tag)) {
setBatchTags([...batchTags, tag])
}
setBatchTagInput("")
}
},
[batchTagInput, batchTags],
)
const removeBatchTag = useCallback(
(tag: string) => {
setBatchTags(batchTags.filter((t) => t !== tag))
},
[batchTags],
)
/* ── 批量改分类 ── */
const handleBatchClassify = useCallback(async () => {
if (!batchCategory) {
message.warning("请选择分类")
return
}
const ids = Array.from(selectedIds)
setBatchLoading(true)
try {
const result = await batchClassifyAssets({
asset_ids: ids,
category: batchCategory,
})
queryClient.invalidateQueries({ queryKey: ["assets"] })
showOperationResult(result, "批量改分类")
setClassifyModalOpen(false)
setBatchCategory("")
if (result.failure_count === 0) {
message.success(`成功将 ${result.success_count} 个素材改为「${batchCategory}`)
} else {
message.warning(
`改分类完成:成功 ${result.success_count} 个,失败 ${result.failure_count}`,
)
}
} catch {
message.error("批量改分类失败,请重试")
} finally {
setBatchLoading(false)
}
}, [batchCategory, selectedIds, queryClient, showOperationResult])
/* ── 批量智能标记 ── */
const handleBatchMark = useCallback(async () => {
const ids = Array.from(selectedIds)
setBatchLoading(true)
try {
const result = await batchMarkAssets({
asset_ids: ids,
smart_view: batchSmartView,
})
queryClient.invalidateQueries({ queryKey: ["assets"] })
showOperationResult(result, "批量智能标记")
setMarkModalOpen(false)
const labelMap: Record<SmartViewType, string> = {
recommended: "推荐",
caution: "慎用",
high_risk: "高风险",
}
if (result.failure_count === 0) {
message.success(
`成功将 ${result.success_count} 个素材标记为「${labelMap[batchSmartView]}`,
)
} else {
message.warning(
`智能标记完成:成功 ${result.success_count} 个,失败 ${result.failure_count}`,
)
}
} catch {
message.error("批量智能标记失败,请重试")
} finally {
setBatchLoading(false)
}
}, [batchSmartView, selectedIds, queryClient, showOperationResult])
/* ── 关闭结果 Drawer ── */
const handleResultDrawerClose = useCallback(() => {
setResultDrawerOpen(false)
setOperationResult(null)
}, [])
return {
// 诊断
diagnosingId,
handleDiagnose,
// 单个操作
handleSingleDelete,
// 批量操作 loading
batchLoading,
// 批量打标签
tagModalOpen,
setTagModalOpen,
batchTagInput,
setBatchTagInput,
batchTags,
setBatchTags,
tagMode,
setTagMode,
handleBatchTag,
handleTagInputKeyDown,
removeBatchTag,
// 批量改分类
classifyModalOpen,
setClassifyModalOpen,
batchCategory,
setBatchCategory,
handleBatchClassify,
// 批量智能标记
markModalOpen,
setMarkModalOpen,
batchSmartView,
setBatchSmartView,
handleBatchMark,
// 批量删除
handleBatchDelete,
// 操作结果
resultDrawerOpen,
operationResult,
operationTitle,
handleResultDrawerClose,
}
}
@@ -1,40 +0,0 @@
import { useState, useCallback } from "react"
import type { AssetItem } from "../types"
/**
* 素材选中态管理 Hook
* 封装单选、全选、取消全选等选中逻辑
*/
interface UseAssetSelectionProps {
filteredAssets: AssetItem[]
}
export function useAssetSelection({ filteredAssets }: UseAssetSelectionProps) {
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
const toggleSelect = useCallback((id: string) => {
setSelectedIds((prev) => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}, [])
const selectAll = useCallback(() => {
setSelectedIds(new Set(filteredAssets.map((a) => a.id)))
}, [filteredAssets])
const deselectAll = useCallback(() => {
setSelectedIds(new Set())
}, [])
return {
selectedIds,
setSelectedIds,
toggleSelect,
selectAll,
deselectAll,
selectedCount: selectedIds.size,
}
}
@@ -1,65 +0,0 @@
import { useState, useCallback } from "react"
import { useQueryClient } from "@tanstack/react-query"
import { message } from "antd"
import { uploadAssetDirect } from "@/api/assets"
import { MAX_FILE_SIZE, LARGE_FILE_THRESHOLD } from "../constants"
/**
* 素材上传 Hook
* 封装上传状态、进度管理和上传逻辑
*/
interface UseAssetUploadProps {
effectiveLibId: string
}
export function useAssetUpload({ effectiveLibId }: UseAssetUploadProps) {
const queryClient = useQueryClient()
const [uploading, setUploading] = useState(false)
const [uploadProgress, setUploadProgress] = useState(0)
const handleUpload = useCallback(
async (file: File) => {
if (file.size > MAX_FILE_SIZE) {
message.error(`文件 "${file.name}" 超过 2GB 限制`)
return
}
if (!effectiveLibId) {
message.warning("请先选择或创建一个视频库")
return
}
setUploading(true)
setUploadProgress(0)
try {
if (file.size > LARGE_FILE_THRESHOLD) {
message.info(`大文件 "${file.name}" 将使用直传上传`)
}
await uploadAssetDirect({
file,
library_id: effectiveLibId,
onProgress: (pct) => setUploadProgress(pct),
})
message.success(`"${file.name}" 上传成功`)
queryClient.invalidateQueries({ queryKey: ["assets"] })
queryClient.invalidateQueries({ queryKey: ["asset-libraries"] })
} catch (err: unknown) {
const detail = err instanceof Error ? err.message : ""
console.error("[handleUpload] 上传失败:", err)
message.error(`"${file.name}" 上传失败${detail ? `${detail}` : ""}`)
// 错误时延迟关闭弹窗,让用户能看到错误提示
await new Promise((r) => setTimeout(r, 1500))
} finally {
setUploading(false)
setUploadProgress(0)
}
},
[effectiveLibId, queryClient],
)
return {
uploading,
uploadProgress,
handleUpload,
}
}
@@ -1,118 +0,0 @@
import { useState, useMemo } from "react"
import { useQuery } from "@tanstack/react-query"
import {
getAssetLibraries,
getAssets,
type AssetLibraryItem,
type AssetItem as ApiAssetItem,
} from "@/api/assets"
import { mapLibrary, mapAsset, type AssetItem, type LibraryItem } from "../types"
/**
* 素材库数据 Hook
* 封装视频库列表、素材列表的数据查询,以及筛选、搜索状态管理
*/
export function useAssetsData() {
/* ── 视频库列表查询 ── */
const { data: apiLibraries = [], isLoading: libLoading } = useQuery<AssetLibraryItem[], Error>({
queryKey: ["asset-libraries"],
queryFn: getAssetLibraries,
staleTime: 60_000,
})
const libraries: LibraryItem[] = useMemo(
() =>
(Array.isArray(apiLibraries) ? apiLibraries : [])
.map(mapLibrary)
.filter((lib) => lib.kind === "video"),
[apiLibraries],
)
/* ── 当前选中的视频库 ── */
const [activeLibId, setActiveLibId] = useState<string>("")
// 当库列表加载完成后,自动选中第一个
const effectiveLibId = activeLibId || libraries[0]?.id || ""
/* ── 当前库的素材列表查询 ── */
const {
data: apiAssets = { items: [], total: 0 },
isLoading: assetsLoading,
isError: assetsError,
error: assetsErrorObj,
refetch: refetchAssets,
} = useQuery<{ items: ApiAssetItem[]; total: number }, Error>({
queryKey: ["assets", effectiveLibId],
queryFn: () =>
getAssets(effectiveLibId, {
// 拉取所有非删除状态的素材,让用户上传后立刻能看到"处理中"的素材
status: "ready,uploading,ingesting,processing,pending,error,failed",
}),
enabled: !!effectiveLibId,
staleTime: 30_000,
})
const assets: AssetItem[] = useMemo(
() => (Array.isArray(apiAssets?.items) ? apiAssets.items : []).map(mapAsset),
[apiAssets],
)
/* ── 筛选状态 ── */
const [searchText, setSearchText] = useState("")
const [filterType, setFilterType] = useState<string>("all")
const [filterTime, setFilterTime] = useState<string>("all")
/* ── 筛选后的素材列表 ── */
const filteredAssets = useMemo(() => {
let list = assets
/* 按素材类型过滤 */
if (filterType !== "all") {
list = list.filter((a) => a.kind === filterType)
}
/* 按时间筛选 */
if (filterTime !== "all") {
const now = new Date()
list = list.filter((a) => {
const d = new Date(a.createdAt)
const diffDays = (now.getTime() - d.getTime()) / (1000 * 60 * 60 * 24)
if (filterTime === "today") return diffDays < 1
if (filterTime === "week") return diffDays < 7
if (filterTime === "month") return diffDays < 30
return true
})
}
/* 搜索 */
if (searchText.trim()) {
const q = searchText.trim().toLowerCase()
list = list.filter((a) => a.name.toLowerCase().includes(q))
}
return list
}, [assets, filterType, filterTime, searchText])
return {
// 视频库
libraries,
libLoading,
activeLibId,
setActiveLibId,
effectiveLibId,
// 素材列表
assets,
assetsLoading,
assetsError,
assetsErrorObj,
refetchAssets,
// 筛选
searchText,
setSearchText,
filterType,
setFilterType,
filterTime,
setFilterTime,
filteredAssets,
}
}
@@ -1,106 +0,0 @@
import { useState, useCallback } from "react"
import { useMutation, useQueryClient } from "@tanstack/react-query"
import { message } from "antd"
import { createAssetLibrary, deleteAssetLibrary } from "@/api/assets"
import type { AssetKind, LibraryItem } from "../types"
/**
* 视频库管理 Hook
* 封装视频库的创建、删除操作,以及新建弹窗的表单状态
*/
interface UseLibraryManagementProps {
libraries: LibraryItem[]
activeLibId: string
setActiveLibId: (id: string) => void
effectiveLibId: string
}
export function useLibraryManagement({
libraries,
setActiveLibId,
effectiveLibId,
}: UseLibraryManagementProps) {
const queryClient = useQueryClient()
/* ── 状态 ── */
const [createModalOpen, setCreateModalOpen] = useState(false)
const [newLibName, setNewLibName] = useState("")
const [newLibKind, setNewLibKind] = useState<AssetKind>("video")
/* ── Mutations ── */
const createLibMutation = useMutation({
mutationFn: createAssetLibrary,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["asset-libraries"] })
message.success("视频库创建成功")
},
onError: () => {
message.error("创建视频库失败")
},
})
const deleteLibMutation = useMutation({
mutationFn: deleteAssetLibrary,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["asset-libraries"] })
message.success("视频库已删除")
},
onError: () => {
message.error("删除视频库失败")
},
})
/* ── 新建视频库 ── */
const handleCreateLibrary = useCallback(async () => {
if (!newLibName.trim()) {
message.warning("请输入视频库名称")
return
}
try {
const newLib = await createLibMutation.mutateAsync({
name: newLibName.trim(),
kind: newLibKind,
})
setActiveLibId(newLib.id)
setCreateModalOpen(false)
setNewLibName("")
setNewLibKind("video")
} catch {
// error handled in mutation
}
}, [newLibName, newLibKind, createLibMutation, setActiveLibId])
/* ── 删除视频库 ── */
const handleDeleteLibrary = useCallback(
async (id: string) => {
try {
await deleteLibMutation.mutateAsync(id)
if (effectiveLibId === id) {
const remaining = libraries.filter((l) => l.id !== id)
if (remaining.length > 0) setActiveLibId(remaining[0].id)
else setActiveLibId("")
}
} catch {
// error handled in mutation
}
},
[deleteLibMutation, effectiveLibId, libraries, setActiveLibId],
)
return {
// 弹窗状态
createModalOpen,
setCreateModalOpen,
// 表单状态
newLibName,
setNewLibName,
newLibKind,
setNewLibKind,
// Mutations
isCreating: createLibMutation.isPending,
isDeleting: deleteLibMutation.isPending,
// Handlers
handleCreateLibrary,
handleDeleteLibrary,
}
}
-115
View File
@@ -1,115 +0,0 @@
/**
* 素材库类型定义
*/
import type { AssetLibraryItem, AssetItem as ApiAssetItem } from "@/api/assets"
import { formatDuration } from "./utils/format"
export type AssetKind = "video" | "image" | "voice"
export type StatusType = "ok" | "warn" | "bad" | "info"
export interface LibraryItem {
id: string
name: string
kind: AssetKind
count: number
}
export interface AssetItem {
id: string
name: string
kind: AssetKind
thumbUrl?: string
fileUrl?: string
status: StatusType
statusLabel: string
/** 是否处于处理中状态(上传中/入库中/诊断中) */
loading?: boolean
duration?: string
size: number
createdAt: string
}
/** 根据 mime_type 推断前端 AssetKind */
export const inferKind = (mimeType: string): AssetKind => {
if (mimeType.startsWith("video/")) return "video"
if (mimeType.startsWith("audio/")) return "voice"
return "image"
}
/** 根据 quality_score / classification_status / asset status 推断前端状态 */
export const inferStatus = (
score?: number,
classificationStatus?: string,
assetStatus?: string,
): { status: StatusType; label: string; loading?: boolean } => {
// 已删除素材(正常情况列表已过滤,这里是防御性处理)
if (assetStatus === "deleted") {
return { status: "bad", label: "已删除" }
}
// 处理中状态:上传中 / 入库中 / 处理中
if (
assetStatus === "uploading" ||
assetStatus === "ingesting" ||
assetStatus === "processing" ||
assetStatus === "pending"
) {
return { status: "info", label: "处理中", loading: true }
}
// 失败状态
if (assetStatus === "error" || assetStatus === "failed") {
return { status: "bad", label: "处理失败" }
}
// 素材已就绪(status=ready)时,不应因 classification 未执行而显示"处理中"
if (assetStatus === "ready") {
if (score == null) return { status: "info", label: "待诊断" }
if (score >= 70) return { status: "ok", label: "合格" }
if (score >= 40) return { status: "warn", label: "待优化" }
return { status: "bad", label: "不合格" }
}
// 素材未就绪:classification 正在处理中
if (classificationStatus === "processing" || classificationStatus === "pending") {
return { status: "info", label: "处理中", loading: true }
}
if (score == null) return { status: "info", label: "待诊断" }
if (score >= 70) return { status: "ok", label: "合格" }
if (score >= 40) return { status: "warn", label: "待优化" }
return { status: "bad", label: "不合格" }
}
/** 将后端 AssetLibraryItem 映射为前端 LibraryItem */
export const mapLibrary = (item: AssetLibraryItem): LibraryItem => ({
id: item.id,
name: item.name,
kind: item.kind || inferKind("video"),
count: item.asset_count ?? 0,
})
/** 将后端 ApiAssetItem 映射为前端 AssetItem */
export const mapAsset = (item: ApiAssetItem): AssetItem => {
const { status, label, loading } = inferStatus(
item.quality_score ?? undefined,
item.classification_status ?? undefined,
item.status ?? undefined,
)
const metadata = item.metadata || {}
const kind = inferKind(item.mime_type || "")
return {
id: item.id,
name: item.name,
kind,
// 视频类型不能用 file_url 做缩略图(是视频文件,<img> 无法渲染)
// 处理中的素材没有缩略图,显示占位符
thumbUrl: loading
? undefined
: (item.thumbnail_url as string | undefined) ||
(metadata.thumbnail_url as string | undefined) ||
(kind !== "video" ? (item.file_url as string | undefined) : undefined),
fileUrl: (item.file_url as string | undefined) || (metadata.file_url as string | undefined),
status,
statusLabel: label,
loading,
duration: metadata.duration != null ? formatDuration(metadata.duration as number) : undefined,
size: item.file_size ? +(item.file_size / (1024 * 1024)).toFixed(1) : 0,
createdAt: item.created_at ? new Date(item.created_at).toISOString().slice(0, 10) : "—",
}
}
-19
View File
@@ -1,19 +0,0 @@
/**
* 素材相关工具函数
*/
import type { AssetKind } from "../types"
import { KIND_LABELS } from "../constants"
export const kindLabel = (kind: AssetKind): string => KIND_LABELS[kind] ?? kind
/** 根据素材类型返回渐变背景 */
export const thumbGradient = (kind: AssetKind): string => {
switch (kind) {
case "video":
return "linear-gradient(135deg, #312e81 0%, #4f46e5 50%, #6366f1 100%)"
case "image":
return "linear-gradient(135deg, #78350f 0%, #d97706 50%, #f59e0b 100%)"
case "voice":
return "linear-gradient(135deg, #064e3b 0%, #059669 50%, #10b981 100%)"
}
}
-22
View File
@@ -1,22 +0,0 @@
/**
* 格式化工具函数
*/
export const formatDuration = (seconds: number): string => {
const m = Math.floor(seconds / 60)
const s = Math.floor(seconds % 60)
return `${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}`
}
export const formatFileSize = (bytes: number): string => {
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 formatDate = (iso: string): string =>
new Date(iso).toLocaleDateString("zh-CN", {
year: "numeric",
month: "2-digit",
day: "2-digit",
})
@@ -1,15 +0,0 @@
import React from "react"
import { VideoCameraOutlined, PictureOutlined, AudioOutlined } from "@ant-design/icons"
import type { AssetKind } from "../types"
/** 根据素材类型返回对应图标 */
export const kindIcon = (kind: AssetKind): React.ReactNode => {
switch (kind) {
case "video":
return <VideoCameraOutlined />
case "image":
return <PictureOutlined />
case "voice":
return <AudioOutlined />
}
}
File diff suppressed because it is too large Load Diff
@@ -2,13 +2,99 @@
* 右栏设置面板 — V8 原型 1:1 还原
* 字幕设置 + BGM设置 + 片段详情
*/
import React from "react"
import type { ClipPropertiesPanelProps } from "@/pages/editing-planner/types/clipProperties"
import SubtitleSettingsSection from "./clip-properties/SubtitleSettingsSection"
import BgmSettingsSection from "./clip-properties/BgmSettingsSection"
import ClipDetailSection from "./clip-properties/ClipDetailSection"
import StatsSection from "./clip-properties/StatsSection"
import { useVoicePreview } from "@/pages/editing-planner/hooks/useVoicePreview"
import React, { useRef, useState, useCallback } from "react"
import { useNavigate } from "react-router-dom"
import type { TemplateMode } from "@/api/editing-planner"
import type { ClipData, ClipType } from "../types"
import { TRANSITION_OPTIONS } from "@/api/template-editor"
import type { AssetItem } from "@/api/assets"
interface SubtitleSettings {
enabled: boolean
position: string
font: string
fontSize: number
fontColor: string
animation: string
mode?: string
stroke?: boolean
shadow?: boolean
asrLanguage?: string
}
interface BgmSettings {
enabled: boolean
music_id: string
volume?: number
fade_in?: number
fade_out?: number
voice_dodge?: boolean
}
interface ClipPropertiesPanelProps {
selectedClip: ClipData | null
subtitleSettings: SubtitleSettings
bgmSettings: BgmSettings
clipsCount: number
totalDuration: number
currentMode: TemplateMode
onSubtitleSettingsChange: (partial: Partial<SubtitleSettings>) => void
onBgmSettingsChange: (partial: Partial<BgmSettings>) => void
onClipUpdate: (clipId: string, data: Partial<ClipData>) => void
/** 打开 BGM 选择器 Drawer */
onOpenBgmDrawer?: () => void
/** 打开字幕样式配置 Drawer */
onOpenSubtitleDrawer?: () => void
/** 配音素材列表(从配音库 API 获取) */
voiceMaterials?: AssetItem[]
/** 配音素材加载中 */
voiceMaterialsLoading?: boolean
/** 刷新配音素材列表 */
onRefreshVoiceMaterials?: () => void
/** 为片段选择配音素材 */
onClipVoiceSelect?: (clipId: string, asset: AssetItem | null) => void
/** 打开转场特效选择器 Drawer */
onOpenTransitionDrawer?: (clipId: string) => void
/** 打开片段调速面板 Drawer */
onOpenSpeedDrawer?: (clipId: string) => void
/** 打开 TTS 配音面板 Drawer */
onOpenTtsDrawer?: (clipId: string) => void
/** 打开水印设置面板 Drawer */
onOpenWatermarkDrawer?: () => void
/** 打开片头片尾设置面板 Drawer */
onOpenIntroOutroDrawer?: () => void
/** 打开混剪设置面板 Drawer */
onOpenPipDrawer?: () => void
/** 打开滤镜调色面板 Drawer */
onOpenFilterDrawer?: () => void
/** 打开绿幕抠像面板 Drawer */
onOpenGreenScreenDrawer?: () => void
/** 打开贴纸面板 Drawer */
onOpenStickerDrawer?: () => void
/** 打开封面选择器 Drawer */
}
const POSITION_OPTIONS = [
{ value: "top", label: "顶部" },
{ value: "center", label: "居中" },
{ value: "bottom", label: "底部" },
]
const FONT_OPTIONS = ["思源黑体", "思源宋体", "苹方", "PingFang", "微软雅黑", "楷体", "华康俪金黑"]
const ANIMATION_OPTIONS = [
{ value: "none", label: "无" },
{ value: "fade", label: "淡入淡出" },
{ value: "slide", label: "滑动" },
{ value: "typewriter", label: "打字机" },
]
/** 片段类型图标/标签 */
const CLIP_TYPE_ICONS: Record<ClipType, string> = { voice: "🎙️", pip: "🖼️" }
const CLIP_TYPE_LABELS: Record<ClipType, string> = {
voice: "口播",
pip: "混剪",
}
const ClipPropertiesPanel: React.FC<ClipPropertiesPanelProps> = ({
selectedClip,
@@ -36,19 +122,165 @@ const ClipPropertiesPanel: React.FC<ClipPropertiesPanelProps> = ({
onOpenGreenScreenDrawer,
onOpenStickerDrawer,
}) => {
const { previewingId, handlePreviewVoice, stopPreview } = useVoicePreview()
const navigate = useNavigate()
/* ── 配音试听 ── */
const audioRef = useRef<HTMLAudioElement | null>(null)
const [previewingId, setPreviewingId] = useState<string | null>(null)
/** 试听配音素材 */
const handlePreviewVoice = useCallback(
(asset: AssetItem) => {
// 点击同一个 → 暂停
if (previewingId === asset.id) {
audioRef.current?.pause()
setPreviewingId(null)
return
}
// 停止上一个
audioRef.current?.pause()
const url = asset.file_url || (asset.metadata?.preview_url as string)
if (!url) return
const audio = new Audio(url)
audioRef.current = audio
audio.play().catch(() => {})
audio.onended = () => setPreviewingId(null)
setPreviewingId(asset.id)
},
[previewingId],
)
/** 从 metadata 取性别标签 */
const getGenderLabel = (m: AssetItem): string => {
const g = (m.metadata?.gender as string) || ""
if (g === "male") return "男"
if (g === "female") return "女"
return ""
}
return (
<div className="ep-right-panel">
{/* ═══ 字幕设置 ═══ */}
<SubtitleSettingsSection
settings={subtitleSettings}
onChange={onSubtitleSettingsChange}
onOpenSubtitleDrawer={onOpenSubtitleDrawer}
/>
<div className="ep-settings-section">
<div className="ep-section-title">
<span className="ep-section-icon">💬</span>
</div>
<div className="ep-toggle-row">
<span className="ep-toggle-label"></span>
<div
className={`ep-toggle ${subtitleSettings.enabled ? "active" : ""}`}
onClick={() =>
onSubtitleSettingsChange({
enabled: !subtitleSettings.enabled,
})
}
>
<div className="ep-toggle-knob" />
</div>
</div>
{subtitleSettings.enabled && (
<>
<div className="ep-field">
<label className="ep-field-label"></label>
<select
className="ep-form-select"
value={subtitleSettings.position}
onChange={(e) => onSubtitleSettingsChange({ position: e.target.value })}
>
{POSITION_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
<div className="ep-field">
<label className="ep-field-label"></label>
<select
className="ep-form-select"
value={subtitleSettings.font}
onChange={(e) => onSubtitleSettingsChange({ font: e.target.value })}
>
{FONT_OPTIONS.map((f) => (
<option key={f} value={f}>
{f}
</option>
))}
</select>
</div>
<div className="ep-field">
<label className="ep-field-label"></label>
<div className="ep-slider-row">
<input
className="ep-slider"
type="range"
min={12}
max={48}
value={subtitleSettings.fontSize}
onChange={(e) =>
onSubtitleSettingsChange({
fontSize: Number(e.target.value),
})
}
/>
<span className="ep-slider-value">{subtitleSettings.fontSize}px</span>
</div>
</div>
<div className="ep-field">
<label className="ep-field-label"></label>
<select
className="ep-form-select"
value={subtitleSettings.animation}
onChange={(e) => onSubtitleSettingsChange({ animation: e.target.value })}
>
{ANIMATION_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
{/* 高级配置按钮 */}
{onOpenSubtitleDrawer && (
<button className="ep-advanced-btn" onClick={onOpenSubtitleDrawer}>
🎨
</button>
)}
</>
)}
</div>
{/* ═══ BGM 设置 ═══ */}
<BgmSettingsSection settings={bgmSettings} onOpenBgmDrawer={onOpenBgmDrawer} />
<div className="ep-settings-section">
<div className="ep-section-title">
<span className="ep-section-icon">🎵</span>
BGM
</div>
{bgmSettings.enabled && bgmSettings.music_id ? (
<div className="ep-bgm-current">
<span className="ep-bgm-current-label">🎵 BGM</span>
<span className="ep-bgm-current-id">{bgmSettings.music_id}</span>
{bgmSettings.volume !== undefined && (
<span className="ep-bgm-current-vol"> {bgmSettings.volume}%</span>
)}
</div>
) : (
<div className="ep-bgm-empty"></div>
)}
{onOpenBgmDrawer && (
<button className="ep-advanced-btn" onClick={onOpenBgmDrawer}>
🎵 {bgmSettings.enabled ? "更换 BGM / 调整混音" : "选择 BGM 音乐"}
</button>
)}
</div>
{/* ═══ 水印设置 ═══ */}
<div className="ep-settings-section">
@@ -142,30 +374,258 @@ const ClipPropertiesPanel: React.FC<ClipPropertiesPanelProps> = ({
{/* ═══ 片段详情(选中时显示) ═══ */}
{selectedClip && (
<ClipDetailSection
clip={selectedClip}
currentMode={currentMode}
voiceMaterials={voiceMaterials}
voiceMaterialsLoading={voiceMaterialsLoading}
onClipUpdate={onClipUpdate}
onRefreshVoiceMaterials={onRefreshVoiceMaterials}
onClipVoiceSelect={onClipVoiceSelect}
onOpenTransitionDrawer={onOpenTransitionDrawer}
onOpenSpeedDrawer={onOpenSpeedDrawer}
onOpenTtsDrawer={onOpenTtsDrawer}
previewingId={previewingId}
onPreviewVoice={handlePreviewVoice}
onStopPreview={stopPreview}
/>
<div className="ep-settings-section">
<div className="ep-section-title">
<span className="ep-section-icon">🎞</span>
</div>
<div className="ep-clip-detail">
{/* 类型选择器 */}
<div className="ep-clip-detail-field">
<div className="ep-clip-detail-label"></div>
<div className="ep-clip-type-selector">
{(["voice", "pip"] as ClipType[]).map((t) => {
const disabled =
currentMode === "pip"
? t !== "pip"
: currentMode === "voice_over"
? t !== "voice"
: false // voice_pip 可切换
return (
<button
key={t}
className={`ep-clip-type-btn${selectedClip.type === t ? " active" : ""}${disabled ? " disabled" : ""}`}
disabled={disabled}
onClick={() => !disabled && onClipUpdate(selectedClip.id, { type: t })}
>
{CLIP_TYPE_ICONS[t]} {CLIP_TYPE_LABELS[t]}
</button>
)
})}
</div>
</div>
{/* 时长 */}
<div className="ep-clip-detail-field">
<div className="ep-clip-detail-label"></div>
<div className="ep-clip-detail-row">
<input
className="ep-duration-input"
type="number"
min={1}
max={120}
value={selectedClip.duration}
onChange={(e) =>
onClipUpdate(selectedClip.id, {
duration: Math.max(1, Math.min(120, Number(e.target.value) || 1)),
})
}
/>
<span className="ep-clip-detail-unit"></span>
</div>
</div>
{/* 转场效果入口 */}
{onOpenTransitionDrawer && (
<div className="ep-clip-detail-field">
<button
className="ep-advanced-btn ep-advanced-btn--transition"
onClick={() => onOpenTransitionDrawer(selectedClip.id)}
>
<span className="ep-advanced-btn-icon">🎬</span>
<span className="ep-advanced-btn-label"></span>
<span className="ep-advanced-btn-value">
{(() => {
const t = selectedClip.transition
if (!t || t.type === "none") return "无转场"
const opt = TRANSITION_OPTIONS.find((o) => o.value === t.type)
return `${opt?.label ?? t.type} · ${t.duration.toFixed(1)}s`
})()}
</span>
<span className="ep-advanced-btn-arrow"></span>
</button>
</div>
)}
{/* 播放速度入口 */}
{onOpenSpeedDrawer && (
<div className="ep-clip-detail-field">
<button
className="ep-advanced-btn ep-advanced-btn--speed"
onClick={() => onOpenSpeedDrawer(selectedClip.id)}
>
<span className="ep-advanced-btn-icon"></span>
<span className="ep-advanced-btn-label"></span>
<span className="ep-advanced-btn-value">
{selectedClip.speed ? `${selectedClip.speed.rate.toFixed(2)}x` : "1.00x"}
</span>
<span className="ep-advanced-btn-arrow"></span>
</button>
</div>
)}
{/* TTS 配音入口 */}
{onOpenTtsDrawer && (
<div className="ep-clip-detail-field">
<button
className="ep-advanced-btn ep-advanced-btn--tts"
onClick={() => onOpenTtsDrawer(selectedClip.id)}
>
<span className="ep-advanced-btn-icon">🎙</span>
<span className="ep-advanced-btn-label">TTS </span>
<span className="ep-advanced-btn-value">
{(() => {
const tts = selectedClip.tts_config
if (!tts || tts.mode === "none") return "无配音"
if (tts.mode === "upload") return "上传配音"
return `TTS · ${tts.voice_id ? "已选音色" : "未选音色"}`
})()}
</span>
<span className="ep-advanced-btn-arrow"></span>
</button>
</div>
)}
{/* 素材起始时间 — 仅 voice 类型显示 */}
{selectedClip.type === "voice" && (
<div className="ep-clip-detail-field">
<div className="ep-clip-detail-label"></div>
<div className="ep-clip-detail-row">
<input
className="ep-duration-input"
type="number"
min={0}
max={9999}
step={0.1}
value={selectedClip.startOffset}
onChange={(e) =>
onClipUpdate(selectedClip.id, {
startOffset: Math.max(0, Math.min(9999, Number(e.target.value) || 0)),
})
}
/>
<span className="ep-clip-detail-unit"></span>
</div>
</div>
)}
{/* 配音素材选择 — 仅 voice 类型显示 */}
{selectedClip.type === "voice" && (
<div className="ep-clip-detail-field">
<div className="ep-clip-detail-label">
{onRefreshVoiceMaterials && (
<button
type="button"
className="ep-voice-refresh-btn"
title="刷新配音列表"
onClick={() => onRefreshVoiceMaterials()}
disabled={voiceMaterialsLoading}
>
{voiceMaterialsLoading ? "⏳" : "🔄"}
</button>
)}
</div>
{voiceMaterialsLoading && voiceMaterials.length === 0 ? (
<div className="ep-voice-loading">...</div>
) : (
<>
<div className="ep-voice-select-row">
<select
className="ep-clip-detail-select"
value={selectedClip.voice_asset_id ?? ""}
onChange={(e) => {
const assetId = e.target.value
if (!onClipVoiceSelect) return
if (!assetId) {
onClipVoiceSelect(selectedClip.id, null)
} else {
const asset = voiceMaterials.find((m) => m.id === assetId)
if (asset) onClipVoiceSelect(selectedClip.id, asset)
}
// 切换选择时停止试听
audioRef.current?.pause()
setPreviewingId(null)
}}
>
<option value=""></option>
{voiceMaterials.map((m) => {
const gender = getGenderLabel(m)
const label = gender ? `${m.name}${gender}` : m.name
return (
<option key={m.id} value={m.id}>
{label}
</option>
)
})}
</select>
{/* 试听按钮 */}
{selectedClip.voice_asset_id && (
<button
type="button"
className="ep-voice-preview-btn"
title={previewingId === selectedClip.voice_asset_id ? "暂停" : "试听"}
onClick={() => {
const asset = voiceMaterials.find(
(m) => m.id === selectedClip.voice_asset_id,
)
if (asset) handlePreviewVoice(asset)
}}
>
{previewingId === selectedClip.voice_asset_id ? "⏸" : "▶️"}
</button>
)}
</div>
{voiceMaterials.length === 0 && (
<div className="ep-voice-empty"></div>
)}
</>
)}
<button
className="ep-voice-upload-btn"
onClick={() => navigate("/app/voice-materials")}
>
+
</button>
</div>
)}
</div>
</div>
)}
{/* ═══ 统计信息(未选中时显示) ═══ */}
{/* ═══ 统计信息(始终显示) ═══ */}
{!selectedClip && (
<StatsSection
clipsCount={clipsCount}
totalDuration={totalDuration}
currentMode={currentMode}
/>
<div className="ep-settings-section">
<div className="ep-section-title">
<span className="ep-section-icon">📊</span>
</div>
<div className="ep-clip-detail">
<div className="ep-clip-detail-field">
<div className="ep-clip-detail-label"></div>
<div className="ep-clip-detail-value">{clipsCount}</div>
</div>
<div className="ep-clip-detail-field">
<div className="ep-clip-detail-label"></div>
<div className="ep-clip-detail-value">{totalDuration.toFixed(1)}s</div>
</div>
<div className="ep-clip-detail-field">
<div className="ep-clip-detail-label"></div>
<div className="ep-clip-detail-value">
{currentMode === "pip"
? "混剪"
: currentMode === "voice_over"
? "人物口播"
: currentMode === "one_take"
? "一镜到底"
: "口播+混剪"}
</div>
</div>
</div>
</div>
)}
</div>
)
@@ -1,106 +0,0 @@
/**
* 所有弹窗和 Drawer 组件的集合
* 纯渲染层,业务逻辑和 state 留在父组件
*/
import React from "react"
import SaveModal from "./SaveModal"
import { ClipLevelDrawers } from "./editing-drawers/ClipLevelDrawers"
import { GlobalDrawers } from "./editing-drawers/GlobalDrawers"
import type { EditingDrawersProps } from "./editing-drawers/types"
const EditingDrawers: React.FC<EditingDrawersProps> = (props) => {
const {
saveModalOpen,
saveLoading,
isUpdate,
draftName,
draftCategory,
draftTags,
categories,
estimatedDuration,
onNameChange,
onCategoryChange,
onTagsChange,
onSave,
onCancelSave,
clips,
} = props
return (
<>
{/* 保存弹窗 */}
<SaveModal
open={saveModalOpen}
loading={saveLoading}
isUpdate={isUpdate}
draftName={draftName}
draftCategory={draftCategory}
draftTags={draftTags}
categories={categories}
estimatedDuration={estimatedDuration}
onNameChange={onNameChange}
onCategoryChange={onCategoryChange}
onTagsChange={onTagsChange}
onSave={onSave}
onCancel={onCancelSave}
/>
{/* 片段级抽屉(转场/调速/TTS) */}
<ClipLevelDrawers
clips={clips}
transitionDrawerOpen={props.transitionDrawerOpen}
transitionTargetClipId={props.transitionTargetClipId}
onCloseTransitionDrawer={props.onCloseTransitionDrawer}
onTransitionChange={props.onTransitionChange}
speedDrawerOpen={props.speedDrawerOpen}
speedTargetClipId={props.speedTargetClipId}
onCloseSpeedDrawer={props.onCloseSpeedDrawer}
onSpeedChange={props.onSpeedChange}
onApplySpeedAll={props.onApplySpeedAll}
ttsDrawerOpen={props.ttsDrawerOpen}
ttsTargetClipId={props.ttsTargetClipId}
onCloseTtsDrawer={props.onCloseTtsDrawer}
onTtsChange={props.onTtsChange}
/>
{/* 全局设置抽屉(BGM/字幕/水印/片头片尾/混剪/滤镜/绿幕/贴纸) */}
<GlobalDrawers
bgmDrawerOpen={props.bgmDrawerOpen}
bgmSettings={props.bgmSettings}
onCloseBgmDrawer={props.onCloseBgmDrawer}
onChangeBgmSettings={props.onChangeBgmSettings}
subtitleDrawerOpen={props.subtitleDrawerOpen}
subtitleSettings={props.subtitleSettings}
onCloseSubtitleDrawer={props.onCloseSubtitleDrawer}
onChangeSubtitleSettings={props.onChangeSubtitleSettings}
totalDuration={props.totalDuration}
watermarkDrawerOpen={props.watermarkDrawerOpen}
watermarkSettings={props.watermarkSettings}
onCloseWatermarkDrawer={props.onCloseWatermarkDrawer}
onWatermarkChange={props.onWatermarkChange}
introOutroDrawerOpen={props.introOutroDrawerOpen}
introOutroSettings={props.introOutroSettings}
onCloseIntroOutroDrawer={props.onCloseIntroOutroDrawer}
onIntroOutroChange={props.onIntroOutroChange}
pipDrawerOpen={props.pipDrawerOpen}
pipSettings={props.pipSettings}
onClosePipDrawer={props.onClosePipDrawer}
onPipChange={props.onPipChange}
filterDrawerOpen={props.filterDrawerOpen}
filterSettings={props.filterSettings}
onCloseFilterDrawer={props.onCloseFilterDrawer}
onFilterChange={props.onFilterChange}
chromaKeyDrawerOpen={props.chromaKeyDrawerOpen}
chromaKeySettings={props.chromaKeySettings}
onCloseChromaKeyDrawer={props.onCloseChromaKeyDrawer}
onChromaKeyChange={props.onChromaKeyChange}
stickerDrawerOpen={props.stickerDrawerOpen}
stickerSettings={props.stickerSettings}
onCloseStickerDrawer={props.onCloseStickerDrawer}
onStickerChange={props.onStickerChange}
/>
</>
)
}
export default EditingDrawers
@@ -1,246 +0,0 @@
import React from "react"
import BgmSelector from "./BgmSelector"
import SubtitleStylePanel from "./SubtitleStylePanel"
import TransitionSelector from "./TransitionSelector"
import SpeedPanel from "./SpeedPanel"
import TtsPanel from "./TtsPanel"
import WatermarkPanel from "./WatermarkPanel"
import IntroOutroPanel from "./IntroOutroPanel"
import PipConfigPanel from "./PipConfigPanel"
import FilterPanel from "./FilterPanel"
import GreenScreenPanel from "./GreenScreenPanel"
import StickerPanel from "./StickerPanel"
import type {
ClipData,
TransitionConfig,
SpeedConfig,
TtsConfig,
WatermarkConfig,
IntroOutroConfig,
PipConfig,
FilterConfig,
ChromaKeyConfig,
StickerConfig,
} from "../types"
import type { SubtitleStyleConfig } from "../types/subtitle"
import type { BgmMixConfig } from "@/api/bgm"
import { DEFAULT_TRANSITION, DEFAULT_SPEED, DEFAULT_TTS_CONFIG } from "../types"
interface EditorDrawersProps {
// BGM
bgmDrawerOpen: boolean
bgmSettings: BgmMixConfig
onBgmSettingsChange: (config: BgmMixConfig) => void
onCloseBgmDrawer: () => void
// 字幕
subtitleDrawerOpen: boolean
subtitleSettings: SubtitleStyleConfig
onSubtitleSettingsChange: (config: SubtitleStyleConfig) => void
onCloseSubtitleDrawer: () => void
// 转场
transitionDrawerOpen: boolean
transitionTargetClipId: string | null
clips: ClipData[]
onTransitionChange: (config: TransitionConfig) => void
onCloseTransitionDrawer: () => void
// 调速
speedDrawerOpen: boolean
speedTargetClipId: string | null
onSpeedChange: (config: SpeedConfig) => void
onApplySpeedAll: (config: SpeedConfig) => void
onCloseSpeedDrawer: () => void
// TTS
ttsDrawerOpen: boolean
ttsTargetClipId: string | null
onTtsChange: (config: TtsConfig) => void
onCloseTtsDrawer: () => void
// 水印
watermarkDrawerOpen: boolean
watermarkSettings: WatermarkConfig
onWatermarkChange: (config: WatermarkConfig) => void
onCloseWatermarkDrawer: () => void
// 片头片尾
introOutroDrawerOpen: boolean
introOutroSettings: IntroOutroConfig
onIntroOutroChange: (config: IntroOutroConfig) => void
onCloseIntroOutroDrawer: () => void
// 混剪
pipDrawerOpen: boolean
pipSettings: PipConfig
totalDuration: number
onPipChange: (config: PipConfig) => void
onClosePipDrawer: () => void
// 滤镜
filterDrawerOpen: boolean
filterSettings: FilterConfig
onFilterChange: (config: FilterConfig) => void
onCloseFilterDrawer: () => void
// 绿幕
chromaKeyDrawerOpen: boolean
chromaKeySettings: ChromaKeyConfig
onChromaKeyChange: (config: ChromaKeyConfig) => void
onCloseChromaKeyDrawer: () => void
// 贴纸
stickerDrawerOpen: boolean
stickerSettings: StickerConfig
onStickerChange: (config: StickerConfig) => void
onCloseStickerDrawer: () => void
}
const EditorDrawers: React.FC<EditorDrawersProps> = ({
bgmDrawerOpen,
bgmSettings,
onBgmSettingsChange,
onCloseBgmDrawer,
subtitleDrawerOpen,
subtitleSettings,
onSubtitleSettingsChange,
onCloseSubtitleDrawer,
transitionDrawerOpen,
transitionTargetClipId,
clips,
onTransitionChange,
onCloseTransitionDrawer,
speedDrawerOpen,
speedTargetClipId,
onSpeedChange,
onApplySpeedAll,
onCloseSpeedDrawer,
ttsDrawerOpen,
ttsTargetClipId,
onTtsChange,
onCloseTtsDrawer,
watermarkDrawerOpen,
watermarkSettings,
onWatermarkChange,
onCloseWatermarkDrawer,
introOutroDrawerOpen,
introOutroSettings,
onIntroOutroChange,
onCloseIntroOutroDrawer,
pipDrawerOpen,
pipSettings,
totalDuration,
onPipChange,
onClosePipDrawer,
filterDrawerOpen,
filterSettings,
onFilterChange,
onCloseFilterDrawer,
chromaKeyDrawerOpen,
chromaKeySettings,
onChromaKeyChange,
onCloseChromaKeyDrawer,
stickerDrawerOpen,
stickerSettings,
onStickerChange,
onCloseStickerDrawer,
}) => {
const transitionConfig = transitionTargetClipId
? (clips.find((c) => c.id === transitionTargetClipId)?.transition ?? DEFAULT_TRANSITION)
: DEFAULT_TRANSITION
const transitionTitle = transitionTargetClipId ? "片段转场设置" : "全局默认转场"
return (
<>
{/* BGM 选择器 Drawer */}
<BgmSelector
open={bgmDrawerOpen}
onClose={onCloseBgmDrawer}
config={bgmSettings}
onChange={onBgmSettingsChange}
/>
{/* 字幕样式配置 Drawer */}
<SubtitleStylePanel
open={subtitleDrawerOpen}
onClose={onCloseSubtitleDrawer}
config={subtitleSettings}
onChange={onSubtitleSettingsChange}
/>
{/* 转场特效选择器 Drawer */}
<TransitionSelector
open={transitionDrawerOpen}
onClose={onCloseTransitionDrawer}
config={transitionConfig}
onChange={onTransitionChange}
title={transitionTitle}
/>
{/* 片段调速面板 Drawer */}
{speedTargetClipId && (
<SpeedPanel
open={speedDrawerOpen}
onClose={onCloseSpeedDrawer}
config={clips.find((c) => c.id === speedTargetClipId)?.speed ?? DEFAULT_SPEED}
onChange={onSpeedChange}
onApplyAll={onApplySpeedAll}
/>
)}
{/* TTS 配音面板 Drawer */}
{ttsTargetClipId && (
<TtsPanel
open={ttsDrawerOpen}
onClose={onCloseTtsDrawer}
config={clips.find((c) => c.id === ttsTargetClipId)?.tts_config ?? DEFAULT_TTS_CONFIG}
onChange={onTtsChange}
/>
)}
{/* 水印配置面板 */}
<WatermarkPanel
open={watermarkDrawerOpen}
onClose={onCloseWatermarkDrawer}
config={watermarkSettings}
onChange={onWatermarkChange}
/>
{/* 片头片尾配置面板 */}
<IntroOutroPanel
open={introOutroDrawerOpen}
onClose={onCloseIntroOutroDrawer}
config={introOutroSettings}
onChange={onIntroOutroChange}
/>
{/* 混剪配置面板 */}
<PipConfigPanel
open={pipDrawerOpen}
onClose={onClosePipDrawer}
config={pipSettings}
onChange={onPipChange}
totalDuration={totalDuration}
/>
{/* 滤镜调色面板 */}
<FilterPanel
open={filterDrawerOpen}
onClose={onCloseFilterDrawer}
config={filterSettings}
onChange={onFilterChange}
/>
{/* 绿幕抠像面板 */}
<GreenScreenPanel
open={chromaKeyDrawerOpen}
onClose={onCloseChromaKeyDrawer}
config={chromaKeySettings}
onChange={onChromaKeyChange}
/>
{/* 贴纸面板 */}
<StickerPanel
open={stickerDrawerOpen}
onClose={onCloseStickerDrawer}
config={stickerSettings}
onChange={onStickerChange}
totalDuration={totalDuration}
/>
</>
)
}
export default EditorDrawers
@@ -4,11 +4,8 @@
*/
import React, { useCallback } from "react"
import { Drawer, Switch } from "antd"
import type { FilterConfig, FilterPreset } from "@/pages/editing-planner/types"
import { DEFAULT_FILTER_CONFIG } from "@/pages/editing-planner/types"
import { PRESET_GRADIENTS } from "@/pages/editing-planner/constants/filter"
import FilterPresetGrid from "./filter/FilterPresetGrid"
import FilterManualAdjust from "./filter/FilterManualAdjust"
import type { FilterConfig, FilterPreset } from "../types"
import { DEFAULT_FILTER_CONFIG, FILTER_PRESET_LABELS } from "../types"
interface FilterPanelProps {
open: boolean
@@ -17,6 +14,34 @@ interface FilterPanelProps {
onChange: (config: FilterConfig) => void
}
/** 所有预设列表 */
const PRESET_LIST: FilterPreset[] = [
"none",
"original",
"fresh",
"warm",
"cool",
"vintage",
"cinema",
"bw",
"sunshine",
"film",
]
/** 预设对应的示例渐变色(用于视觉预览) */
const PRESET_GRADIENTS: Record<FilterPreset, string> = {
none: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
original: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
fresh: "linear-gradient(135deg, #a8edea 0%, #fed6e3 100%)",
warm: "linear-gradient(135deg, #f093fb 0%, #f5576c 100%)",
cool: "linear-gradient(135deg, #4facfe 0%, #00f2fe 100%)",
vintage: "linear-gradient(135deg, #c79081 0%, #dfa579 100%)",
cinema: "linear-gradient(135deg, #2c3e50 0%, #4ca1af 100%)",
bw: "linear-gradient(135deg, #434343 0%, #000000 100%)",
sunshine: "linear-gradient(135deg, #f6d365 0%, #fda085 100%)",
film: "linear-gradient(135deg, #8e9eab 0%, #eef2f3 100%)",
}
const FilterPanel: React.FC<FilterPanelProps> = ({ open, onClose, config, onChange }) => {
const update = useCallback(
(partial: Partial<FilterConfig>) => {
@@ -29,6 +54,7 @@ const FilterPanel: React.FC<FilterPanelProps> = ({ open, onClose, config, onChan
onChange({ ...DEFAULT_FILTER_CONFIG, enabled: config.enabled })
}, [config.enabled, onChange])
/** 选择预设时重置手动参数 */
const handlePresetSelect = useCallback(
(preset: FilterPreset) => {
if (preset === "none") {
@@ -44,13 +70,6 @@ const FilterPanel: React.FC<FilterPanelProps> = ({ open, onClose, config, onChan
[config.enabled, onChange],
)
const handleManualChange = useCallback(
(key: keyof FilterConfig, value: number) => {
onChange({ ...config, [key]: value })
},
[config, onChange],
)
return (
<Drawer
title="滤镜调色"
@@ -71,10 +90,110 @@ const FilterPanel: React.FC<FilterPanelProps> = ({ open, onClose, config, onChan
</div>
{/* 预设滤镜选择 */}
<FilterPresetGrid selectedPreset={config.preset} onPresetSelect={handlePresetSelect} />
<div className="filter-section">
<div className="filter-section-title"></div>
<div className="filter-presets">
{PRESET_LIST.map((p) => (
<button
key={p}
className={`filter-preset-item${config.preset === p ? " active" : ""}`}
onClick={() => handlePresetSelect(p)}
>
<div className="filter-preset-preview" style={{ background: PRESET_GRADIENTS[p] }} />
<span className="filter-preset-label">{FILTER_PRESET_LABELS[p]}</span>
</button>
))}
</div>
</div>
{/* 手动调节 */}
<FilterManualAdjust config={config} onChange={handleManualChange} />
<div className="filter-section">
<div className="filter-section-title"></div>
{/* 亮度 */}
<div className="filter-slider-row">
<span className="filter-slider-label"></span>
<input
type="range"
className="filter-slider"
min={-100}
max={100}
value={config.brightness}
onChange={(e) => update({ brightness: Number(e.target.value) })}
/>
<span className="filter-slider-value">{config.brightness}</span>
</div>
{/* 对比度 */}
<div className="filter-slider-row">
<span className="filter-slider-label"></span>
<input
type="range"
className="filter-slider"
min={-100}
max={100}
value={config.contrast}
onChange={(e) => update({ contrast: Number(e.target.value) })}
/>
<span className="filter-slider-value">{config.contrast}</span>
</div>
{/* 饱和度 */}
<div className="filter-slider-row">
<span className="filter-slider-label"></span>
<input
type="range"
className="filter-slider"
min={-100}
max={100}
value={config.saturation}
onChange={(e) => update({ saturation: Number(e.target.value) })}
/>
<span className="filter-slider-value">{config.saturation}</span>
</div>
{/* 色温 */}
<div className="filter-slider-row">
<span className="filter-slider-label"></span>
<input
type="range"
className="filter-slider"
min={-100}
max={100}
value={config.temperature}
onChange={(e) => update({ temperature: Number(e.target.value) })}
/>
<span className="filter-slider-value">{config.temperature}</span>
</div>
{/* 色调 */}
<div className="filter-slider-row">
<span className="filter-slider-label"></span>
<input
type="range"
className="filter-slider"
min={-100}
max={100}
value={config.tint}
onChange={(e) => update({ tint: Number(e.target.value) })}
/>
<span className="filter-slider-value">{config.tint}</span>
</div>
{/* 锐度 */}
<div className="filter-slider-row">
<span className="filter-slider-label"></span>
<input
type="range"
className="filter-slider"
min={0}
max={100}
value={config.sharpness}
onChange={(e) => update({ sharpness: Number(e.target.value) })}
/>
<span className="filter-slider-value">{config.sharpness}</span>
</div>
</div>
{/* 预览色块 */}
<div className="filter-section">
@@ -5,14 +5,19 @@
*/
import React, { useCallback } from "react"
import { Drawer } from "antd"
import type {
IntroOutroConfig,
IntroOutroItem,
IntroOutroKind,
} from "@/pages/editing-planner/types"
import { DEFAULT_INTRO_OUTRO } from "@/pages/editing-planner/types"
import IntroOutroBlock from "./intro-outro/IntroOutroBlock"
import type { IntroOutroConfig, IntroOutroItem, IntroOutroKind, TransitionType } from "../types"
import { DEFAULT_INTRO_OUTRO } from "../types"
import { TRANSITION_OPTIONS } from "@/api/template-editor"
/* ──────────── 常量 ──────────── */
const KIND_OPTIONS: { value: IntroOutroKind; label: string; icon: string }[] = [
{ value: "none", label: "无", icon: "🚫" },
{ value: "video", label: "视频", icon: "🎬" },
{ value: "image", label: "图片", icon: "🖼️" },
]
/* ──────────── Props ──────────── */
interface IntroOutroPanelProps {
open: boolean
onClose: () => void
@@ -68,24 +73,210 @@ const IntroOutroPanel: React.FC<IntroOutroPanelProps> = ({ open, onClose, config
className="intro-outro-panel-drawer"
>
{/* ═══ 片头区块 ═══ */}
<IntroOutroBlock
title="片头"
icon="🎞️"
item={config.intro}
transitionLabel="进入过渡动画"
onKindChange={handleIntroKindChange}
onChange={handleIntroChange}
/>
<div className="iop-block">
<div className="iop-block-header">
<span className="iop-block-icon">🎞</span>
<span className="iop-block-title"></span>
</div>
{/* 类型选择 */}
<div className="iop-kind-row">
{KIND_OPTIONS.map((opt) => (
<button
key={opt.value}
className={`iop-kind-btn${config.intro.kind === opt.value ? " active" : ""}`}
onClick={() => handleIntroKindChange(opt.value)}
>
<span className="iop-kind-icon">{opt.icon}</span>
<span className="iop-kind-label">{opt.label}</span>
</button>
))}
</div>
{/* 视频/图片配置 */}
{config.intro.kind !== "none" && (
<>
<div className="iop-field">
<label className="iop-field-label">
{config.intro.kind === "video" ? "视频" : "图片"} URL
</label>
<input
className="iop-input"
type="text"
placeholder={
config.intro.kind === "video"
? "https://example.com/intro.mp4"
: "https://example.com/intro.png"
}
value={config.intro.url ?? ""}
onChange={(e) => handleIntroChange({ url: e.target.value })}
/>
</div>
<div className="iop-field">
<label className="iop-field-label"></label>
<div className="iop-slider-row">
<input
className="iop-slider"
type="range"
min={1}
max={15}
step={0.5}
value={config.intro.duration}
onChange={(e) => handleIntroChange({ duration: Number(e.target.value) })}
/>
<span className="iop-slider-value">{config.intro.duration}s</span>
</div>
</div>
<div className="iop-field">
<label className="iop-field-label"></label>
<select
className="iop-select"
value={config.intro.transition ?? "none"}
onChange={(e) =>
handleIntroChange({
transition: e.target.value as TransitionType,
})
}
>
{TRANSITION_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
{config.intro.transition && config.intro.transition !== "none" && (
<div className="iop-field">
<label className="iop-field-label"></label>
<div className="iop-slider-row">
<input
className="iop-slider"
type="range"
min={0.3}
max={2.0}
step={0.1}
value={config.intro.transition_duration ?? 0.5}
onChange={(e) =>
handleIntroChange({
transition_duration: Number(e.target.value),
})
}
/>
<span className="iop-slider-value">
{(config.intro.transition_duration ?? 0.5).toFixed(1)}s
</span>
</div>
</div>
)}
</>
)}
</div>
{/* ═══ 片尾区块 ═══ */}
<IntroOutroBlock
title="片尾"
icon="🏁"
item={config.outro}
transitionLabel="退出过渡动画"
onKindChange={handleOutroKindChange}
onChange={handleOutroChange}
/>
<div className="iop-block">
<div className="iop-block-header">
<span className="iop-block-icon">🏁</span>
<span className="iop-block-title"></span>
</div>
{/* 类型选择 */}
<div className="iop-kind-row">
{KIND_OPTIONS.map((opt) => (
<button
key={opt.value}
className={`iop-kind-btn${config.outro.kind === opt.value ? " active" : ""}`}
onClick={() => handleOutroKindChange(opt.value)}
>
<span className="iop-kind-icon">{opt.icon}</span>
<span className="iop-kind-label">{opt.label}</span>
</button>
))}
</div>
{/* 视频/图片配置 */}
{config.outro.kind !== "none" && (
<>
<div className="iop-field">
<label className="iop-field-label">
{config.outro.kind === "video" ? "视频" : "图片"} URL
</label>
<input
className="iop-input"
type="text"
placeholder={
config.outro.kind === "video"
? "https://example.com/outro.mp4"
: "https://example.com/outro.png"
}
value={config.outro.url ?? ""}
onChange={(e) => handleOutroChange({ url: e.target.value })}
/>
</div>
<div className="iop-field">
<label className="iop-field-label"></label>
<div className="iop-slider-row">
<input
className="iop-slider"
type="range"
min={1}
max={15}
step={0.5}
value={config.outro.duration}
onChange={(e) => handleOutroChange({ duration: Number(e.target.value) })}
/>
<span className="iop-slider-value">{config.outro.duration}s</span>
</div>
</div>
<div className="iop-field">
<label className="iop-field-label">退</label>
<select
className="iop-select"
value={config.outro.transition ?? "none"}
onChange={(e) =>
handleOutroChange({
transition: e.target.value as TransitionType,
})
}
>
{TRANSITION_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
{config.outro.transition && config.outro.transition !== "none" && (
<div className="iop-field">
<label className="iop-field-label"></label>
<div className="iop-slider-row">
<input
className="iop-slider"
type="range"
min={0.3}
max={2.0}
step={0.1}
value={config.outro.transition_duration ?? 0.5}
onChange={(e) =>
handleOutroChange({
transition_duration: Number(e.target.value),
})
}
/>
<span className="iop-slider-value">
{(config.outro.transition_duration ?? 0.5).toFixed(1)}s
</span>
</div>
</div>
)}
</>
)}
</div>
{/* ── 底部操作 ── */}
<div className="iop-footer">
@@ -1,28 +0,0 @@
import React from "react"
import type { TemplateMode } from "@/api/editing-planner"
interface ModeBarProps {
modeList: { key: TemplateMode; label: string; icon: string }[]
currentMode: TemplateMode
onModeChange: (mode: TemplateMode) => void
}
const ModeBar: React.FC<ModeBarProps> = ({ modeList, currentMode, onModeChange }) => {
return (
<div className="ep-mode-bar">
<span className="ep-mode-bar-label"></span>
{modeList.map((m) => (
<button
key={m.key}
className={`ep-mode-btn ${currentMode === m.key ? "active" : ""}`}
onClick={() => onModeChange(m.key)}
>
<span className="ep-mode-icon">{m.icon}</span>
<span className="ep-mode-label">{m.label}</span>
</button>
))}
</div>
)
}
export default ModeBar
@@ -2,12 +2,65 @@
* 混剪配置面板 — Drawer 形式
* 左侧图层列表 + 右侧单图层配置 + 迷你预览区
*/
import React from "react"
import React, { useCallback, useMemo } from "react"
import { Drawer, Switch } from "antd"
import type { PipConfig } from "@/pages/editing-planner/types"
import LayerList from "./pip-config/LayerList"
import LayerConfig from "./pip-config/LayerConfig"
import { usePipLayers } from "@/pages/editing-planner/hooks/usePipLayers"
import type { PipConfig, PipLayer, PipGridPosition, PipAnimType, PipSlideDirection } from "../types"
import { DEFAULT_PIP_LAYER, DEFAULT_PIP_CONFIG } from "../types"
/* ──────────── 常量 ──────────── */
/** 九宫格位置 → 百分比坐标映射 */
const GRID_POSITION_MAP: Record<PipGridPosition, { x: number; y: number }> = {
top_left: { x: 5, y: 5 },
top_center: { x: 37.5, y: 5 },
top_right: { x: 70, y: 5 },
center_left: { x: 5, y: 37.5 },
center: { x: 37.5, y: 37.5 },
center_right: { x: 70, y: 37.5 },
bottom_left: { x: 5, y: 70 },
bottom_center: { x: 37.5, y: 70 },
bottom_right: { x: 70, y: 70 },
}
/** 九宫格位置选项 */
const GRID_POSITIONS: PipGridPosition[] = [
"top_left",
"top_center",
"top_right",
"center_left",
"center",
"center_right",
"bottom_left",
"bottom_center",
"bottom_right",
]
/** 入场动画选项 */
const ANIM_OPTIONS: { value: PipAnimType; label: string }[] = [
{ value: "none", label: "无" },
{ value: "fade_in", label: "淡入" },
{ value: "slide_in", label: "滑入" },
]
/** 滑入方向选项 */
const SLIDE_DIR_OPTIONS: { value: PipSlideDirection; label: string }[] = [
{ value: "left", label: "← 左" },
{ value: "right", label: "→ 右" },
{ value: "up", label: "↑ 上" },
{ value: "down", label: "↓ 下" },
]
/** 预览图层颜色池 */
const LAYER_COLORS = [
"rgba(22,119,255,0.5)",
"rgba(82,196,26,0.5)",
"rgba(250,173,20,0.5)",
"rgba(255,77,79,0.5)",
"rgba(114,46,209,0.5)",
"rgba(19,194,194,0.5)",
]
/* ──────────── Props ──────────── */
interface PipConfigPanelProps {
open: boolean
@@ -17,6 +70,13 @@ interface PipConfigPanelProps {
totalDuration: number
}
/* ──────────── 辅助函数 ──────────── */
let layerIdCounter = 0
const genLayerId = () => `pip_layer_${Date.now()}_${++layerIdCounter}`
/* ──────────── 组件 ──────────── */
const PipConfigPanel: React.FC<PipConfigPanelProps> = ({
open,
onClose,
@@ -24,19 +84,106 @@ const PipConfigPanel: React.FC<PipConfigPanelProps> = ({
onChange,
totalDuration,
}) => {
const {
selectedId,
setSelectedId,
selectedLayer,
handleAddLayer,
handleDeleteLayer,
updateLayer,
handleEnableToggle,
handleReset,
handleGridClick,
handleWidthChange,
handleHeightChange,
} = usePipLayers({ config, onChange })
/** 当前选中图层 ID */
const [selectedId, setSelectedId] = React.useState<string>("")
/** 当前选中图层 */
const selectedLayer = useMemo(
() => config.layers.find((l) => l.id === selectedId) ?? null,
[config.layers, selectedId],
)
/* ── 添加图层 ── */
const handleAddLayer = useCallback(() => {
const newLayer: PipLayer = {
...DEFAULT_PIP_LAYER,
id: genLayerId(),
name: `图层 ${config.layers.length + 1}`,
z_index: config.layers.length + 1,
}
onChange({
...config,
layers: [...config.layers, newLayer],
})
setSelectedId(newLayer.id)
}, [config, onChange])
/* ── 删除图层 ── */
const handleDeleteLayer = useCallback(
(id: string) => {
const newLayers = config.layers.filter((l) => l.id !== id)
onChange({ ...config, layers: newLayers })
if (selectedId === id) {
setSelectedId(newLayers.length > 0 ? newLayers[0].id : "")
}
},
[config, onChange, selectedId],
)
/* ── 更新图层 ── */
const updateLayer = useCallback(
(id: string, partial: Partial<PipLayer>) => {
onChange({
...config,
layers: config.layers.map((l) => (l.id === id ? { ...l, ...partial } : l)),
})
},
[config, onChange],
)
/* ── 切换启用 ── */
const handleEnableToggle = useCallback(
(checked: boolean) => {
onChange({ ...config, enabled: checked })
},
[config, onChange],
)
/* ── 重置 ── */
const handleReset = useCallback(() => {
onChange({ ...DEFAULT_PIP_CONFIG })
setSelectedId("")
}, [onChange])
/* ── 九宫格点击 ── */
const handleGridClick = useCallback(
(pos: PipGridPosition) => {
if (!selectedLayer) return
const coords = GRID_POSITION_MAP[pos]
updateLayer(selectedLayer.id, {
grid_position: pos,
x: coords.x,
y: coords.y,
})
},
[selectedLayer, updateLayer],
)
/* ── 宽高比锁定 ── */
const handleWidthChange = useCallback(
(val: number) => {
if (!selectedLayer) return
const partial: Partial<PipLayer> = { width: val }
if (selectedLayer.aspect_lock) {
// 保持宽高比 1:1(百分比相同)
partial.height = val
}
updateLayer(selectedLayer.id, partial)
},
[selectedLayer, updateLayer],
)
const handleHeightChange = useCallback(
(val: number) => {
if (!selectedLayer) return
const partial: Partial<PipLayer> = { height: val }
if (selectedLayer.aspect_lock) {
partial.width = val
}
updateLayer(selectedLayer.id, partial)
},
[selectedLayer, updateLayer],
)
return (
<Drawer
@@ -50,7 +197,9 @@ const PipConfigPanel: React.FC<PipConfigPanelProps> = ({
{/* ═══ 顶部工具栏 ═══ */}
<div className="pip-toolbar">
<div className="pip-toolbar-left">
<span style={{ fontSize: 13, color: "#666" }}> {config.layers.length} </span>
<button className="pip-add-btn" onClick={handleAddLayer}>
+
</button>
</div>
<div className="pip-enable-switch">
<span></span>
@@ -60,22 +209,329 @@ const PipConfigPanel: React.FC<PipConfigPanelProps> = ({
{/* ═══ 主体:图层列表 + 配置区 ═══ */}
<div className="pip-body">
<LayerList
layers={config.layers}
selectedId={selectedId}
onSelect={setSelectedId}
onAdd={handleAddLayer}
onDelete={handleDeleteLayer}
/>
<LayerConfig
layer={selectedLayer}
layers={config.layers}
totalDuration={totalDuration}
onUpdate={updateLayer}
onGridClick={handleGridClick}
onWidthChange={handleWidthChange}
onHeightChange={handleHeightChange}
/>
{/* 左侧图层列表 */}
<div className="pip-layer-list">
{config.layers.length === 0 ? (
<div className="pip-layer-empty"></div>
) : (
config.layers.map((layer, idx) => (
<div
key={layer.id}
className={`pip-layer-item${selectedId === layer.id ? " active" : ""}`}
onClick={() => setSelectedId(layer.id)}
>
{layer.thumbnail_url || layer.material_url ? (
<img
className="pip-layer-thumb"
src={layer.thumbnail_url || layer.material_url}
alt={layer.name}
/>
) : (
<div
className="pip-layer-thumb"
style={{
background: LAYER_COLORS[idx % LAYER_COLORS.length],
}}
/>
)}
<span className="pip-layer-name">{layer.name}</span>
<button
className="pip-layer-delete"
onClick={(e) => {
e.stopPropagation()
handleDeleteLayer(layer.id)
}}
title="删除图层"
>
</button>
</div>
))
)}
</div>
{/* 右侧配置区 */}
<div className="pip-config-area">
{!selectedLayer ? (
<div className="pip-config-empty"></div>
) : (
<>
{/* ── 迷你预览 ── */}
<div className="pip-preview-box">
{config.layers.map((layer, idx) => (
<div
key={layer.id}
className={`pip-preview-layer${selectedId === layer.id ? " selected" : ""}`}
style={{
left: `${layer.x}%`,
top: `${layer.y}%`,
width: `${layer.width}%`,
height: `${layer.height}%`,
background: LAYER_COLORS[idx % LAYER_COLORS.length],
opacity: layer.opacity / 100,
borderRadius: `${layer.border_radius}%`,
}}
>
<span className="pip-preview-label">{layer.name}</span>
</div>
))}
</div>
{/* ── 素材类型 ── */}
<div className="pip-field">
<label className="pip-field-label"></label>
<div className="pip-type-btns">
<button
className={`pip-type-btn${selectedLayer.material_type === "image" ? " active" : ""}`}
onClick={() => updateLayer(selectedLayer.id, { material_type: "image" })}
>
🖼
</button>
<button
className={`pip-type-btn${selectedLayer.material_type === "video" ? " active" : ""}`}
onClick={() => updateLayer(selectedLayer.id, { material_type: "video" })}
>
🎬
</button>
</div>
</div>
{/* ── 素材 URL ── */}
<div className="pip-field">
<label className="pip-field-label">
{selectedLayer.material_type === "image" ? "图片" : "视频"} URL
</label>
<input
className="pip-input"
type="text"
placeholder={
selectedLayer.material_type === "image"
? "https://example.com/image.png"
: "https://example.com/video.mp4"
}
value={selectedLayer.material_url}
onChange={(e) =>
updateLayer(selectedLayer.id, {
material_url: e.target.value,
})
}
/>
</div>
{/* ── 位置:九宫格 + 坐标 ── */}
<div className="pip-field">
<label className="pip-field-label"></label>
<div style={{ display: "flex", gap: 16, alignItems: "flex-start" }}>
<div className="pip-grid">
{GRID_POSITIONS.map((pos) => (
<button
key={pos}
className={`pip-grid-btn${selectedLayer.grid_position === pos ? " active" : ""}`}
onClick={() => handleGridClick(pos)}
>
<span className="pip-grid-dot" />
</button>
))}
</div>
<div className="pip-field-row" style={{ flex: 1 }}>
<div>
<label className="pip-field-label">X (%)</label>
<input
className="pip-number"
type="number"
min={0}
max={100}
value={selectedLayer.x}
onChange={(e) =>
updateLayer(selectedLayer.id, {
x: Number(e.target.value),
})
}
/>
</div>
<div>
<label className="pip-field-label">Y (%)</label>
<input
className="pip-number"
type="number"
min={0}
max={100}
value={selectedLayer.y}
onChange={(e) =>
updateLayer(selectedLayer.id, {
y: Number(e.target.value),
})
}
/>
</div>
</div>
</div>
</div>
{/* ── 尺寸 ── */}
<div className="pip-field">
<label className="pip-field-label"></label>
<div className="pip-slider-row">
<span style={{ fontSize: 12, color: "#999", width: 20 }}></span>
<input
className="pip-slider"
type="range"
min={10}
max={80}
value={selectedLayer.width}
onChange={(e) => handleWidthChange(Number(e.target.value))}
/>
<span className="pip-slider-value">{selectedLayer.width}%</span>
</div>
<div className="pip-slider-row" style={{ marginTop: 6 }}>
<span style={{ fontSize: 12, color: "#999", width: 20 }}></span>
<input
className="pip-slider"
type="range"
min={10}
max={80}
value={selectedLayer.height}
onChange={(e) => handleHeightChange(Number(e.target.value))}
/>
<span className="pip-slider-value">{selectedLayer.height}%</span>
</div>
<div
className="pip-lock-row"
style={{ marginTop: 6 }}
onClick={() =>
updateLayer(selectedLayer.id, {
aspect_lock: !selectedLayer.aspect_lock,
})
}
>
<span className="pip-lock-icon">{selectedLayer.aspect_lock ? "🔒" : "🔓"}</span>
<span>{selectedLayer.aspect_lock ? "已锁定比例" : "锁定宽高比"}</span>
</div>
</div>
{/* ── 圆角 ── */}
<div className="pip-field">
<label className="pip-field-label"></label>
<div className="pip-slider-row">
<input
className="pip-slider"
type="range"
min={0}
max={50}
value={selectedLayer.border_radius}
onChange={(e) =>
updateLayer(selectedLayer.id, {
border_radius: Number(e.target.value),
})
}
/>
<span className="pip-slider-value">{selectedLayer.border_radius}%</span>
</div>
</div>
{/* ── 透明度 ── */}
<div className="pip-field">
<label className="pip-field-label"></label>
<div className="pip-slider-row">
<input
className="pip-slider"
type="range"
min={0}
max={100}
value={selectedLayer.opacity}
onChange={(e) =>
updateLayer(selectedLayer.id, {
opacity: Number(e.target.value),
})
}
/>
<span className="pip-slider-value">{selectedLayer.opacity}%</span>
</div>
</div>
{/* ── 时间 ── */}
<div className="pip-field">
<label className="pip-field-label"></label>
<div className="pip-field-row">
<div>
<label className="pip-field-label"> (s)</label>
<input
className="pip-number"
type="number"
min={0}
max={totalDuration || 999}
step={0.1}
value={selectedLayer.start_time}
onChange={(e) =>
updateLayer(selectedLayer.id, {
start_time: Number(e.target.value),
})
}
/>
</div>
<div>
<label className="pip-field-label"> (s)</label>
<input
className="pip-number"
type="number"
min={0.1}
max={totalDuration || 999}
step={0.1}
value={selectedLayer.duration}
onChange={(e) =>
updateLayer(selectedLayer.id, {
duration: Number(e.target.value),
})
}
/>
</div>
</div>
</div>
{/* ── 入场动画 ── */}
<div className="pip-field">
<label className="pip-field-label"></label>
<select
className="pip-select"
value={selectedLayer.animation}
onChange={(e) =>
updateLayer(selectedLayer.id, {
animation: e.target.value as PipAnimType,
})
}
>
{ANIM_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
{/* 滑入方向(仅 slide_in 时显示) */}
{selectedLayer.animation === "slide_in" && (
<div className="pip-field">
<label className="pip-field-label"></label>
<select
className="pip-select"
value={selectedLayer.slide_direction}
onChange={(e) =>
updateLayer(selectedLayer.id, {
slide_direction: e.target.value as PipSlideDirection,
})
}
>
{SLIDE_DIR_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
)}
</>
)}
</div>
</div>
{/* ── 底部操作 ── */}
@@ -1,169 +0,0 @@
import React from "react"
import ClipPropertiesPanel from "./ClipPropertiesPanel"
import EditorClipList from "./EditorClipList"
import type { ClipData } from "../types"
import type { SubtitleStyleConfig } from "../types/subtitle"
import type { BgmMixConfig } from "@/api/bgm"
import type { TemplateMode } from "@/api/editing-planner"
import type { AssetItem } from "@/api/assets"
interface RightPanelProps {
rightTab: "properties" | "clips"
onTabChange: (tab: "properties" | "clips") => void
// 属性 tab
selectedClip: ClipData | null
subtitleSettings: SubtitleStyleConfig
bgmSettings: BgmMixConfig
clipsCount: number
totalDuration: number
currentMode: TemplateMode
onSubtitleSettingsChange: (partial: Partial<SubtitleStyleConfig>) => void
onBgmSettingsChange: (partial: Partial<BgmMixConfig>) => void
onClipUpdate: (clipId: string, updates: Partial<ClipData>) => void
onOpenBgmDrawer: () => void
onOpenSubtitleDrawer: () => void
voiceMaterials: AssetItem[]
voiceMaterialsLoading: boolean
onRefreshVoiceMaterials: () => void
onClipVoiceSelect: (clipId: string, asset: AssetItem | null) => void
onOpenTransitionDrawer: (clipId?: string) => void
onOpenSpeedDrawer: (clipId: string) => void
onOpenTtsDrawer: (clipId: string) => void
onOpenWatermarkDrawer: () => void
onOpenIntroOutroDrawer: () => void
onOpenPipDrawer: () => void
onOpenFilterDrawer: () => void
onOpenGreenScreenDrawer: () => void
onOpenStickerDrawer: () => void
// 片段 tab
clips: ClipData[]
selectedClipId: string | null
onClipSelect: (clipId: string) => void
onClipMoveUp: (clipId: string) => void
onClipMoveDown: (clipId: string) => void
onClipRemove: (clipId: string) => void
onClipAdd: () => void
}
const RightPanel: React.FC<RightPanelProps> = ({
rightTab,
onTabChange,
selectedClip,
subtitleSettings,
bgmSettings,
clipsCount,
totalDuration,
currentMode,
onSubtitleSettingsChange,
onBgmSettingsChange,
onClipUpdate,
onOpenBgmDrawer,
onOpenSubtitleDrawer,
voiceMaterials,
voiceMaterialsLoading,
onRefreshVoiceMaterials,
onClipVoiceSelect,
onOpenTransitionDrawer,
onOpenSpeedDrawer,
onOpenTtsDrawer,
onOpenWatermarkDrawer,
onOpenIntroOutroDrawer,
onOpenPipDrawer,
onOpenFilterDrawer,
onOpenGreenScreenDrawer,
onOpenStickerDrawer,
clips,
selectedClipId,
onClipSelect,
onClipMoveUp,
onClipMoveDown,
onClipRemove,
onClipAdd,
}) => {
return (
<div className="ep-right-panel">
{/* Tab 切换 */}
<div className="ep-right-tabs">
<button
className={`ep-right-tab${rightTab === "properties" ? " active" : ""}`}
onClick={() => onTabChange("properties")}
>
</button>
<button
className={`ep-right-tab${rightTab === "clips" ? " active" : ""}`}
onClick={() => onTabChange("clips")}
>
</button>
</div>
{/* 属性 Tab */}
{rightTab === "properties" && (
<div className="ep-right-tab-content">
{(() => {
// ClipPropertiesPanel 内部类型与主文件类型结构一致但字段细节不同
// 使用 unknown 作为中间类型避免 any 警告
const sub = subtitleSettings as unknown as React.ComponentProps<
typeof ClipPropertiesPanel
>["subtitleSettings"]
const bgm = bgmSettings as unknown as React.ComponentProps<
typeof ClipPropertiesPanel
>["bgmSettings"]
const onSubChange = onSubtitleSettingsChange as unknown as React.ComponentProps<
typeof ClipPropertiesPanel
>["onSubtitleSettingsChange"]
const onBgmChange = onBgmSettingsChange as unknown as React.ComponentProps<
typeof ClipPropertiesPanel
>["onBgmSettingsChange"]
return (
<ClipPropertiesPanel
selectedClip={selectedClip}
subtitleSettings={sub}
bgmSettings={bgm}
clipsCount={clipsCount}
totalDuration={totalDuration}
currentMode={currentMode}
onSubtitleSettingsChange={onSubChange}
onBgmSettingsChange={onBgmChange}
onClipUpdate={onClipUpdate}
onOpenBgmDrawer={onOpenBgmDrawer}
onOpenSubtitleDrawer={onOpenSubtitleDrawer}
voiceMaterials={voiceMaterials}
voiceMaterialsLoading={voiceMaterialsLoading}
onRefreshVoiceMaterials={onRefreshVoiceMaterials}
onClipVoiceSelect={onClipVoiceSelect}
onOpenTransitionDrawer={onOpenTransitionDrawer}
onOpenSpeedDrawer={onOpenSpeedDrawer}
onOpenTtsDrawer={onOpenTtsDrawer}
onOpenWatermarkDrawer={onOpenWatermarkDrawer}
onOpenIntroOutroDrawer={onOpenIntroOutroDrawer}
onOpenPipDrawer={onOpenPipDrawer}
onOpenFilterDrawer={onOpenFilterDrawer}
onOpenGreenScreenDrawer={onOpenGreenScreenDrawer}
onOpenStickerDrawer={onOpenStickerDrawer}
/>
)
})()}
</div>
)}
{/* 片段 Tab */}
{rightTab === "clips" && (
<div className="ep-right-tab-content">
<EditorClipList
clips={clips}
selectedClipId={selectedClipId}
onSelect={onClipSelect}
onMoveUp={onClipMoveUp}
onMoveDown={onClipMoveDown}
onRemove={onClipRemove}
onAdd={onClipAdd}
/>
</div>
)}
</div>
)
}
export default RightPanel
@@ -1,32 +0,0 @@
import React from "react"
interface StatusBarProps {
clipsCount: number
totalDuration: number
currentModeLabel: string
templateSegments: number
}
const StatusBar: React.FC<StatusBarProps> = ({
clipsCount,
totalDuration,
currentModeLabel,
templateSegments,
}) => {
return (
<div className="ep-status-bar">
<div className="ep-status-left">
<span>📋 : {clipsCount}</span>
<span className="ep-status-sep">|</span>
<span> : {totalDuration.toFixed(1)}s</span>
</div>
<div className="ep-status-right">
<span>🎬 {currentModeLabel}</span>
<span className="ep-status-sep">|</span>
<span>📐 : {templateSegments}</span>
</div>
</div>
)
}
export default StatusBar
@@ -2,13 +2,10 @@
* 贴纸配置面板
* 贴纸素材库(emoji / 图片)+ 文字花字 + 位置大小调整
*/
import React from "react"
import React, { useCallback, useState } from "react"
import { Drawer, Switch } from "antd"
import type { StickerConfig } from "@/pages/editing-planner/types"
import StickerLibrary from "./sticker/StickerLibrary"
import StickerList from "./sticker/StickerList"
import StickerPropsEditor from "./sticker/StickerPropsEditor"
import { useStickerItems } from "@/pages/editing-planner/hooks/useStickerItems"
import type { StickerConfig, StickerItem, StickerType, TextStickerPreset } from "../types"
import { DEFAULT_STICKER_CONFIG, DEFAULT_STICKER_ITEM, TEXT_STICKER_PRESET_LABELS } from "../types"
interface StickerPanelProps {
open: boolean
@@ -18,6 +15,59 @@ interface StickerPanelProps {
totalDuration: number
}
/** 常用 emoji 素材 */
const EMOJI_LIST = [
"😀",
"😂",
"🥰",
"😎",
"🤩",
"😱",
"🤔",
"😴",
"🥳",
"😍",
"❤️",
"🔥",
"⭐",
"✨",
"💯",
"👍",
"👏",
"🎉",
"🎵",
"💪",
"📌",
"💡",
"🎯",
"✅",
"❌",
"⬆️",
"⬇️",
"➡️",
"⭕",
"🔔",
]
/** 文字花字预设对应的 CSS 样式预览 */
const TEXT_PRESET_STYLES: Record<TextStickerPreset, React.CSSProperties> = {
normal: { color: "#fff", textShadow: "none" },
highlight: { color: "#FFD700", textShadow: "0 0 8px rgba(255,215,0,0.6)" },
bubble: { color: "#fff", background: "rgba(0,0,0,0.5)", borderRadius: 8 },
neon: { color: "#0ff", textShadow: "0 0 6px #0ff, 0 0 12px #0ff" },
shadow: { color: "#fff", textShadow: "2px 2px 4px rgba(0,0,0,0.8)" },
outline: { color: "#fff", WebkitTextStroke: "1px #000" },
gradient: {
color: "transparent",
background: "linear-gradient(90deg,#f093fb,#f5576c)",
WebkitBackgroundClip: "text",
},
handwrite: { color: "#333", fontStyle: "italic", fontFamily: "cursive" },
}
/** 生成唯一 ID */
const genId = () => `sticker_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
const StickerPanel: React.FC<StickerPanelProps> = ({
open,
onClose,
@@ -25,17 +75,63 @@ const StickerPanel: React.FC<StickerPanelProps> = ({
onChange,
totalDuration,
}) => {
const {
selectedId,
setSelectedId,
activeTab,
setActiveTab,
selectedSticker,
updateItem,
addSticker,
removeSticker,
handleReset,
} = useStickerItems({ config, onChange, totalDuration })
const [selectedId, setSelectedId] = useState<string | null>(null)
const [activeTab, setActiveTab] = useState<StickerType>("emoji")
const selectedSticker = config.items.find((s) => s.id === selectedId) ?? null
/** 更新单个贴纸 */
const updateItem = useCallback(
(id: string, partial: Partial<StickerItem>) => {
onChange({
...config,
items: config.items.map((s) => (s.id === id ? { ...s, ...partial } : s)),
})
},
[config, onChange],
)
/** 添加贴纸 */
const addSticker = useCallback(
(type: StickerType, content: string) => {
const newItem: StickerItem = {
...DEFAULT_STICKER_ITEM,
id: genId(),
type,
content,
duration: totalDuration > 0 ? totalDuration : 5,
z_index: config.items.length + 1,
}
onChange({
...config,
enabled: true,
items: [...config.items, newItem],
})
setSelectedId(newItem.id)
},
[config, onChange, totalDuration],
)
/** 删除贴纸 */
const removeSticker = useCallback(
(id: string) => {
onChange({
...config,
items: config.items.filter((s) => s.id !== id),
})
if (selectedId === id) setSelectedId(null)
},
[config, onChange, selectedId],
)
/** 重置所有 */
const handleReset = useCallback(() => {
onChange({ ...DEFAULT_STICKER_CONFIG, enabled: config.enabled })
setSelectedId(null)
}, [config.enabled, onChange])
/** 文字花字输入 */
const [textInput, setTextInput] = useState("")
return (
<Drawer
@@ -56,24 +152,355 @@ const StickerPanel: React.FC<StickerPanelProps> = ({
/>
</div>
{/* 素材库 + 类型Tab */}
<StickerLibrary activeTab={activeTab} onTabChange={setActiveTab} onAddSticker={addSticker} />
{/* 类型 Tab */}
<div className="sticker-tabs">
{(["emoji", "image", "text"] as StickerType[]).map((t) => (
<button
key={t}
className={`sticker-tab${activeTab === t ? " active" : ""}`}
onClick={() => setActiveTab(t)}
>
{t === "emoji" ? "表情贴纸" : t === "image" ? "图片贴纸" : "文字花字"}
</button>
))}
</div>
{/* Tab 内容区 */}
<div className="sticker-tab-content">
{/* Emoji 素材库 */}
{activeTab === "emoji" && (
<div className="sticker-emoji-grid">
{EMOJI_LIST.map((emoji) => (
<button
key={emoji}
className="sticker-emoji-btn"
onClick={() => addSticker("emoji", emoji)}
>
{emoji}
</button>
))}
</div>
)}
{/* 图片贴纸 */}
{activeTab === "image" && (
<div className="sticker-image-input">
<input
type="text"
className="sticker-url-input"
placeholder="输入图片 URL 添加贴纸..."
onKeyDown={(e) => {
if (e.key === "Enter" && e.currentTarget.value.trim()) {
addSticker("image", e.currentTarget.value.trim())
e.currentTarget.value = ""
}
}}
/>
<button
className="sticker-url-add-btn"
onClick={() => {
const input = document.querySelector<HTMLInputElement>(".sticker-url-input")
if (input?.value.trim()) {
addSticker("image", input.value.trim())
input.value = ""
}
}}
>
</button>
</div>
)}
{/* 文字花字 */}
{activeTab === "text" && (
<div className="sticker-text-section">
<div className="sticker-text-input-row">
<input
type="text"
className="sticker-text-input"
placeholder="输入文字内容..."
value={textInput}
onChange={(e) => setTextInput(e.target.value)}
/>
<button
className="sticker-text-add-btn"
disabled={!textInput.trim()}
onClick={() => {
if (textInput.trim()) {
addSticker("text", textInput.trim())
setTextInput("")
}
}}
>
</button>
</div>
<div className="sticker-text-presets">
<div className="sticker-preset-title"></div>
<div className="sticker-preset-grid">
{(Object.keys(TEXT_STICKER_PRESET_LABELS) as TextStickerPreset[]).map((p) => (
<div
key={p}
className="sticker-preset-preview"
style={{
...TEXT_PRESET_STYLES[p],
background:
p === "bubble"
? "rgba(0,0,0,0.5)"
: p === "gradient"
? "linear-gradient(90deg,#f093fb,#f5576c)"
: "#1a1a2e",
}}
>
<span style={TEXT_PRESET_STYLES[p]}></span>
<div className="sticker-preset-name">{TEXT_STICKER_PRESET_LABELS[p]}</div>
</div>
))}
</div>
</div>
</div>
)}
</div>
{/* 已添加贴纸列表 */}
<StickerList
items={config.items}
selectedId={selectedId}
onSelect={setSelectedId}
onDelete={removeSticker}
/>
{config.items.length > 0 && (
<div className="sticker-list-section">
<div className="sticker-section-title"> ({config.items.length})</div>
<div className="sticker-list">
{config.items.map((item) => (
<div
key={item.id}
className={`sticker-list-item${selectedId === item.id ? " active" : ""}`}
onClick={() => setSelectedId(item.id)}
>
<span className="sticker-list-icon">
{item.type === "emoji" ? item.content : item.type === "text" ? "T" : "🖼"}
</span>
<span className="sticker-list-name">
{item.type === "text"
? item.content.slice(0, 10)
: item.type === "emoji"
? "表情贴纸"
: "图片贴纸"}
</span>
<button
className="sticker-list-delete"
onClick={(e) => {
e.stopPropagation()
removeSticker(item.id)
}}
>
</button>
</div>
))}
</div>
</div>
)}
{/* 选中贴纸的属性编辑 */}
{selectedSticker && (
<StickerPropsEditor
sticker={selectedSticker}
totalDuration={totalDuration}
onUpdate={updateItem}
/>
<div className="sticker-props-section">
<div className="sticker-section-title"></div>
{/* 位置 */}
<div className="sticker-prop-row">
<span className="sticker-prop-label"> X</span>
<input
type="range"
className="sticker-prop-slider"
min={0}
max={100}
value={selectedSticker.x}
onChange={(e) => updateItem(selectedSticker.id, { x: Number(e.target.value) })}
/>
<span className="sticker-prop-value">{selectedSticker.x}%</span>
</div>
<div className="sticker-prop-row">
<span className="sticker-prop-label"> Y</span>
<input
type="range"
className="sticker-prop-slider"
min={0}
max={100}
value={selectedSticker.y}
onChange={(e) => updateItem(selectedSticker.id, { y: Number(e.target.value) })}
/>
<span className="sticker-prop-value">{selectedSticker.y}%</span>
</div>
{/* 尺寸 */}
<div className="sticker-prop-row">
<span className="sticker-prop-label"></span>
<input
type="range"
className="sticker-prop-slider"
min={5}
max={50}
value={selectedSticker.width}
onChange={(e) =>
updateItem(selectedSticker.id, {
width: Number(e.target.value),
height: Number(e.target.value),
})
}
/>
<span className="sticker-prop-value">{selectedSticker.width}%</span>
</div>
{/* 旋转 */}
<div className="sticker-prop-row">
<span className="sticker-prop-label"></span>
<input
type="range"
className="sticker-prop-slider"
min={-180}
max={180}
value={selectedSticker.rotation}
onChange={(e) =>
updateItem(selectedSticker.id, {
rotation: Number(e.target.value),
})
}
/>
<span className="sticker-prop-value">{selectedSticker.rotation}°</span>
</div>
{/* 透明度 */}
<div className="sticker-prop-row">
<span className="sticker-prop-label"></span>
<input
type="range"
className="sticker-prop-slider"
min={0}
max={100}
value={selectedSticker.opacity}
onChange={(e) =>
updateItem(selectedSticker.id, {
opacity: Number(e.target.value),
})
}
/>
<span className="sticker-prop-value">{selectedSticker.opacity}%</span>
</div>
{/* 时间 */}
<div className="sticker-prop-row">
<span className="sticker-prop-label"></span>
<input
type="number"
className="sticker-prop-number"
min={0}
max={totalDuration}
step={0.1}
value={selectedSticker.start_time}
onChange={(e) =>
updateItem(selectedSticker.id, {
start_time: Number(e.target.value),
})
}
/>
<span className="sticker-prop-label"></span>
<input
type="number"
className="sticker-prop-number"
min={0}
max={totalDuration}
step={0.1}
value={selectedSticker.duration}
onChange={(e) =>
updateItem(selectedSticker.id, {
duration: Number(e.target.value),
})
}
/>
</div>
{/* 文字贴纸特有属性 */}
{selectedSticker.type === "text" && (
<>
<div className="sticker-prop-row">
<span className="sticker-prop-label"></span>
<select
className="sticker-prop-select"
value={selectedSticker.text_preset}
onChange={(e) =>
updateItem(selectedSticker.id, {
text_preset: e.target.value as TextStickerPreset,
})
}
>
{(Object.keys(TEXT_STICKER_PRESET_LABELS) as TextStickerPreset[]).map((p) => (
<option key={p} value={p}>
{TEXT_STICKER_PRESET_LABELS[p]}
</option>
))}
</select>
</div>
<div className="sticker-prop-row">
<span className="sticker-prop-label"></span>
<input
type="range"
className="sticker-prop-slider"
min={12}
max={72}
value={selectedSticker.font_size}
onChange={(e) =>
updateItem(selectedSticker.id, {
font_size: Number(e.target.value),
})
}
/>
<span className="sticker-prop-value">{selectedSticker.font_size}px</span>
</div>
<div className="sticker-prop-row">
<span className="sticker-prop-label"></span>
<input
type="color"
className="sticker-prop-color"
value={selectedSticker.text_color}
onChange={(e) =>
updateItem(selectedSticker.id, {
text_color: e.target.value,
})
}
/>
</div>
</>
)}
{/* 预览 */}
<div className="sticker-preview-box">
<div
className="sticker-preview-item"
style={{
left: `${selectedSticker.x}%`,
top: `${selectedSticker.y}%`,
width: `${selectedSticker.width}%`,
height: `${selectedSticker.width}%`,
transform: `translate(-50%, -50%) rotate(${selectedSticker.rotation}deg)`,
opacity: selectedSticker.opacity / 100,
fontSize:
selectedSticker.type === "text" ? `${selectedSticker.font_size}px` : undefined,
...TEXT_PRESET_STYLES[selectedSticker.text_preset],
}}
>
{selectedSticker.type === "emoji" && selectedSticker.content}
{selectedSticker.type === "text" && selectedSticker.content}
{selectedSticker.type === "image" && (
<img
src={selectedSticker.content}
alt="sticker"
style={{
width: "100%",
height: "100%",
objectFit: "contain",
}}
/>
)}
</div>
</div>
</div>
)}
{/* 底部重置 */}
@@ -5,14 +5,31 @@
import React from "react"
import { Drawer, Slider, ColorPicker, Select } from "antd"
import type { Color } from "antd/es/color-picker"
import type { SubtitleStyleConfig } from "@/pages/editing-planner/types/subtitle"
import {
POSITION_OPTIONS,
FONT_OPTIONS,
ANIMATION_OPTIONS,
ASR_LANGUAGE_OPTIONS,
} from "@/pages/editing-planner/constants/subtitleStyle"
import SubtitlePreview from "./subtitle-style/SubtitlePreview"
import type { SubtitleStyleConfig } from "../types/subtitle"
/* ──────────── 选项常量 ──────────── */
const POSITION_OPTIONS = [
{ value: "top", label: "顶部" },
{ value: "center", label: "居中" },
{ value: "bottom", label: "底部" },
]
const FONT_OPTIONS = ["思源黑体", "思源宋体", "苹方", "PingFang", "微软雅黑", "楷体", "华康俪金黑"]
const ANIMATION_OPTIONS = [
{ value: "none", label: "无" },
{ value: "fade", label: "淡入淡出" },
{ value: "slide", label: "滑动" },
{ value: "typewriter", label: "打字机" },
]
const ASR_LANGUAGE_OPTIONS = [
{ value: "zh", label: "中文" },
{ value: "en", label: "English" },
]
/* ──────────── Props ──────────── */
interface SubtitleStylePanelProps {
open: boolean
@@ -180,7 +197,23 @@ const SubtitleStylePanel: React.FC<SubtitleStylePanelProps> = ({
</div>
{/* ── 预览 ── */}
<SubtitlePreview config={config} />
<div className="sub-field">
<label className="sub-label"></label>
<div className="sub-preview-box">
<span
className="sub-preview-text"
style={{
fontSize: `${Math.min(config.fontSize, 28)}px`,
color: config.fontColor,
fontFamily: config.font,
WebkitTextStroke: config.stroke ? "1px #000" : undefined,
textShadow: config.shadow ? "2px 2px 4px rgba(0,0,0,0.8)" : undefined,
}}
>
</span>
</div>
</div>
</>
)}
</Drawer>
@@ -10,24 +10,7 @@
*/
import React, { useState, useRef, useCallback, useEffect, useLayoutEffect, useMemo } from "react"
import type { ClipData, ClipType, TrimConfig } from "../types"
import {
DEFAULT_PIXELS_PER_SECOND,
MIN_PIXELS_PER_SECOND,
MAX_PIXELS_PER_SECOND,
ZOOM_STEP,
MIN_TRIM_DURATION,
DEFAULT_ADD_DURATION,
MIN_ADD_DURATION,
MAX_ADD_DURATION,
TRACK_GAP,
ADD_PICKER_WIDTH,
} from "../constants/timeline"
import { formatTime } from "../utils/timeline"
import { ClipCard } from "./timeline/ClipCard"
import { TimeRuler } from "./timeline/TimeRuler"
import { AddClipPicker } from "./timeline/AddClipPicker"
import { TrimPreview } from "./timeline/TrimPreview"
import { ContextMenu } from "./timeline/ContextMenu"
import { TRANSITION_OPTIONS } from "@/api/template-editor"
interface TimelinePanelProps {
clips: ClipData[]
@@ -55,6 +38,18 @@ interface TimelinePanelProps {
totalDuration?: number
}
/** 片段类型图标 */
const CLIP_TYPE_ICONS: Record<ClipType, string> = {
voice: "🎙️",
pip: "🖼️",
}
/** 片段类型标签 */
const CLIP_TYPE_LABELS: Record<ClipType, string> = {
voice: "口播",
pip: "混剪",
}
/** 裁剪拖拽方向 */
type TrimDirection = "left" | "right"
@@ -140,7 +135,7 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
/* ── "+" 卡片:类型+时长选择状态 ── */
const [addType, setAddType] = useState<ClipType>(defaultAddType)
const [addDuration, setAddDuration] = useState<number>(DEFAULT_ADD_DURATION)
const [addDuration, setAddDuration] = useState<number>(5)
/* ── 模式切换时自动同步默认添加类型 ── */
useEffect(() => {
@@ -149,17 +144,21 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
}
}, [currentMode, addType, availableTypes, defaultAddType])
/* ── 面板尺寸 ── */
const PICKER_W = 240
const GAP = 6
/* ── 计算 picker 初始位置 ── */
const updatePickerPosition = useCallback(() => {
if (!addCardRef.current) return
const rect = addCardRef.current.getBoundingClientRect()
const vw = window.innerWidth
const roughHeight = 180
let top = rect.top - TRACK_GAP - roughHeight
let top = rect.top - GAP - roughHeight
if (top < 8) top = 8
let right = vw - rect.right
if (rect.right - ADD_PICKER_WIDTH < 8) {
right = vw - ADD_PICKER_WIDTH - 8
if (rect.right - PICKER_W < 8) {
right = vw - PICKER_W - 8
}
setPickerPos({ top, right })
}, [])
@@ -182,9 +181,9 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
const pickerH = pickerEl.offsetHeight
const vh = window.innerHeight
const vw = window.innerWidth
let top = addRect.top - TRACK_GAP - pickerH
let top = addRect.top - GAP - pickerH
if (top < 8) {
top = addRect.bottom + TRACK_GAP
top = addRect.bottom + GAP
if (top + pickerH > vh - 8) {
top = vh - 8 - pickerH
if (top < 8) top = 8
@@ -193,7 +192,7 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
let right = vw - addRect.right
const pickerRect = pickerEl.getBoundingClientRect()
if (pickerRect.left < 8) {
right = vw - ADD_PICKER_WIDTH - 8
right = vw - PICKER_W - 8
}
setPickerPos({ top, right })
}, [showAddPicker])
@@ -225,7 +224,7 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
}, [contextMenu])
/* ── 缩放 & 时长 ── */
const pps = pixelsPerSecond ?? DEFAULT_PIXELS_PER_SECOND
const pps = pixelsPerSecond ?? 40
const totalDuration = totalDurationProp ?? clips.reduce((s, c) => s + c.duration, 0)
/* ── 播放头拖拽全局 mousemove/mouseup ── */
@@ -345,9 +344,11 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
useEffect(() => {
if (!trimDrag) return
const PX_PER_SECOND = pixelsPerSecond ?? 40 // 与缩放级别同步
const handleMouseMove = (e: MouseEvent) => {
const dx = e.clientX - trimDrag.startX
const dtSec = dx / pps
const dtSec = dx / PX_PER_SECOND
const clip = clips.find((c) => c.id === trimDrag.clipId)
if (!clip) return
@@ -358,13 +359,10 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
if (trimDrag.direction === "left") {
// 左手柄:调整入点
newStart = Math.max(0, Math.min(origTrim.start_time + dtSec, newEnd - MIN_TRIM_DURATION))
newStart = Math.max(0, Math.min(origTrim.start_time + dtSec, newEnd - 1))
} else {
// 右手柄:调整出点
newEnd = Math.max(
origTrim.start_time + MIN_TRIM_DURATION,
Math.min(origTrim.end_time + dtSec, origDur),
)
newEnd = Math.max(origTrim.start_time + 1, Math.min(origTrim.end_time + dtSec, origDur))
}
const newDuration = Math.round((newEnd - newStart) * 10) / 10
@@ -398,7 +396,7 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
document.removeEventListener("mousemove", handleMouseMove)
document.removeEventListener("mouseup", handleMouseUp)
}
}, [trimDrag, trimPreview, clips, onClipTrim, pps])
}, [trimDrag, trimPreview, clips, onClipTrim, pixelsPerSecond])
/* ── 右键菜单 ── */
const handleContextMenu = useCallback((e: React.MouseEvent, clipId: string) => {
@@ -430,6 +428,25 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
setContextMenu(null)
}, [contextMenu, onClipRemove])
/* ── 时间标尺 ── */
const trackWidth = Math.max(totalDuration * pps, 300)
const rulerMarks: number[] = []
const step = totalDuration <= 30 ? 5 : totalDuration <= 60 ? 10 : 15
for (let t = 0; t <= totalDuration + step; t += step) {
rulerMarks.push(t)
}
const formatTime = (sec: number) => {
const m = Math.floor(sec / 60)
const s = Math.floor(sec % 60)
return `${m}:${s.toString().padStart(2, "0")}`
}
/** 格式化裁剪时间(精确到0.1秒) */
const formatTrimTime = (sec: number) => {
return `${sec.toFixed(1)}s`
}
return (
<div className="ep-timeline-area">
{/* 时间线头部 */}
@@ -443,7 +460,7 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
<div className="ep-timeline-zoom">
<button
className="ep-zoom-btn"
onClick={() => onZoomChange?.(Math.max(MIN_PIXELS_PER_SECOND, pps - ZOOM_STEP))}
onClick={() => onZoomChange?.(Math.max(10, pps - 10))}
title="缩小"
>
@@ -451,15 +468,15 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
<input
type="range"
className="ep-zoom-slider"
min={MIN_PIXELS_PER_SECOND}
max={MAX_PIXELS_PER_SECOND}
min={10}
max={120}
step={5}
value={pps}
onChange={(e) => onZoomChange?.(Number(e.target.value))}
/>
<button
className="ep-zoom-btn"
onClick={() => onZoomChange?.(Math.min(MAX_PIXELS_PER_SECOND, pps + ZOOM_STEP))}
onClick={() => onZoomChange?.(Math.min(120, pps + 10))}
title="放大"
>
+
@@ -489,7 +506,15 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
{/* 时间标尺 */}
{currentMode !== "one_take" && (
<TimeRuler totalDuration={totalDuration} pps={pps} onClick={handleRulerClick} />
<div className="ep-time-ruler" onClick={handleRulerClick}>
<div className="ep-time-ruler-inner" style={{ width: trackWidth }}>
{rulerMarks.map((t) => (
<span key={t} className="ep-time-mark" style={{ left: `${t * pps}px` }}>
{t}s
</span>
))}
</div>
</div>
)}
{/* 水平片段轨道 */}
@@ -511,30 +536,115 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
<div className="ep-track-empty-text"> + </div>
</div>
) : (
clips.map((clip, idx) => (
<ClipCard
key={clip.id}
clip={clip}
idx={idx}
isSelected={selectedClipId === clip.id}
isDragging={dragIdx === idx}
isDragOver={dragOverIdx === idx}
isHovered={hoveredClipId === clip.id}
pps={pps}
trimDragActive={!!trimDrag}
showTrimHandles={!!onClipTrim}
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDragEnd={handleDragEnd}
onDrop={handleDrop}
onSelect={onClipSelect}
onContextMenu={handleContextMenu}
onMouseEnter={() => setHoveredClipId(clip.id)}
onMouseLeave={() => setHoveredClipId(null)}
onTrimHandleMouseDown={handleTrimHandleMouseDown}
onRemove={onClipRemove}
/>
))
clips.map((clip, idx) => {
/* 转场指示器 */
const trans = clip.transition
const showTransition = idx > 0 && trans && trans.type !== "none"
const transOpt = showTransition
? TRANSITION_OPTIONS.find((o) => o.value === trans!.type)
: undefined
/* 速度徽章 */
const speed = clip.speed
const showSpeed = speed && Math.abs(speed.rate - 1.0) > 0.01
/* 裁剪状态 */
const hasTrim = !!clip.trim_config
const isHovered = hoveredClipId === clip.id
return (
<React.Fragment key={clip.id}>
{/* 转场指示器 */}
{showTransition && transOpt && (
<div
className="ep-transition-indicator"
title={`${transOpt.label} · ${trans!.duration.toFixed(1)}s`}
>
<span className="ep-trans-icon">{transOpt.icon}</span>
<span className="ep-trans-duration">{trans!.duration.toFixed(1)}s</span>
</div>
)}
<div
className={`ep-clip-card ${selectedClipId === clip.id ? "selected" : ""} ${dragIdx === idx ? "dragging" : ""} ${dragOverIdx === idx ? "drag-over" : ""} ${hasTrim ? "trimmed" : ""}`}
style={{ width: Math.max(clip.duration * pps, 60) }}
draggable={!trimDrag}
onDragStart={(e) => handleDragStart(e, idx)}
onDragOver={(e) => handleDragOver(e, idx)}
onDragEnd={handleDragEnd}
onDrop={(e) => handleDrop(e, idx)}
onClick={() => onClipSelect(clip.id)}
onContextMenu={(e) => handleContextMenu(e, clip.id)}
onMouseEnter={() => setHoveredClipId(clip.id)}
onMouseLeave={() => setHoveredClipId(null)}
>
{/* 左裁剪手柄 */}
{isHovered && onClipTrim && (
<div
className="ep-trim-handle ep-trim-handle-left"
onMouseDown={(e) => handleTrimHandleMouseDown(e, clip.id, "left")}
title="拖动调整入点"
>
<div className="ep-trim-handle-line" />
</div>
)}
{/* 类型图标 */}
<div className="ep-clip-thumbnail">{CLIP_TYPE_ICONS[clip.type] || "🎬"}</div>
{/* 片段信息 */}
<div className="ep-clip-info">
<span className="ep-clip-name">
{CLIP_TYPE_LABELS[clip.type] || "片段"} {idx + 1}
</span>
<span className="ep-clip-duration">
{clip.duration}s
{hasTrim && (
<span className="ep-trim-indicator" title="已裁剪">
</span>
)}
</span>
</div>
{/* 速度徽章 */}
{showSpeed && <span className="ep-speed-badge">{speed!.rate.toFixed(1)}x</span>}
{/* 裁剪徽章 */}
{hasTrim && (
<span
className="ep-trim-badge"
title={`入点 ${clip.trim_config!.start_time.toFixed(1)}s / 出点 ${clip.trim_config!.end_time.toFixed(1)}s`}
>
</span>
)}
{/* 右裁剪手柄 */}
{isHovered && onClipTrim && (
<div
className="ep-trim-handle ep-trim-handle-right"
onMouseDown={(e) => handleTrimHandleMouseDown(e, clip.id, "right")}
title="拖动调整出点"
>
<div className="ep-trim-handle-line" />
</div>
)}
{/* 删除按钮 */}
<button
className="ep-clip-remove"
onClick={(e) => {
e.stopPropagation()
onClipRemove(clip.id)
}}
>
</button>
</div>
</React.Fragment>
)
})
)}
{/* ── 轨道末尾 "+" 添加卡片 ── */}
@@ -553,42 +663,109 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
{/* 裁剪预览 tooltip */}
{trimPreview && (
<TrimPreview
startTime={trimPreview.startTime}
endTime={trimPreview.endTime}
duration={trimPreview.duration}
x={trimPreview.x}
y={trimPreview.y}
/>
<div
className="ep-trim-preview"
style={{
position: "fixed",
left: trimPreview.x + 12,
top: trimPreview.y - 40,
}}
>
<div className="ep-trim-preview-row">
<span className="ep-trim-preview-label"></span>
<span className="ep-trim-preview-value">{formatTrimTime(trimPreview.startTime)}</span>
</div>
<div className="ep-trim-preview-row">
<span className="ep-trim-preview-label"></span>
<span className="ep-trim-preview-value">{formatTrimTime(trimPreview.endTime)}</span>
</div>
<div className="ep-trim-preview-row ep-trim-preview-duration">
<span className="ep-trim-preview-label"></span>
<span className="ep-trim-preview-value">{formatTrimTime(trimPreview.duration)}</span>
</div>
</div>
)}
{/* 右键菜单 */}
{contextMenu && (
<ContextMenu
x={contextMenu.x}
y={contextMenu.y}
menuRef={contextMenuRef}
hasTrim={!!clips.find((c) => c.id === contextMenu.clipId)?.trim_config}
onSplit={handleContextSplit}
onResetTrim={handleContextResetTrim}
onDelete={handleContextDelete}
/>
<div
ref={contextMenuRef}
className="ep-context-menu"
style={{
position: "fixed",
left: contextMenu.x,
top: contextMenu.y,
}}
>
<div className="ep-context-menu-item" onClick={handleContextSplit}>
<span className="ep-context-menu-icon"></span>
<span></span>
</div>
{clips.find((c) => c.id === contextMenu.clipId)?.trim_config && (
<div className="ep-context-menu-item" onClick={handleContextResetTrim}>
<span className="ep-context-menu-icon"></span>
<span></span>
</div>
)}
<div className="ep-context-menu-divider" />
<div
className="ep-context-menu-item ep-context-menu-item-danger"
onClick={handleContextDelete}
>
<span className="ep-context-menu-icon">🗑</span>
<span></span>
</div>
</div>
)}
{/* 类型+时长选择面板 */}
{showAddPicker && (
<AddClipPicker
pickerRef={pickerRef}
position={pickerPos}
availableTypes={availableTypes}
addType={addType}
addDuration={addDuration}
minDuration={MIN_ADD_DURATION}
maxDuration={MAX_ADD_DURATION}
onTypeChange={setAddType}
onDurationChange={setAddDuration}
onConfirm={handleConfirmAdd}
/>
<div
ref={pickerRef}
className="ep-add-clip-picker ep-add-clip-picker--portal"
style={{
position: "fixed",
top: pickerPos.top,
right: pickerPos.right,
}}
>
<div className="ep-add-clip-picker-title"></div>
{/* 类型选择 */}
<div className="ep-add-clip-type-row">
<span className="ep-add-clip-type-label">:</span>
{availableTypes.map((t) => (
<button
key={t}
className={`ep-add-clip-type-btn${addType === t ? " active" : ""}`}
onClick={() => setAddType(t)}
>
{CLIP_TYPE_ICONS[t]} {CLIP_TYPE_LABELS[t]}
</button>
))}
</div>
{/* 时长输入 */}
<div className="ep-add-clip-duration-row">
<span className="ep-add-clip-type-label">:</span>
<input
type="number"
className="ep-duration-input"
min={1}
max={120}
value={addDuration}
onChange={(e) =>
setAddDuration(Math.max(1, Math.min(120, Number(e.target.value) || 1)))
}
/>
<span className="ep-add-clip-duration-unit"></span>
</div>
{/* 确认按钮 */}
<button className="ep-add-clip-confirm-btn" onClick={handleConfirmAdd}>
</button>
</div>
)}
</div>
)
@@ -1,54 +0,0 @@
import React from "react"
import type { EditingTemplate } from "@/api/editing-planner"
interface TopBarProps {
currentTemplate: EditingTemplate | null
canUndo: boolean
canRedo: boolean
onUndo: () => void
onRedo: () => void
onOpenSaveModal: () => void
}
const TopBar: React.FC<TopBarProps> = ({
currentTemplate,
canUndo,
canRedo,
onUndo,
onRedo,
onOpenSaveModal,
}) => {
return (
<div className="ep-top-bar">
<div className="ep-top-bar-left">
<span className="ep-logo"></span>
<span className="ep-app-title"></span>
<span className="ep-divider">|</span>
<span className="ep-template-name">{currentTemplate?.name || "未选择模板"}</span>
</div>
<div className="ep-top-bar-right">
<button
className="ep-btn ep-btn-secondary"
onClick={onUndo}
disabled={!canUndo}
title="撤销 (Ctrl+Z)"
>
</button>
<button
className="ep-btn ep-btn-secondary"
onClick={onRedo}
disabled={!canRedo}
title="重做 (Ctrl+Shift+Z)"
>
</button>
<button className="ep-btn ep-btn-secondary" onClick={onOpenSaveModal}>
💾
</button>
</div>
</div>
)
}
export default TopBar
+220 -62
View File
@@ -2,14 +2,23 @@
* TTS 配音面板 — Drawer 形式
* 配音模式切换 + 文本输入 + 音色选择 + 语速/语调/音量 + 试听 + 字幕联动
*/
import React from "react"
import { Drawer } from "antd"
import type { TtsConfig } from "@/pages/editing-planner/types"
import { TTS_MODE_OPTIONS } from "@/pages/editing-planner/constants/tts"
import VoiceSelector from "./tts/VoiceSelector"
import TtsSlider from "./tts/TtsSlider"
import { useTtsPanel } from "@/pages/editing-planner/hooks/useTtsPanel"
import React, { useState, useCallback, useEffect, useRef } from "react"
import { Drawer, Slider, message } from "antd"
import type { TtsConfig, TtsMode } from "../types"
import { DEFAULT_TTS_CONFIG } from "../types"
import { getTtsVoices, previewTts, type TTSVoice } from "@/api/tts"
/* ──────────── 音色卡片分类图标 ──────────── */
const VOICE_CATEGORY_MAP: Record<string, { icon: string; label: string }> = {
male: { icon: "👨", label: "男声" },
female: { icon: "👩", label: "女声" },
young: { icon: "🧑", label: "少年" },
service: { icon: "🎧", label: "客服" },
news: { icon: "📰", label: "新闻" },
emotion: { icon: "🎭", label: "情感" },
}
/* ──────────── Props ──────────── */
interface TtsPanelProps {
open: boolean
onClose: () => void
@@ -19,21 +28,126 @@ interface TtsPanelProps {
}
const TtsPanel: React.FC<TtsPanelProps> = ({ open, onClose, config, onChange }) => {
const {
voices,
voicesLoading,
previewLoading,
handleModeChange,
handleTextChange,
handleVoiceSelect,
handleSpeedChange,
handlePitchChange,
handleVolumeChange,
handleSubtitleSyncToggle,
handlePreview,
handleReset,
handleClose,
} = useTtsPanel({ open, config, onChange, onClose })
/* ── 音色列表 ── */
const [voices, setVoices] = useState<TTSVoice[]>([])
const [voicesLoading, setVoicesLoading] = useState(false)
/* ── 试听状态 ── */
const [previewLoading, setPreviewLoading] = useState(false)
const audioRef = useRef<HTMLAudioElement | null>(null)
/* ── 加载音色列表 ── */
useEffect(() => {
if (!open) return
setVoicesLoading(true)
getTtsVoices()
.then((v) => setVoices(v))
.catch(() => message.error("加载音色列表失败"))
.finally(() => setVoicesLoading(false))
}, [open])
/* ── 切换配音模式 ── */
const handleModeChange = useCallback(
(mode: TtsMode) => {
onChange({ ...config, mode })
},
[config, onChange],
)
/* ── 文本输入 ── */
const handleTextChange = useCallback(
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
const text = e.target.value.slice(0, 5000)
onChange({ ...config, text })
},
[config, onChange],
)
/* ── 选择音色 ── */
const handleVoiceSelect = useCallback(
(voiceId: string) => {
onChange({ ...config, voice_id: voiceId })
},
[config, onChange],
)
/* ── 语速 ── */
const handleSpeedChange = useCallback(
(speed: number) => {
onChange({ ...config, speed })
},
[config, onChange],
)
/* ── 语调 ── */
const handlePitchChange = useCallback(
(pitch: number) => {
onChange({ ...config, pitch })
},
[config, onChange],
)
/* ── 音量 ── */
const handleVolumeChange = useCallback(
(volume: number) => {
onChange({ ...config, volume })
},
[config, onChange],
)
/* ── 字幕联动 ── */
const handleSubtitleSyncToggle = useCallback(() => {
onChange({ ...config, subtitle_sync: !config.subtitle_sync })
}, [config, onChange])
/* ── 试听 ── */
const handlePreview = useCallback(async () => {
if (!config.text.trim()) {
message.warning("请先输入合成文本")
return
}
if (!config.voice_id) {
message.warning("请先选择音色")
return
}
setPreviewLoading(true)
try {
const res = await previewTts({
text: config.text.slice(0, 200), // 试听截取前200字
voice_id: config.voice_id,
speed: config.speed,
pitch: config.pitch,
})
// 停止上一个
audioRef.current?.pause()
const audio = new Audio(res.audio_url)
audioRef.current = audio
audio.play().catch(() => message.error("播放失败"))
audio.onended = () => {
audioRef.current = null
}
message.success("试听播放中")
} catch {
message.error("试听生成失败")
} finally {
setPreviewLoading(false)
}
}, [config])
/* ── 重置 ── */
const handleReset = useCallback(() => {
onChange({ ...DEFAULT_TTS_CONFIG })
}, [onChange])
/* ── 关闭时停止音频 ── */
const handleClose = useCallback(() => {
audioRef.current?.pause()
audioRef.current = null
onClose()
}, [onClose])
/* ── 音色分类分组 ── */
const voiceCategories = Object.entries(VOICE_CATEGORY_MAP)
return (
<Drawer
@@ -48,7 +162,11 @@ const TtsPanel: React.FC<TtsPanelProps> = ({ open, onClose, config, onChange })
<div className="tts-mode-section">
<div className="tts-mode-label"></div>
<div className="tts-mode-group">
{TTS_MODE_OPTIONS.map((m) => (
{[
{ mode: "none" as TtsMode, icon: "🔇", label: "无配音" },
{ mode: "upload" as TtsMode, icon: "📁", label: "上传配音" },
{ mode: "tts" as TtsMode, icon: "🤖", label: "TTS 合成" },
].map((m) => (
<button
key={m.mode}
className={`tts-mode-btn${config.mode === m.mode ? " active" : ""}`}
@@ -74,57 +192,97 @@ const TtsPanel: React.FC<TtsPanelProps> = ({ open, onClose, config, onChange })
className="tts-text-input"
placeholder="请输入需要合成的文本内容..."
value={config.text}
onChange={(e) => handleTextChange(e.target.value)}
onChange={handleTextChange}
maxLength={5000}
rows={5}
/>
</div>
{/* 音色选择 */}
<VoiceSelector
voices={voices}
voicesLoading={voicesLoading}
selectedVoiceId={config.voice_id}
onVoiceSelect={handleVoiceSelect}
/>
<div className="tts-voice-section">
<div className="tts-voice-label">
{voicesLoading && <span className="tts-voice-loading">...</span>}
</div>
<div className="tts-voice-grid">
{voiceCategories.map(([cat, info]) => {
const voice = voices.find((v) => v.category === cat)
const isSelected = voice && config.voice_id === voice.id
return (
<button
key={cat}
className={`tts-voice-card${isSelected ? " active" : ""}`}
onClick={() => voice && handleVoiceSelect(voice.id)}
disabled={!voice || voicesLoading}
>
<span className="tts-voice-card-icon">{info.icon}</span>
<span className="tts-voice-card-name">{voice?.name || info.label}</span>
</button>
)
})}
</div>
</div>
{/* 语速滑块 */}
<TtsSlider
label="语速"
value={config.speed}
min={0.5}
max={2.0}
step={0.05}
unit="x"
onChange={handleSpeedChange}
marks={["0.5x", "1.0x", "2.0x"]}
tooltipFormatter={(v) => `${v.toFixed(2)}x`}
/>
<div className="tts-slider-section">
<div className="tts-slider-header">
<span className="tts-slider-label"></span>
<span className="tts-slider-value">{config.speed.toFixed(2)}x</span>
</div>
<Slider
min={0.5}
max={2.0}
step={0.05}
value={config.speed}
onChange={handleSpeedChange}
tooltip={{ formatter: (v) => `${(v as number).toFixed(2)}x` }}
/>
<div className="tts-slider-marks">
<span>0.5x</span>
<span>1.0x</span>
<span>2.0x</span>
</div>
</div>
{/* 语调滑块 */}
<TtsSlider
label="语调"
value={config.pitch}
min={-12}
max={12}
step={1}
unit="半音"
onChange={handlePitchChange}
marks={["-12", "0", "+12"]}
tooltipFormatter={(v) => `${v}半音`}
/>
<div className="tts-slider-section">
<div className="tts-slider-header">
<span className="tts-slider-label"></span>
<span className="tts-slider-value">
{config.pitch > 0 ? "+" : ""}
{config.pitch}
</span>
</div>
<Slider
min={-12}
max={12}
step={1}
value={config.pitch}
onChange={handlePitchChange}
tooltip={{ formatter: (v) => `${v}半音` }}
/>
<div className="tts-slider-marks">
<span>-12</span>
<span>0</span>
<span>+12</span>
</div>
</div>
{/* 音量滑块 */}
<TtsSlider
label="音量"
value={config.volume}
min={0}
max={100}
step={1}
unit="%"
onChange={handleVolumeChange}
tooltipFormatter={(v) => `${v}%`}
/>
<div className="tts-slider-section">
<div className="tts-slider-header">
<span className="tts-slider-label"></span>
<span className="tts-slider-value">{config.volume}%</span>
</div>
<Slider
min={0}
max={100}
step={1}
value={config.volume}
onChange={handleVolumeChange}
tooltip={{ formatter: (v) => `${v}%` }}
/>
</div>
{/* 试听按钮 */}
<div className="tts-preview-section">
@@ -3,16 +3,38 @@
* 三个 Tab:图片水印 / 文字水印 / 滚动水印
* 通用设置:位置、不透明度
*/
import React from "react"
import React, { useCallback, useState } from "react"
import { Drawer } from "antd"
import type { WatermarkConfig } from "@/pages/editing-planner/types"
import WatermarkTypeTabs from "./watermark/WatermarkTypeTabs"
import ImageWatermarkSection from "./watermark/ImageWatermarkSection"
import TextWatermarkSection from "./watermark/TextWatermarkSection"
import ScrollWatermarkSection from "./watermark/ScrollWatermarkSection"
import WatermarkCommonSection from "./watermark/WatermarkCommonSection"
import { useWatermarkConfig } from "@/pages/editing-planner/hooks/useWatermarkConfig"
import type { WatermarkConfig, WatermarkType, WatermarkPosition, ScrollDirection } from "../types"
import { DEFAULT_WATERMARK } from "../types"
/* ──────────── 常量 ──────────── */
const WATERMARK_TABS: { key: WatermarkType; label: string; icon: string }[] = [
{ key: "none", label: "无水印", icon: "🚫" },
{ key: "image", label: "图片水印", icon: "🖼️" },
{ key: "text", label: "文字水印", icon: "📝" },
{ key: "scroll", label: "滚动水印", icon: "📜" },
]
const POSITION_OPTIONS: { value: WatermarkPosition; label: string }[] = [
{ value: "top_left", label: "左上角" },
{ value: "top_right", label: "右上角" },
{ value: "bottom_left", label: "左下角" },
{ value: "bottom_right", label: "右下角" },
{ value: "center", label: "居中" },
]
const SCROLL_DIRECTION_OPTIONS: {
value: ScrollDirection
label: string
}[] = [
{ value: "horizontal", label: "水平滚动" },
{ value: "vertical", label: "垂直滚动" },
{ value: "diagonal", label: "对角滚动" },
]
/* ──────────── Props ──────────── */
interface WatermarkPanelProps {
open: boolean
onClose: () => void
@@ -21,21 +43,100 @@ interface WatermarkPanelProps {
}
const WatermarkPanel: React.FC<WatermarkPanelProps> = ({ open, onClose, config, onChange }) => {
const {
localImageUrl,
handleTypeChange,
handlePositionChange,
handleOpacityChange,
handleImageUrlChange,
handleImageWidthChange,
handleImageHeightChange,
handleTextChange,
handleFontSizeChange,
handleColorChange,
handleScrollDirectionChange,
handleScrollSpeedChange,
handleReset,
} = useWatermarkConfig({ config, onChange })
/* ── 图片上传预览 URL(本地预览用) ── */
const [localImageUrl, setLocalImageUrl] = useState<string>("")
/* ── 切换水印类型 ── */
const handleTypeChange = useCallback(
(type: WatermarkType) => {
onChange({ ...DEFAULT_WATERMARK, type })
},
[onChange],
)
/* ── 通用设置变更 ── */
const handlePositionChange = useCallback(
(position: WatermarkPosition) => {
onChange({ ...config, position })
},
[config, onChange],
)
const handleOpacityChange = useCallback(
(opacity: number) => {
onChange({ ...config, opacity })
},
[config, onChange],
)
/* ── 图片水印设置 ── */
const handleImageUrlChange = useCallback(
(url: string) => {
setLocalImageUrl(url)
onChange({ ...config, image_url: url })
},
[config, onChange],
)
const handleImageWidthChange = useCallback(
(width: number) => {
onChange({ ...config, width })
},
[config, onChange],
)
const handleImageHeightChange = useCallback(
(height: number) => {
onChange({ ...config, height })
},
[config, onChange],
)
/* ── 文字水印设置 ── */
const handleTextChange = useCallback(
(text: string) => {
onChange({ ...config, text })
},
[config, onChange],
)
const handleFontSizeChange = useCallback(
(font_size: number) => {
onChange({ ...config, font_size })
},
[config, onChange],
)
const handleColorChange = useCallback(
(color: string) => {
onChange({ ...config, color })
},
[config, onChange],
)
/* ── 滚动水印设置 ── */
const handleScrollDirectionChange = useCallback(
(scroll_direction: ScrollDirection) => {
onChange({ ...config, scroll_direction })
},
[config, onChange],
)
const handleScrollSpeedChange = useCallback(
(scroll_speed: number) => {
onChange({ ...config, scroll_speed })
},
[config, onChange],
)
/* ── 重置 ── */
const handleReset = useCallback(() => {
setLocalImageUrl("")
onChange({ ...DEFAULT_WATERMARK })
}, [onChange])
/* ── 当前激活的 Tab ── */
const activeTab = config.type
return (
<Drawer
@@ -47,7 +148,18 @@ const WatermarkPanel: React.FC<WatermarkPanelProps> = ({ open, onClose, config,
className="watermark-panel-drawer"
>
{/* ── Tab 切换 ── */}
<WatermarkTypeTabs activeTab={config.type} onTypeChange={handleTypeChange} />
<div className="wp-tabs">
{WATERMARK_TABS.map((tab) => (
<button
key={tab.key}
className={`wp-tab${activeTab === tab.key ? " active" : ""}`}
onClick={() => handleTypeChange(tab.key)}
>
<span className="wp-tab-icon">{tab.icon}</span>
<span className="wp-tab-label">{tab.label}</span>
</button>
))}
</div>
{/* ── 无水印提示 ── */}
{config.type === "none" && (
@@ -60,53 +172,210 @@ const WatermarkPanel: React.FC<WatermarkPanelProps> = ({ open, onClose, config,
{/* ── 图片水印配置 ── */}
{config.type === "image" && (
<ImageWatermarkSection
imageUrl={config.image_url || ""}
localImageUrl={localImageUrl}
width={config.width}
height={config.height}
onImageUrlChange={handleImageUrlChange}
onWidthChange={handleImageWidthChange}
onHeightChange={handleImageHeightChange}
/>
<div className="wp-section">
<div className="wp-field">
<label className="wp-field-label"> URL</label>
<input
className="wp-input"
type="text"
placeholder="https://example.com/logo.png"
value={localImageUrl || config.image_url || ""}
onChange={(e) => handleImageUrlChange(e.target.value)}
/>
{(localImageUrl || config.image_url) && (
<div className="wp-image-preview">
<img
src={localImageUrl || config.image_url}
alt="水印预览"
onError={(e) => {
;(e.target as HTMLImageElement).style.display = "none"
}}
/>
</div>
)}
</div>
<div className="wp-field">
<label className="wp-field-label">0 </label>
<input
className="wp-input"
type="number"
min={0}
max={2000}
value={config.width ?? 0}
onChange={(e) => handleImageWidthChange(Number(e.target.value))}
/>
</div>
<div className="wp-field">
<label className="wp-field-label">0 </label>
<input
className="wp-input"
type="number"
min={0}
max={2000}
value={config.height ?? 0}
onChange={(e) => handleImageHeightChange(Number(e.target.value))}
/>
</div>
</div>
)}
{/* ── 文字水印配置 ── */}
{config.type === "text" && (
<TextWatermarkSection
text={config.text || ""}
fontSize={config.font_size ?? 24}
color={config.color ?? "#ffffff"}
onTextChange={handleTextChange}
onFontSizeChange={handleFontSizeChange}
onColorChange={handleColorChange}
/>
<div className="wp-section">
<div className="wp-field">
<label className="wp-field-label"></label>
<textarea
className="wp-textarea"
placeholder="输入水印文字内容"
rows={3}
value={config.text ?? ""}
onChange={(e) => handleTextChange(e.target.value)}
/>
</div>
<div className="wp-field">
<label className="wp-field-label"></label>
<div className="wp-slider-row">
<input
className="wp-slider"
type="range"
min={12}
max={72}
value={config.font_size ?? 24}
onChange={(e) => handleFontSizeChange(Number(e.target.value))}
/>
<span className="wp-slider-value">{config.font_size ?? 24}px</span>
</div>
</div>
<div className="wp-field">
<label className="wp-field-label"></label>
<div className="wp-color-row">
<input
className="wp-color-input"
type="color"
value={config.color ?? "#ffffff"}
onChange={(e) => handleColorChange(e.target.value)}
/>
<span className="wp-color-value">{config.color ?? "#ffffff"}</span>
</div>
</div>
</div>
)}
{/* ── 滚动水印配置 ── */}
{config.type === "scroll" && (
<ScrollWatermarkSection
text={config.text || ""}
scrollDirection={config.scroll_direction ?? "horizontal"}
scrollSpeed={config.scroll_speed ?? 50}
fontSize={config.font_size ?? 24}
color={config.color ?? "#ffffff"}
onTextChange={handleTextChange}
onScrollDirectionChange={handleScrollDirectionChange}
onScrollSpeedChange={handleScrollSpeedChange}
onFontSizeChange={handleFontSizeChange}
onColorChange={handleColorChange}
/>
<div className="wp-section">
<div className="wp-field">
<label className="wp-field-label"></label>
<textarea
className="wp-textarea"
placeholder="输入滚动水印文字"
rows={2}
value={config.text ?? ""}
onChange={(e) => handleTextChange(e.target.value)}
/>
</div>
<div className="wp-field">
<label className="wp-field-label"></label>
<select
className="wp-select"
value={config.scroll_direction ?? "horizontal"}
onChange={(e) => handleScrollDirectionChange(e.target.value as ScrollDirection)}
>
{SCROLL_DIRECTION_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
<div className="wp-field">
<label className="wp-field-label"></label>
<div className="wp-slider-row">
<input
className="wp-slider"
type="range"
min={10}
max={200}
value={config.scroll_speed ?? 50}
onChange={(e) => handleScrollSpeedChange(Number(e.target.value))}
/>
<span className="wp-slider-value">{config.scroll_speed ?? 50}px/s</span>
</div>
</div>
<div className="wp-field">
<label className="wp-field-label"></label>
<div className="wp-slider-row">
<input
className="wp-slider"
type="range"
min={12}
max={72}
value={config.font_size ?? 24}
onChange={(e) => handleFontSizeChange(Number(e.target.value))}
/>
<span className="wp-slider-value">{config.font_size ?? 24}px</span>
</div>
</div>
<div className="wp-field">
<label className="wp-field-label"></label>
<div className="wp-color-row">
<input
className="wp-color-input"
type="color"
value={config.color ?? "#ffffff"}
onChange={(e) => handleColorChange(e.target.value)}
/>
<span className="wp-color-value">{config.color ?? "#ffffff"}</span>
</div>
</div>
</div>
)}
{/* ── 通用设置(非 none 时显示) ── */}
{config.type !== "none" && (
<WatermarkCommonSection
position={config.position}
opacity={config.opacity}
onPositionChange={handlePositionChange}
onOpacityChange={handleOpacityChange}
/>
<div className="wp-section wp-common-section">
<div className="wp-section-divider" />
<div className="wp-common-title"></div>
<div className="wp-field">
<label className="wp-field-label"></label>
<select
className="wp-select"
value={config.position}
onChange={(e) => handlePositionChange(e.target.value as WatermarkPosition)}
>
{POSITION_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
<div className="wp-field">
<label className="wp-field-label"></label>
<div className="wp-slider-row">
<input
className="wp-slider"
type="range"
min={0}
max={1}
step={0.05}
value={config.opacity}
onChange={(e) => handleOpacityChange(Number(e.target.value))}
/>
<span className="wp-slider-value">{Math.round(config.opacity * 100)}%</span>
</div>
</div>
</div>
)}
{/* ── 底部操作 ── */}
@@ -1,41 +0,0 @@
/**
* BGM 设置区块
*/
import React from "react"
import type { BgmSettings } from "@/pages/editing-planner/types/clipProperties"
interface BgmSettingsSectionProps {
settings: BgmSettings
onOpenBgmDrawer?: () => void
}
const BgmSettingsSection: React.FC<BgmSettingsSectionProps> = ({ settings, onOpenBgmDrawer }) => {
return (
<div className="ep-settings-section">
<div className="ep-section-title">
<span className="ep-section-icon">🎵</span>
BGM
</div>
{settings.enabled && settings.music_id ? (
<div className="ep-bgm-current">
<span className="ep-bgm-current-label">🎵 BGM</span>
<span className="ep-bgm-current-id">{settings.music_id}</span>
{settings.volume !== undefined && (
<span className="ep-bgm-current-vol"> {settings.volume}%</span>
)}
</div>
) : (
<div className="ep-bgm-empty"></div>
)}
{onOpenBgmDrawer && (
<button className="ep-advanced-btn" onClick={onOpenBgmDrawer}>
🎵 {settings.enabled ? "更换 BGM / 调整混音" : "选择 BGM 音乐"}
</button>
)}
</div>
)
}
export default BgmSettingsSection
@@ -1,266 +0,0 @@
/**
* 片段详情区块
*/
import React from "react"
import { useNavigate } from "react-router-dom"
import { TRANSITION_OPTIONS } from "@/api/template-editor"
import type { ClipData, ClipType } from "@/pages/editing-planner/types"
import { CLIP_TYPE_ICONS, CLIP_TYPE_LABELS } from "@/pages/editing-planner/constants/clipProperties"
import { getGenderLabel } from "@/pages/editing-planner/utils/clipProperties"
import type { AssetItem } from "@/api/assets"
import type { TemplateMode } from "@/api/editing-planner"
interface ClipDetailSectionProps {
clip: ClipData
currentMode: TemplateMode
voiceMaterials?: AssetItem[]
voiceMaterialsLoading?: boolean
onClipUpdate: (clipId: string, data: Partial<ClipData>) => void
onRefreshVoiceMaterials?: () => void
onClipVoiceSelect?: (clipId: string, asset: AssetItem | null) => void
onOpenTransitionDrawer?: (clipId: string) => void
onOpenSpeedDrawer?: (clipId: string) => void
onOpenTtsDrawer?: (clipId: string) => void
previewingId: string | null
onPreviewVoice: (asset: AssetItem) => void
onStopPreview: () => void
}
const ClipDetailSection: React.FC<ClipDetailSectionProps> = ({
clip,
currentMode,
voiceMaterials = [],
voiceMaterialsLoading = false,
onClipUpdate,
onRefreshVoiceMaterials,
onClipVoiceSelect,
onOpenTransitionDrawer,
onOpenSpeedDrawer,
onOpenTtsDrawer,
previewingId,
onPreviewVoice,
onStopPreview,
}) => {
const navigate = useNavigate()
const isTypeDisabled = (t: ClipType) => {
if (currentMode === "pip") return t !== "pip"
if (currentMode === "voice_over") return t !== "voice"
return false
}
const transitionLabel = (() => {
const t = clip.transition
if (!t || t.type === "none") return "无转场"
const opt = TRANSITION_OPTIONS.find((o) => o.value === t.type)
return `${opt?.label ?? t.type} · ${t.duration.toFixed(1)}s`
})()
const speedLabel = clip.speed ? `${clip.speed.rate.toFixed(2)}x` : "1.00x"
const ttsLabel = (() => {
const tts = clip.tts_config
if (!tts || tts.mode === "none") return "无配音"
if (tts.mode === "upload") return "上传配音"
return `TTS · ${tts.voice_id ? "已选音色" : "未选音色"}`
})()
return (
<div className="ep-settings-section">
<div className="ep-section-title">
<span className="ep-section-icon">🎞</span>
</div>
<div className="ep-clip-detail">
{/* 类型选择器 */}
<div className="ep-clip-detail-field">
<div className="ep-clip-detail-label"></div>
<div className="ep-clip-type-selector">
{(["voice", "pip"] as ClipType[]).map((t) => {
const disabled = isTypeDisabled(t)
return (
<button
key={t}
className={`ep-clip-type-btn${clip.type === t ? " active" : ""}${disabled ? " disabled" : ""}`}
disabled={disabled}
onClick={() => !disabled && onClipUpdate(clip.id, { type: t })}
>
{CLIP_TYPE_ICONS[t]} {CLIP_TYPE_LABELS[t]}
</button>
)
})}
</div>
</div>
{/* 时长 */}
<div className="ep-clip-detail-field">
<div className="ep-clip-detail-label"></div>
<div className="ep-clip-detail-row">
<input
className="ep-duration-input"
type="number"
min={1}
max={120}
value={clip.duration}
onChange={(e) =>
onClipUpdate(clip.id, {
duration: Math.max(1, Math.min(120, Number(e.target.value) || 1)),
})
}
/>
<span className="ep-clip-detail-unit"></span>
</div>
</div>
{/* 转场效果入口 */}
{onOpenTransitionDrawer && (
<div className="ep-clip-detail-field">
<button
className="ep-advanced-btn ep-advanced-btn--transition"
onClick={() => onOpenTransitionDrawer(clip.id)}
>
<span className="ep-advanced-btn-icon">🎬</span>
<span className="ep-advanced-btn-label"></span>
<span className="ep-advanced-btn-value">{transitionLabel}</span>
<span className="ep-advanced-btn-arrow"></span>
</button>
</div>
)}
{/* 播放速度入口 */}
{onOpenSpeedDrawer && (
<div className="ep-clip-detail-field">
<button
className="ep-advanced-btn ep-advanced-btn--speed"
onClick={() => onOpenSpeedDrawer(clip.id)}
>
<span className="ep-advanced-btn-icon"></span>
<span className="ep-advanced-btn-label"></span>
<span className="ep-advanced-btn-value">{speedLabel}</span>
<span className="ep-advanced-btn-arrow"></span>
</button>
</div>
)}
{/* TTS 配音入口 */}
{onOpenTtsDrawer && (
<div className="ep-clip-detail-field">
<button
className="ep-advanced-btn ep-advanced-btn--tts"
onClick={() => onOpenTtsDrawer(clip.id)}
>
<span className="ep-advanced-btn-icon">🎙</span>
<span className="ep-advanced-btn-label">TTS </span>
<span className="ep-advanced-btn-value">{ttsLabel}</span>
<span className="ep-advanced-btn-arrow"></span>
</button>
</div>
)}
{/* 素材起始时间 — 仅 voice 类型显示 */}
{clip.type === "voice" && (
<div className="ep-clip-detail-field">
<div className="ep-clip-detail-label"></div>
<div className="ep-clip-detail-row">
<input
className="ep-duration-input"
type="number"
min={0}
max={9999}
step={0.1}
value={clip.startOffset}
onChange={(e) =>
onClipUpdate(clip.id, {
startOffset: Math.max(0, Math.min(9999, Number(e.target.value) || 0)),
})
}
/>
<span className="ep-clip-detail-unit"></span>
</div>
</div>
)}
{/* 配音素材选择 — 仅 voice 类型显示 */}
{clip.type === "voice" && (
<div className="ep-clip-detail-field">
<div className="ep-clip-detail-label">
{onRefreshVoiceMaterials && (
<button
type="button"
className="ep-voice-refresh-btn"
title="刷新配音列表"
onClick={() => onRefreshVoiceMaterials()}
disabled={voiceMaterialsLoading}
>
{voiceMaterialsLoading ? "⏳" : "🔄"}
</button>
)}
</div>
{voiceMaterialsLoading && voiceMaterials.length === 0 ? (
<div className="ep-voice-loading">...</div>
) : (
<>
<div className="ep-voice-select-row">
<select
className="ep-clip-detail-select"
value={clip.voice_asset_id ?? ""}
onChange={(e) => {
const assetId = e.target.value
if (!onClipVoiceSelect) return
if (!assetId) {
onClipVoiceSelect(clip.id, null)
} else {
const asset = voiceMaterials.find((m) => m.id === assetId)
if (asset) onClipVoiceSelect(clip.id, asset)
}
onStopPreview()
}}
>
<option value=""></option>
{voiceMaterials.map((m) => {
const gender = getGenderLabel(m)
const label = gender ? `${m.name}${gender}` : m.name
return (
<option key={m.id} value={m.id}>
{label}
</option>
)
})}
</select>
{clip.voice_asset_id && (
<button
type="button"
className="ep-voice-preview-btn"
title={previewingId === clip.voice_asset_id ? "暂停" : "试听"}
onClick={() => {
const asset = voiceMaterials.find((m) => m.id === clip.voice_asset_id)
if (asset) onPreviewVoice(asset)
}}
>
{previewingId === clip.voice_asset_id ? "⏸" : "▶️"}
</button>
)}
</div>
{voiceMaterials.length === 0 && (
<div className="ep-voice-empty"></div>
)}
</>
)}
<button
className="ep-voice-upload-btn"
onClick={() => navigate("/app/voice-materials")}
>
+
</button>
</div>
)}
</div>
</div>
)
}
export default ClipDetailSection
@@ -1,39 +0,0 @@
/**
* 编辑统计区块
*/
import React from "react"
import { formatModeLabel } from "@/pages/editing-planner/utils/clipProperties"
import type { TemplateMode } from "@/api/editing-planner"
interface StatsSectionProps {
clipsCount: number
totalDuration: number
currentMode: TemplateMode
}
const StatsSection: React.FC<StatsSectionProps> = ({ clipsCount, totalDuration, currentMode }) => {
return (
<div className="ep-settings-section">
<div className="ep-section-title">
<span className="ep-section-icon">📊</span>
</div>
<div className="ep-clip-detail">
<div className="ep-clip-detail-field">
<div className="ep-clip-detail-label"></div>
<div className="ep-clip-detail-value">{clipsCount}</div>
</div>
<div className="ep-clip-detail-field">
<div className="ep-clip-detail-label"></div>
<div className="ep-clip-detail-value">{totalDuration.toFixed(1)}s</div>
</div>
<div className="ep-clip-detail-field">
<div className="ep-clip-detail-label"></div>
<div className="ep-clip-detail-value">{formatModeLabel(currentMode)}</div>
</div>
</div>
</div>
)
}
export default StatsSection
@@ -1,113 +0,0 @@
/**
* 字幕设置区块
*/
import React from "react"
import {
POSITION_OPTIONS,
FONT_OPTIONS,
ANIMATION_OPTIONS,
} from "@/pages/editing-planner/constants/clipProperties"
import type { SubtitleSettings } from "@/pages/editing-planner/types/clipProperties"
interface SubtitleSettingsSectionProps {
settings: SubtitleSettings
onChange: (partial: Partial<SubtitleSettings>) => void
onOpenSubtitleDrawer?: () => void
}
const SubtitleSettingsSection: React.FC<SubtitleSettingsSectionProps> = ({
settings,
onChange,
onOpenSubtitleDrawer,
}) => {
return (
<div className="ep-settings-section">
<div className="ep-section-title">
<span className="ep-section-icon">💬</span>
</div>
<div className="ep-toggle-row">
<span className="ep-toggle-label"></span>
<div
className={`ep-toggle ${settings.enabled ? "active" : ""}`}
onClick={() => onChange({ enabled: !settings.enabled })}
>
<div className="ep-toggle-knob" />
</div>
</div>
{settings.enabled && (
<>
<div className="ep-field">
<label className="ep-field-label"></label>
<select
className="ep-form-select"
value={settings.position}
onChange={(e) => onChange({ position: e.target.value })}
>
{POSITION_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
<div className="ep-field">
<label className="ep-field-label"></label>
<select
className="ep-form-select"
value={settings.font}
onChange={(e) => onChange({ font: e.target.value })}
>
{FONT_OPTIONS.map((f) => (
<option key={f} value={f}>
{f}
</option>
))}
</select>
</div>
<div className="ep-field">
<label className="ep-field-label"></label>
<div className="ep-slider-row">
<input
className="ep-slider"
type="range"
min={12}
max={48}
value={settings.fontSize}
onChange={(e) => onChange({ fontSize: Number(e.target.value) })}
/>
<span className="ep-slider-value">{settings.fontSize}px</span>
</div>
</div>
<div className="ep-field">
<label className="ep-field-label"></label>
<select
className="ep-form-select"
value={settings.animation}
onChange={(e) => onChange({ animation: e.target.value })}
>
{ANIMATION_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
{onOpenSubtitleDrawer && (
<button className="ep-advanced-btn" onClick={onOpenSubtitleDrawer}>
🎨
</button>
)}
</>
)}
</div>
)
}
export default SubtitleSettingsSection
@@ -1,74 +0,0 @@
import React from "react"
import { DEFAULT_TRANSITION, DEFAULT_SPEED, DEFAULT_TTS_CONFIG } from "../../types"
import TransitionSelector from "../TransitionSelector"
import SpeedPanel from "../SpeedPanel"
import TtsPanel from "../TtsPanel"
import type { ClipLevelDrawersProps } from "./types"
/**
* 片段级抽屉(转场/调速/TTS)
* 这些抽屉针对特定片段,需要 targetClipId 来定位和读取当前配置
*/
export const ClipLevelDrawers: React.FC<ClipLevelDrawersProps> = ({
clips,
transitionDrawerOpen,
transitionTargetClipId,
onCloseTransitionDrawer,
onTransitionChange,
speedDrawerOpen,
speedTargetClipId,
onCloseSpeedDrawer,
onSpeedChange,
onApplySpeedAll,
ttsDrawerOpen,
ttsTargetClipId,
onCloseTtsDrawer,
onTtsChange,
}) => {
const transitionConfig = transitionTargetClipId
? (clips.find((c) => c.id === transitionTargetClipId)?.transition ?? DEFAULT_TRANSITION)
: DEFAULT_TRANSITION
const transitionTitle = transitionTargetClipId ? "片段转场设置" : "全局默认转场"
const speedConfig = speedTargetClipId
? (clips.find((c) => c.id === speedTargetClipId)?.speed ?? DEFAULT_SPEED)
: DEFAULT_SPEED
const ttsConfig = ttsTargetClipId
? (clips.find((c) => c.id === ttsTargetClipId)?.tts_config ?? DEFAULT_TTS_CONFIG)
: DEFAULT_TTS_CONFIG
return (
<>
{/* 转场特效选择器 */}
<TransitionSelector
open={transitionDrawerOpen}
onClose={onCloseTransitionDrawer}
config={transitionConfig}
onChange={onTransitionChange}
title={transitionTitle}
/>
{/* 片段调速面板 */}
{speedTargetClipId && (
<SpeedPanel
open={speedDrawerOpen}
onClose={onCloseSpeedDrawer}
config={speedConfig}
onChange={onSpeedChange}
onApplyAll={onApplySpeedAll}
/>
)}
{/* TTS 配音面板 */}
{ttsTargetClipId && (
<TtsPanel
open={ttsDrawerOpen}
onClose={onCloseTtsDrawer}
config={ttsConfig}
onChange={onTtsChange}
/>
)}
</>
)
}
@@ -1,119 +0,0 @@
import React from "react"
import BgmSelector from "../BgmSelector"
import SubtitleStylePanel from "../SubtitleStylePanel"
import WatermarkPanel from "../WatermarkPanel"
import IntroOutroPanel from "../IntroOutroPanel"
import PipConfigPanel from "../PipConfigPanel"
import FilterPanel from "../FilterPanel"
import GreenScreenPanel from "../GreenScreenPanel"
import StickerPanel from "../StickerPanel"
import type { GlobalDrawersProps, BgmDrawerProps, SubtitleDrawerProps } from "./types"
/**
* 全局设置抽屉(BGM/字幕/水印/片头片尾/混剪/滤镜/绿幕/贴纸)
*/
export const GlobalDrawers: React.FC<BgmDrawerProps & SubtitleDrawerProps & GlobalDrawersProps> = ({
bgmDrawerOpen,
bgmSettings,
onCloseBgmDrawer,
onChangeBgmSettings,
subtitleDrawerOpen,
subtitleSettings,
onCloseSubtitleDrawer,
onChangeSubtitleSettings,
totalDuration,
watermarkDrawerOpen,
watermarkSettings,
onCloseWatermarkDrawer,
onWatermarkChange,
introOutroDrawerOpen,
introOutroSettings,
onCloseIntroOutroDrawer,
onIntroOutroChange,
pipDrawerOpen,
pipSettings,
onClosePipDrawer,
onPipChange,
filterDrawerOpen,
filterSettings,
onCloseFilterDrawer,
onFilterChange,
chromaKeyDrawerOpen,
chromaKeySettings,
onCloseChromaKeyDrawer,
onChromaKeyChange,
stickerDrawerOpen,
stickerSettings,
onCloseStickerDrawer,
onStickerChange,
}) => {
return (
<>
{/* BGM 选择器 */}
<BgmSelector
open={bgmDrawerOpen}
onClose={onCloseBgmDrawer}
config={bgmSettings}
onChange={onChangeBgmSettings}
/>
{/* 字幕样式配置 */}
<SubtitleStylePanel
open={subtitleDrawerOpen}
onClose={onCloseSubtitleDrawer}
config={subtitleSettings}
onChange={onChangeSubtitleSettings}
/>
{/* 水印配置面板 */}
<WatermarkPanel
open={watermarkDrawerOpen}
onClose={onCloseWatermarkDrawer}
config={watermarkSettings}
onChange={onWatermarkChange}
/>
{/* 片头片尾配置面板 */}
<IntroOutroPanel
open={introOutroDrawerOpen}
onClose={onCloseIntroOutroDrawer}
config={introOutroSettings}
onChange={onIntroOutroChange}
/>
{/* 混剪配置面板 */}
<PipConfigPanel
open={pipDrawerOpen}
onClose={onClosePipDrawer}
config={pipSettings}
onChange={onPipChange}
totalDuration={totalDuration}
/>
{/* 滤镜调色面板 */}
<FilterPanel
open={filterDrawerOpen}
onClose={onCloseFilterDrawer}
config={filterSettings}
onChange={onFilterChange}
/>
{/* 绿幕抠像面板 */}
<GreenScreenPanel
open={chromaKeyDrawerOpen}
onClose={onCloseChromaKeyDrawer}
config={chromaKeySettings}
onChange={onChromaKeyChange}
/>
{/* 贴纸面板 */}
<StickerPanel
open={stickerDrawerOpen}
onClose={onCloseStickerDrawer}
config={stickerSettings}
onChange={onStickerChange}
totalDuration={totalDuration}
/>
</>
)
}
@@ -1,101 +0,0 @@
import type { TemplateCategory } from "@/api/editing-planner"
import type {
ClipData,
TransitionConfig,
SpeedConfig,
TtsConfig,
WatermarkConfig,
IntroOutroConfig,
PipConfig,
FilterConfig,
ChromaKeyConfig,
StickerConfig,
} from "../../types"
import type { SubtitleStyleConfig } from "../../types/subtitle"
import type { BgmMixConfig } from "@/api/bgm"
/** 保存弹窗 Props */
export interface SaveModalDrawerProps {
saveModalOpen: boolean
saveLoading: boolean
isUpdate: boolean
draftName: string
draftCategory: string
draftTags: string
categories: TemplateCategory[]
estimatedDuration: number
onNameChange: (name: string) => void
onCategoryChange: (cat: string) => void
onTagsChange: (tags: string) => void
onSave: () => Promise<void>
onCancelSave: () => void
}
/** BGM 抽屉 Props */
export interface BgmDrawerProps {
bgmDrawerOpen: boolean
bgmSettings: BgmMixConfig
onCloseBgmDrawer: () => void
onChangeBgmSettings: (config: BgmMixConfig) => void
}
/** 字幕抽屉 Props */
export interface SubtitleDrawerProps {
subtitleDrawerOpen: boolean
subtitleSettings: SubtitleStyleConfig
onCloseSubtitleDrawer: () => void
onChangeSubtitleSettings: (config: SubtitleStyleConfig) => void
}
/** 单个片段级抽屉通用 Props */
export interface ClipLevelDrawersProps {
clips: ClipData[]
transitionDrawerOpen: boolean
transitionTargetClipId: string | null
onCloseTransitionDrawer: () => void
onTransitionChange: (config: TransitionConfig) => void
speedDrawerOpen: boolean
speedTargetClipId: string | null
onCloseSpeedDrawer: () => void
onSpeedChange: (config: SpeedConfig) => void
onApplySpeedAll: (config: SpeedConfig) => void
ttsDrawerOpen: boolean
ttsTargetClipId: string | null
onCloseTtsDrawer: () => void
onTtsChange: (config: TtsConfig) => void
}
/** 全局设置抽屉 Props */
export interface GlobalDrawersProps {
totalDuration: number
watermarkDrawerOpen: boolean
watermarkSettings: WatermarkConfig
onCloseWatermarkDrawer: () => void
onWatermarkChange: (config: WatermarkConfig) => void
introOutroDrawerOpen: boolean
introOutroSettings: IntroOutroConfig
onCloseIntroOutroDrawer: () => void
onIntroOutroChange: (config: IntroOutroConfig) => void
pipDrawerOpen: boolean
pipSettings: PipConfig
onClosePipDrawer: () => void
onPipChange: (config: PipConfig) => void
filterDrawerOpen: boolean
filterSettings: FilterConfig
onCloseFilterDrawer: () => void
onFilterChange: (config: FilterConfig) => void
chromaKeyDrawerOpen: boolean
chromaKeySettings: ChromaKeyConfig
onCloseChromaKeyDrawer: () => void
onChromaKeyChange: (config: ChromaKeyConfig) => void
stickerDrawerOpen: boolean
stickerSettings: StickerConfig
onCloseStickerDrawer: () => void
onStickerChange: (config: StickerConfig) => void
}
export type EditingDrawersProps = SaveModalDrawerProps &
BgmDrawerProps &
SubtitleDrawerProps &
ClipLevelDrawersProps &
GlobalDrawersProps
@@ -1,40 +0,0 @@
/**
* 滤镜手动调节组件
*/
import React from "react"
import type { FilterConfig } from "../../types"
import { MANUAL_ADJUST_ITEMS } from "../../constants/filter"
type FilterKey = keyof Pick<
FilterConfig,
"brightness" | "contrast" | "saturation" | "temperature" | "tint" | "sharpness"
>
interface FilterManualAdjustProps {
config: FilterConfig
onChange: (key: FilterKey, value: number) => void
}
const FilterManualAdjust: React.FC<FilterManualAdjustProps> = ({ config, onChange }) => {
return (
<div className="filter-section">
<div className="filter-section-title"></div>
{MANUAL_ADJUST_ITEMS.map((item) => (
<div key={item.key} className="filter-slider-row">
<span className="filter-slider-label">{item.label}</span>
<input
type="range"
className="filter-slider"
min={item.min}
max={item.max}
value={config[item.key as FilterKey]}
onChange={(e) => onChange(item.key as FilterKey, Number(e.target.value))}
/>
<span className="filter-slider-value">{config[item.key as FilterKey]}</span>
</div>
))}
</div>
)
}
export default FilterManualAdjust
@@ -1,34 +0,0 @@
/**
* 滤镜预设选择组件
*/
import React from "react"
import type { FilterPreset } from "../../types"
import { FILTER_PRESET_LABELS } from "../../types"
import { PRESET_LIST, PRESET_GRADIENTS } from "../../constants/filter"
interface FilterPresetGridProps {
selectedPreset: FilterPreset
onPresetSelect: (preset: FilterPreset) => void
}
const FilterPresetGrid: React.FC<FilterPresetGridProps> = ({ selectedPreset, onPresetSelect }) => {
return (
<div className="filter-section">
<div className="filter-section-title"></div>
<div className="filter-presets">
{PRESET_LIST.map((p) => (
<button
key={p}
className={`filter-preset-item${selectedPreset === p ? " active" : ""}`}
onClick={() => onPresetSelect(p)}
>
<div className="filter-preset-preview" style={{ background: PRESET_GRADIENTS[p] }} />
<span className="filter-preset-label">{FILTER_PRESET_LABELS[p]}</span>
</button>
))}
</div>
</div>
)
}
export default FilterPresetGrid
@@ -1,123 +0,0 @@
/**
* 片头/片尾通用区块组件(片头片尾结构对称,复用同一个组件)
*/
import React from "react"
import type { IntroOutroItem, IntroOutroKind, TransitionType } from "../../types"
import { TRANSITION_OPTIONS } from "@/api/template-editor"
import { KIND_OPTIONS } from "../../constants/introOutro"
interface IntroOutroBlockProps {
title: string
icon: string
item: IntroOutroItem
transitionLabel: string
onKindChange: (kind: IntroOutroKind) => void
onChange: (partial: Partial<IntroOutroItem>) => void
}
const IntroOutroBlock: React.FC<IntroOutroBlockProps> = ({
title,
icon,
item,
transitionLabel,
onKindChange,
onChange,
}) => {
const hasContent = item.kind !== "none"
return (
<div className="iop-block">
<div className="iop-block-header">
<span className="iop-block-icon">{icon}</span>
<span className="iop-block-title">{title}</span>
</div>
{/* 类型选择 */}
<div className="iop-kind-row">
{KIND_OPTIONS.map((opt) => (
<button
key={opt.value}
className={`iop-kind-btn${item.kind === opt.value ? " active" : ""}`}
onClick={() => onKindChange(opt.value)}
>
<span className="iop-kind-icon">{opt.icon}</span>
<span className="iop-kind-label">{opt.label}</span>
</button>
))}
</div>
{/* 视频/图片配置 */}
{hasContent && (
<>
<div className="iop-field">
<label className="iop-field-label">{item.kind === "video" ? "视频" : "图片"} URL</label>
<input
className="iop-input"
type="text"
placeholder={
item.kind === "video"
? `https://example.com/${title}.mp4`
: `https://example.com/${title}.png`
}
value={item.url ?? ""}
onChange={(e) => onChange({ url: e.target.value })}
/>
</div>
<div className="iop-field">
<label className="iop-field-label"></label>
<div className="iop-slider-row">
<input
className="iop-slider"
type="range"
min={1}
max={15}
step={0.5}
value={item.duration}
onChange={(e) => onChange({ duration: Number(e.target.value) })}
/>
<span className="iop-slider-value">{item.duration}s</span>
</div>
</div>
<div className="iop-field">
<label className="iop-field-label">{transitionLabel}</label>
<select
className="iop-select"
value={item.transition ?? "none"}
onChange={(e) => onChange({ transition: e.target.value as TransitionType })}
>
{TRANSITION_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
{item.transition && item.transition !== "none" && (
<div className="iop-field">
<label className="iop-field-label"></label>
<div className="iop-slider-row">
<input
className="iop-slider"
type="range"
min={0.3}
max={2.0}
step={0.1}
value={item.transition_duration ?? 0.5}
onChange={(e) => onChange({ transition_duration: Number(e.target.value) })}
/>
<span className="iop-slider-value">
{(item.transition_duration ?? 0.5).toFixed(1)}s
</span>
</div>
</div>
)}
</>
)}
</div>
)
}
export default IntroOutroBlock
@@ -1,286 +0,0 @@
/**
* 混剪单图层配置区
*/
import React from "react"
import type {
PipLayer,
PipAnimType,
PipSlideDirection,
PipGridPosition,
} from "@/pages/editing-planner/types"
import {
GRID_POSITIONS,
ANIM_OPTIONS,
SLIDE_DIR_OPTIONS,
LAYER_COLORS,
} from "@/pages/editing-planner/constants/pipConfig"
interface LayerConfigProps {
layer: PipLayer | null
layers: PipLayer[]
totalDuration: number
onUpdate: (id: string, partial: Partial<PipLayer>) => void
onGridClick: (pos: PipGridPosition) => void
onWidthChange: (val: number) => void
onHeightChange: (val: number) => void
}
const LayerConfig: React.FC<LayerConfigProps> = ({
layer,
layers,
totalDuration,
onUpdate,
onGridClick,
onWidthChange,
onHeightChange,
}) => {
if (!layer) {
return (
<div className="pip-config-area">
<div className="pip-config-empty"></div>
</div>
)
}
return (
<div className="pip-config-area">
{/* ── 迷你预览 ── */}
<div className="pip-preview-box">
{layers.map((l, idx) => (
<div
key={l.id}
className={`pip-preview-layer${layer.id === l.id ? " selected" : ""}`}
style={{
left: `${l.x}%`,
top: `${l.y}%`,
width: `${l.width}%`,
height: `${l.height}%`,
background: LAYER_COLORS[idx % LAYER_COLORS.length],
opacity: l.opacity / 100,
borderRadius: `${l.border_radius}%`,
}}
>
<span className="pip-preview-label">{l.name}</span>
</div>
))}
</div>
{/* ── 素材类型 ── */}
<div className="pip-field">
<label className="pip-field-label"></label>
<div className="pip-type-btns">
<button
className={`pip-type-btn${layer.material_type === "image" ? " active" : ""}`}
onClick={() => onUpdate(layer.id, { material_type: "image" })}
>
🖼
</button>
<button
className={`pip-type-btn${layer.material_type === "video" ? " active" : ""}`}
onClick={() => onUpdate(layer.id, { material_type: "video" })}
>
🎬
</button>
</div>
</div>
{/* ── 素材 URL ── */}
<div className="pip-field">
<label className="pip-field-label">
{layer.material_type === "image" ? "图片" : "视频"} URL
</label>
<input
className="pip-input"
type="text"
placeholder={
layer.material_type === "image"
? "https://example.com/image.png"
: "https://example.com/video.mp4"
}
value={layer.material_url}
onChange={(e) => onUpdate(layer.id, { material_url: e.target.value })}
/>
</div>
{/* ── 位置:九宫格 + 坐标 ── */}
<div className="pip-field">
<label className="pip-field-label"></label>
<div style={{ display: "flex", gap: 16, alignItems: "flex-start" }}>
<div className="pip-grid">
{GRID_POSITIONS.map((pos) => (
<button
key={pos}
className={`pip-grid-btn${layer.grid_position === pos ? " active" : ""}`}
onClick={() => onGridClick(pos)}
>
<span className="pip-grid-dot" />
</button>
))}
</div>
<div className="pip-field-row" style={{ flex: 1 }}>
<div>
<label className="pip-field-label">X (%)</label>
<input
className="pip-number"
type="number"
min={0}
max={100}
value={layer.x}
onChange={(e) => onUpdate(layer.id, { x: Number(e.target.value) })}
/>
</div>
<div>
<label className="pip-field-label">Y (%)</label>
<input
className="pip-number"
type="number"
min={0}
max={100}
value={layer.y}
onChange={(e) => onUpdate(layer.id, { y: Number(e.target.value) })}
/>
</div>
</div>
</div>
</div>
{/* ── 尺寸 ── */}
<div className="pip-field">
<label className="pip-field-label"></label>
<div className="pip-slider-row">
<span style={{ fontSize: 12, color: "#999", width: 20 }}></span>
<input
className="pip-slider"
type="range"
min={10}
max={80}
value={layer.width}
onChange={(e) => onWidthChange(Number(e.target.value))}
/>
<span className="pip-slider-value">{layer.width}%</span>
</div>
<div className="pip-slider-row" style={{ marginTop: 6 }}>
<span style={{ fontSize: 12, color: "#999", width: 20 }}></span>
<input
className="pip-slider"
type="range"
min={10}
max={80}
value={layer.height}
onChange={(e) => onHeightChange(Number(e.target.value))}
/>
<span className="pip-slider-value">{layer.height}%</span>
</div>
<div
className="pip-lock-row"
style={{ marginTop: 6 }}
onClick={() => onUpdate(layer.id, { aspect_lock: !layer.aspect_lock })}
>
<span className="pip-lock-icon">{layer.aspect_lock ? "🔒" : "🔓"}</span>
<span>{layer.aspect_lock ? "已锁定比例" : "锁定宽高比"}</span>
</div>
</div>
{/* ── 圆角 ── */}
<div className="pip-field">
<label className="pip-field-label"></label>
<div className="pip-slider-row">
<input
className="pip-slider"
type="range"
min={0}
max={50}
value={layer.border_radius}
onChange={(e) => onUpdate(layer.id, { border_radius: Number(e.target.value) })}
/>
<span className="pip-slider-value">{layer.border_radius}%</span>
</div>
</div>
{/* ── 透明度 ── */}
<div className="pip-field">
<label className="pip-field-label"></label>
<div className="pip-slider-row">
<input
className="pip-slider"
type="range"
min={0}
max={100}
value={layer.opacity}
onChange={(e) => onUpdate(layer.id, { opacity: Number(e.target.value) })}
/>
<span className="pip-slider-value">{layer.opacity}%</span>
</div>
</div>
{/* ── 时间 ── */}
<div className="pip-field">
<label className="pip-field-label"></label>
<div className="pip-field-row">
<div>
<label className="pip-field-label"> (s)</label>
<input
className="pip-number"
type="number"
min={0}
max={totalDuration || 999}
step={0.1}
value={layer.start_time}
onChange={(e) => onUpdate(layer.id, { start_time: Number(e.target.value) })}
/>
</div>
<div>
<label className="pip-field-label"> (s)</label>
<input
className="pip-number"
type="number"
min={0.1}
max={totalDuration || 999}
step={0.1}
value={layer.duration}
onChange={(e) => onUpdate(layer.id, { duration: Number(e.target.value) })}
/>
</div>
</div>
</div>
{/* ── 入场动画 ── */}
<div className="pip-field">
<label className="pip-field-label"></label>
<select
className="pip-select"
value={layer.animation}
onChange={(e) => onUpdate(layer.id, { animation: e.target.value as PipAnimType })}
>
{ANIM_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
{/* 滑入方向(仅 slide_in 时显示) */}
{layer.animation === "slide_in" && (
<div className="pip-field">
<label className="pip-field-label"></label>
<select
className="pip-select"
value={layer.slide_direction}
onChange={(e) =>
onUpdate(layer.id, { slide_direction: e.target.value as PipSlideDirection })
}
>
{SLIDE_DIR_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
)}
</div>
)
}
export default LayerConfig
@@ -1,65 +0,0 @@
/**
* 混剪图层列表
*/
import React from "react"
import type { PipLayer } from "@/pages/editing-planner/types"
import { LAYER_COLORS } from "@/pages/editing-planner/constants/pipConfig"
interface LayerListProps {
layers: PipLayer[]
selectedId: string
onSelect: (id: string) => void
onAdd: () => void
onDelete: (id: string) => void
}
const LayerList: React.FC<LayerListProps> = ({ layers, selectedId, onSelect, onAdd, onDelete }) => {
return (
<div className="pip-layer-list">
<div className="pip-toolbar" style={{ marginBottom: 8 }}>
<button className="pip-add-btn" onClick={onAdd}>
+
</button>
</div>
{layers.length === 0 ? (
<div className="pip-layer-empty"></div>
) : (
layers.map((layer, idx) => (
<div
key={layer.id}
className={`pip-layer-item${selectedId === layer.id ? " active" : ""}`}
onClick={() => onSelect(layer.id)}
>
{layer.thumbnail_url || layer.material_url ? (
<img
className="pip-layer-thumb"
src={layer.thumbnail_url || layer.material_url}
alt={layer.name}
/>
) : (
<div
className="pip-layer-thumb"
style={{
background: LAYER_COLORS[idx % LAYER_COLORS.length],
}}
/>
)}
<span className="pip-layer-name">{layer.name}</span>
<button
className="pip-layer-delete"
onClick={(e) => {
e.stopPropagation()
onDelete(layer.id)
}}
title="删除图层"
>
</button>
</div>
))
)}
</div>
)
}
export default LayerList
@@ -1,142 +0,0 @@
/**
* 贴纸素材库(emoji / 图片 / 文字花字)
*/
import React, { useState } from "react"
import type { StickerType, TextStickerPreset } from "@/pages/editing-planner/types"
import {
EMOJI_LIST,
STICKER_TYPE_TABS,
TEXT_PRESET_STYLES,
TEXT_STICKER_PRESET_LABELS,
} from "@/pages/editing-planner/constants/sticker"
interface StickerLibraryProps {
activeTab: StickerType
onTabChange: (tab: StickerType) => void
onAddSticker: (type: StickerType, content: string) => void
}
const StickerLibrary: React.FC<StickerLibraryProps> = ({
activeTab,
onTabChange,
onAddSticker,
}) => {
const [textInput, setTextInput] = useState("")
const imageInputRef = React.useRef<HTMLInputElement>(null)
const handleAddImage = () => {
const val = imageInputRef.current?.value.trim()
if (val) {
onAddSticker("image", val)
if (imageInputRef.current) imageInputRef.current.value = ""
}
}
const handleAddText = () => {
if (textInput.trim()) {
onAddSticker("text", textInput.trim())
setTextInput("")
}
}
return (
<>
{/* 类型 Tab */}
<div className="sticker-tabs">
{STICKER_TYPE_TABS.map((t) => (
<button
key={t.value}
className={`sticker-tab${activeTab === t.value ? " active" : ""}`}
onClick={() => onTabChange(t.value)}
>
{t.label}
</button>
))}
</div>
{/* Tab 内容区 */}
<div className="sticker-tab-content">
{/* Emoji 素材库 */}
{activeTab === "emoji" && (
<div className="sticker-emoji-grid">
{EMOJI_LIST.map((emoji) => (
<button
key={emoji}
className="sticker-emoji-btn"
onClick={() => onAddSticker("emoji", emoji)}
>
{emoji}
</button>
))}
</div>
)}
{/* 图片贴纸 */}
{activeTab === "image" && (
<div className="sticker-image-input">
<input
ref={imageInputRef}
type="text"
className="sticker-url-input"
placeholder="输入图片 URL 添加贴纸..."
onKeyDown={(e) => {
if (e.key === "Enter" && e.currentTarget.value.trim()) {
handleAddImage()
}
}}
/>
<button className="sticker-url-add-btn" onClick={handleAddImage}>
</button>
</div>
)}
{/* 文字花字 */}
{activeTab === "text" && (
<div className="sticker-text-section">
<div className="sticker-text-input-row">
<input
type="text"
className="sticker-text-input"
placeholder="输入文字内容..."
value={textInput}
onChange={(e) => setTextInput(e.target.value)}
/>
<button
className="sticker-text-add-btn"
disabled={!textInput.trim()}
onClick={handleAddText}
>
</button>
</div>
<div className="sticker-text-presets">
<div className="sticker-preset-title"></div>
<div className="sticker-preset-grid">
{(Object.keys(TEXT_STICKER_PRESET_LABELS) as TextStickerPreset[]).map((p) => (
<div
key={p}
className="sticker-preset-preview"
style={{
background:
p === "bubble"
? "rgba(0,0,0,0.5)"
: p === "gradient"
? "linear-gradient(90deg,#f093fb,#f5576c)"
: "#1a1a2e",
}}
>
<span style={TEXT_PRESET_STYLES[p]}></span>
<div className="sticker-preset-name">{TEXT_STICKER_PRESET_LABELS[p]}</div>
</div>
))}
</div>
</div>
</div>
)}
</div>
</>
)
}
export default StickerLibrary
@@ -1,54 +0,0 @@
/**
* 已添加贴纸列表
*/
import React from "react"
import type { StickerItem } from "@/pages/editing-planner/types"
interface StickerListProps {
items: StickerItem[]
selectedId: string | null
onSelect: (id: string) => void
onDelete: (id: string) => void
}
const StickerList: React.FC<StickerListProps> = ({ items, selectedId, onSelect, onDelete }) => {
if (items.length === 0) return null
const getDisplayContent = (item: StickerItem) => {
if (item.type === "emoji") return { icon: item.content, name: "表情贴纸" }
if (item.type === "text") return { icon: "T", name: item.content.slice(0, 10) }
return { icon: "🖼", name: "图片贴纸" }
}
return (
<div className="sticker-list-section">
<div className="sticker-section-title"> ({items.length})</div>
<div className="sticker-list">
{items.map((item) => {
const { icon, name } = getDisplayContent(item)
return (
<div
key={item.id}
className={`sticker-list-item${selectedId === item.id ? " active" : ""}`}
onClick={() => onSelect(item.id)}
>
<span className="sticker-list-icon">{icon}</span>
<span className="sticker-list-name">{name}</span>
<button
className="sticker-list-delete"
onClick={(e) => {
e.stopPropagation()
onDelete(item.id)
}}
>
</button>
</div>
)
})}
</div>
</div>
)
}
export default StickerList
@@ -1,200 +0,0 @@
/**
* 选中贴纸的属性编辑器
*/
import React from "react"
import type { StickerItem, TextStickerPreset } from "@/pages/editing-planner/types"
import {
TEXT_PRESET_STYLES,
TEXT_STICKER_PRESET_LABELS,
} from "@/pages/editing-planner/constants/sticker"
interface StickerPropsEditorProps {
sticker: StickerItem
totalDuration: number
onUpdate: (id: string, partial: Partial<StickerItem>) => void
}
const StickerPropsEditor: React.FC<StickerPropsEditorProps> = ({
sticker,
totalDuration,
onUpdate,
}) => {
return (
<div className="sticker-props-section">
<div className="sticker-section-title"></div>
{/* 位置 */}
<div className="sticker-prop-row">
<span className="sticker-prop-label"> X</span>
<input
type="range"
className="sticker-prop-slider"
min={0}
max={100}
value={sticker.x}
onChange={(e) => onUpdate(sticker.id, { x: Number(e.target.value) })}
/>
<span className="sticker-prop-value">{sticker.x}%</span>
</div>
<div className="sticker-prop-row">
<span className="sticker-prop-label"> Y</span>
<input
type="range"
className="sticker-prop-slider"
min={0}
max={100}
value={sticker.y}
onChange={(e) => onUpdate(sticker.id, { y: Number(e.target.value) })}
/>
<span className="sticker-prop-value">{sticker.y}%</span>
</div>
{/* 尺寸 */}
<div className="sticker-prop-row">
<span className="sticker-prop-label"></span>
<input
type="range"
className="sticker-prop-slider"
min={5}
max={50}
value={sticker.width}
onChange={(e) =>
onUpdate(sticker.id, {
width: Number(e.target.value),
height: Number(e.target.value),
})
}
/>
<span className="sticker-prop-value">{sticker.width}%</span>
</div>
{/* 旋转 */}
<div className="sticker-prop-row">
<span className="sticker-prop-label"></span>
<input
type="range"
className="sticker-prop-slider"
min={-180}
max={180}
value={sticker.rotation}
onChange={(e) => onUpdate(sticker.id, { rotation: Number(e.target.value) })}
/>
<span className="sticker-prop-value">{sticker.rotation}°</span>
</div>
{/* 透明度 */}
<div className="sticker-prop-row">
<span className="sticker-prop-label"></span>
<input
type="range"
className="sticker-prop-slider"
min={0}
max={100}
value={sticker.opacity}
onChange={(e) => onUpdate(sticker.id, { opacity: Number(e.target.value) })}
/>
<span className="sticker-prop-value">{sticker.opacity}%</span>
</div>
{/* 时间 */}
<div className="sticker-prop-row">
<span className="sticker-prop-label"></span>
<input
type="number"
className="sticker-prop-number"
min={0}
max={totalDuration}
step={0.1}
value={sticker.start_time}
onChange={(e) => onUpdate(sticker.id, { start_time: Number(e.target.value) })}
/>
<span className="sticker-prop-label"></span>
<input
type="number"
className="sticker-prop-number"
min={0}
max={totalDuration}
step={0.1}
value={sticker.duration}
onChange={(e) => onUpdate(sticker.id, { duration: Number(e.target.value) })}
/>
</div>
{/* 文字贴纸特有属性 */}
{sticker.type === "text" && (
<>
<div className="sticker-prop-row">
<span className="sticker-prop-label"></span>
<select
className="sticker-prop-select"
value={sticker.text_preset}
onChange={(e) =>
onUpdate(sticker.id, { text_preset: e.target.value as TextStickerPreset })
}
>
{(Object.keys(TEXT_STICKER_PRESET_LABELS) as TextStickerPreset[]).map((p) => (
<option key={p} value={p}>
{TEXT_STICKER_PRESET_LABELS[p]}
</option>
))}
</select>
</div>
<div className="sticker-prop-row">
<span className="sticker-prop-label"></span>
<input
type="range"
className="sticker-prop-slider"
min={12}
max={72}
value={sticker.font_size}
onChange={(e) => onUpdate(sticker.id, { font_size: Number(e.target.value) })}
/>
<span className="sticker-prop-value">{sticker.font_size}px</span>
</div>
<div className="sticker-prop-row">
<span className="sticker-prop-label"></span>
<input
type="color"
className="sticker-prop-color"
value={sticker.text_color}
onChange={(e) => onUpdate(sticker.id, { text_color: e.target.value })}
/>
</div>
</>
)}
{/* 预览 */}
<div className="sticker-preview-box">
<div
className="sticker-preview-item"
style={{
left: `${sticker.x}%`,
top: `${sticker.y}%`,
width: `${sticker.width}%`,
height: `${sticker.width}%`,
transform: `translate(-50%, -50%) rotate(${sticker.rotation}deg)`,
opacity: sticker.opacity / 100,
fontSize: sticker.type === "text" ? `${sticker.font_size}px` : undefined,
...TEXT_PRESET_STYLES[sticker.text_preset],
}}
>
{sticker.type === "emoji" && sticker.content}
{sticker.type === "text" && sticker.content}
{sticker.type === "image" && (
<img
src={sticker.content}
alt="sticker"
style={{
width: "100%",
height: "100%",
objectFit: "contain",
}}
/>
)}
</div>
</div>
</div>
)
}
export default StickerPropsEditor
@@ -1,37 +0,0 @@
/**
* 字幕预览组件
*/
import React from "react"
import type { SubtitleStyleConfig } from "../../types/subtitle"
interface SubtitlePreviewProps {
config: SubtitleStyleConfig
previewText?: string
}
const SubtitlePreview: React.FC<SubtitlePreviewProps> = ({
config,
previewText = "这是一段字幕预览",
}) => {
return (
<div className="sub-field">
<label className="sub-label"></label>
<div className="sub-preview-box">
<span
className="sub-preview-text"
style={{
fontSize: `${Math.min(config.fontSize, 28)}px`,
color: config.fontColor,
fontFamily: config.font,
WebkitTextStroke: config.stroke ? "1px #000" : undefined,
textShadow: config.shadow ? "2px 2px 4px rgba(0,0,0,0.8)" : undefined,
}}
>
{previewText}
</span>
</div>
</div>
)
}
export default SubtitlePreview
@@ -1,80 +0,0 @@
import React from "react"
import type { ClipType } from "../../types"
import { CLIP_TYPE_ICONS, CLIP_TYPE_LABELS } from "../../constants/timeline"
interface AddClipPickerProps {
pickerRef: React.RefObject<HTMLDivElement>
position: { top: number; right: number }
availableTypes: ClipType[]
addType: ClipType
addDuration: number
onTypeChange: (type: ClipType) => void
onDurationChange: (duration: number) => void
onConfirm: () => void
minDuration?: number
maxDuration?: number
}
export const AddClipPicker: React.FC<AddClipPickerProps> = ({
pickerRef,
position,
availableTypes,
addType,
addDuration,
onTypeChange,
onDurationChange,
onConfirm,
minDuration = 1,
maxDuration = 120,
}) => {
return (
<div
ref={pickerRef}
className="ep-add-clip-picker ep-add-clip-picker--portal"
style={{
position: "fixed",
top: position.top,
right: position.right,
}}
>
<div className="ep-add-clip-picker-title"></div>
{/* 类型选择 */}
<div className="ep-add-clip-type-row">
<span className="ep-add-clip-type-label">:</span>
{availableTypes.map((t) => (
<button
key={t}
className={`ep-add-clip-type-btn${addType === t ? " active" : ""}`}
onClick={() => onTypeChange(t)}
>
{CLIP_TYPE_ICONS[t]} {CLIP_TYPE_LABELS[t]}
</button>
))}
</div>
{/* 时长输入 */}
<div className="ep-add-clip-duration-row">
<span className="ep-add-clip-type-label">:</span>
<input
type="number"
className="ep-duration-input"
min={minDuration}
max={maxDuration}
value={addDuration}
onChange={(e) =>
onDurationChange(
Math.max(minDuration, Math.min(maxDuration, Number(e.target.value) || minDuration)),
)
}
/>
<span className="ep-add-clip-duration-unit"></span>
</div>
{/* 确认按钮 */}
<button className="ep-add-clip-confirm-btn" onClick={onConfirm}>
</button>
</div>
)
}
@@ -1,155 +0,0 @@
import React from "react"
import type { ClipData } from "../../types"
import { TRANSITION_OPTIONS } from "@/api/template-editor"
import { CLIP_TYPE_ICONS, CLIP_TYPE_LABELS, MIN_CLIP_WIDTH } from "../../constants/timeline"
interface ClipCardProps {
clip: ClipData
idx: number
isSelected: boolean
isDragging: boolean
isDragOver: boolean
isHovered: boolean
pps: number
trimDragActive: boolean
showTrimHandles: boolean
onDragStart: (e: React.DragEvent, idx: number) => void
onDragOver: (e: React.DragEvent, idx: number) => void
onDragEnd: () => void
onDrop: (e: React.DragEvent, idx: number) => void
onSelect: (clipId: string) => void
onContextMenu: (e: React.MouseEvent, clipId: string) => void
onMouseEnter: () => void
onMouseLeave: () => void
onTrimHandleMouseDown: (e: React.MouseEvent, clipId: string, direction: "left" | "right") => void
onRemove: (clipId: string) => void
}
export const ClipCard: React.FC<ClipCardProps> = ({
clip,
idx,
isSelected,
isDragging,
isDragOver,
isHovered,
pps,
trimDragActive,
showTrimHandles,
onDragStart,
onDragOver,
onDragEnd,
onDrop,
onSelect,
onContextMenu,
onMouseEnter,
onMouseLeave,
onTrimHandleMouseDown,
onRemove,
}) => {
/* 转场指示器 */
const trans = clip.transition
const showTransition = idx > 0 && trans && trans.type !== "none"
const transOpt = showTransition
? TRANSITION_OPTIONS.find((o) => o.value === trans!.type)
: undefined
/* 速度徽章 */
const speed = clip.speed
const showSpeed = speed && Math.abs(speed.rate - 1.0) > 0.01
/* 裁剪状态 */
const hasTrim = !!clip.trim_config
return (
<React.Fragment key={clip.id}>
{/* 转场指示器 */}
{showTransition && transOpt && (
<div
className="ep-transition-indicator"
title={`${transOpt.label} · ${trans!.duration.toFixed(1)}s`}
>
<span className="ep-trans-icon">{transOpt.icon}</span>
<span className="ep-trans-duration">{trans!.duration.toFixed(1)}s</span>
</div>
)}
<div
className={`ep-clip-card ${isSelected ? "selected" : ""} ${isDragging ? "dragging" : ""} ${isDragOver ? "drag-over" : ""} ${hasTrim ? "trimmed" : ""}`}
style={{ width: Math.max(clip.duration * pps, MIN_CLIP_WIDTH) }}
draggable={!trimDragActive}
onDragStart={(e) => onDragStart(e, idx)}
onDragOver={(e) => onDragOver(e, idx)}
onDragEnd={onDragEnd}
onDrop={(e) => onDrop(e, idx)}
onClick={() => onSelect(clip.id)}
onContextMenu={(e) => onContextMenu(e, clip.id)}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
>
{/* 左裁剪手柄 */}
{isHovered && showTrimHandles && (
<div
className="ep-trim-handle ep-trim-handle-left"
onMouseDown={(e) => onTrimHandleMouseDown(e, clip.id, "left")}
title="拖动调整入点"
>
<div className="ep-trim-handle-line" />
</div>
)}
{/* 类型图标 */}
<div className="ep-clip-thumbnail">{CLIP_TYPE_ICONS[clip.type] || "🎬"}</div>
{/* 片段信息 */}
<div className="ep-clip-info">
<span className="ep-clip-name">
{CLIP_TYPE_LABELS[clip.type] || "片段"} {idx + 1}
</span>
<span className="ep-clip-duration">
{clip.duration}s
{hasTrim && (
<span className="ep-trim-indicator" title="已裁剪">
</span>
)}
</span>
</div>
{/* 速度徽章 */}
{showSpeed && <span className="ep-speed-badge">{speed!.rate.toFixed(1)}x</span>}
{/* 裁剪徽章 */}
{hasTrim && (
<span
className="ep-trim-badge"
title={`入点 ${clip.trim_config!.start_time.toFixed(1)}s / 出点 ${clip.trim_config!.end_time.toFixed(1)}s`}
>
</span>
)}
{/* 右裁剪手柄 */}
{isHovered && showTrimHandles && (
<div
className="ep-trim-handle ep-trim-handle-right"
onMouseDown={(e) => onTrimHandleMouseDown(e, clip.id, "right")}
title="拖动调整出点"
>
<div className="ep-trim-handle-line" />
</div>
)}
{/* 删除按钮 */}
<button
className="ep-clip-remove"
onClick={(e) => {
e.stopPropagation()
onRemove(clip.id)
}}
>
</button>
</div>
</React.Fragment>
)
}
@@ -1,49 +0,0 @@
import React from "react"
interface ContextMenuProps {
x: number
y: number
menuRef: React.RefObject<HTMLDivElement>
hasTrim: boolean
onSplit: () => void
onResetTrim: () => void
onDelete: () => void
}
export const ContextMenu: React.FC<ContextMenuProps> = ({
x,
y,
menuRef,
hasTrim,
onSplit,
onResetTrim,
onDelete,
}) => {
return (
<div
ref={menuRef}
className="ep-context-menu"
style={{
position: "fixed",
left: x,
top: y,
}}
>
<div className="ep-context-menu-item" onClick={onSplit}>
<span className="ep-context-menu-icon"></span>
<span></span>
</div>
{hasTrim && (
<div className="ep-context-menu-item" onClick={onResetTrim}>
<span className="ep-context-menu-icon"></span>
<span></span>
</div>
)}
<div className="ep-context-menu-divider" />
<div className="ep-context-menu-item ep-context-menu-item-danger" onClick={onDelete}>
<span className="ep-context-menu-icon">🗑</span>
<span></span>
</div>
</div>
)
}
@@ -1,27 +0,0 @@
import React from "react"
import { getRulerStep, MIN_TRACK_WIDTH } from "../../constants/timeline"
import { generateRulerMarks } from "../../utils/timeline"
interface TimeRulerProps {
totalDuration: number
pps: number
onClick: (e: React.MouseEvent<HTMLDivElement>) => void
}
export const TimeRuler: React.FC<TimeRulerProps> = ({ totalDuration, pps, onClick }) => {
const trackWidth = Math.max(totalDuration * pps, MIN_TRACK_WIDTH)
const step = getRulerStep(totalDuration)
const marks = generateRulerMarks(totalDuration, step)
return (
<div className="ep-time-ruler" onClick={onClick}>
<div className="ep-time-ruler-inner" style={{ width: trackWidth }}>
{marks.map((t) => (
<span key={t} className="ep-time-mark" style={{ left: `${t * pps}px` }}>
{t}s
</span>
))}
</div>
</div>
)
}
@@ -1,36 +0,0 @@
import React from "react"
import { formatTrimTime } from "../../utils/timeline"
interface TrimPreviewProps {
startTime: number
endTime: number
duration: number
x: number
y: number
}
export const TrimPreview: React.FC<TrimPreviewProps> = ({ startTime, endTime, duration, x, y }) => {
return (
<div
className="ep-trim-preview"
style={{
position: "fixed",
left: x + 12,
top: y - 40,
}}
>
<div className="ep-trim-preview-row">
<span className="ep-trim-preview-label"></span>
<span className="ep-trim-preview-value">{formatTrimTime(startTime)}</span>
</div>
<div className="ep-trim-preview-row">
<span className="ep-trim-preview-label"></span>
<span className="ep-trim-preview-value">{formatTrimTime(endTime)}</span>
</div>
<div className="ep-trim-preview-row ep-trim-preview-duration">
<span className="ep-trim-preview-label"></span>
<span className="ep-trim-preview-value">{formatTrimTime(duration)}</span>
</div>
</div>
)
}
@@ -1,62 +0,0 @@
/**
* TTS 滑块组件(语速/语调/音量)
*/
import React from "react"
import { Slider } from "antd"
interface TtsSliderProps {
label: string
value: number
min: number
max: number
step: number
unit?: string
onChange: (val: number) => void
marks?: string[]
tooltipFormatter?: (v: number) => string
}
const TtsSlider: React.FC<TtsSliderProps> = ({
label,
value,
min,
max,
step,
unit = "",
onChange,
marks,
tooltipFormatter,
}) => {
const displayValue =
unit === "x"
? `${value.toFixed(2)}x`
: label === "语调"
? `${value > 0 ? "+" : ""}${value} 半音`
: `${value}${unit}`
return (
<div className="tts-slider-section">
<div className="tts-slider-header">
<span className="tts-slider-label">{label}</span>
<span className="tts-slider-value">{displayValue}</span>
</div>
<Slider
min={min}
max={max}
step={step}
value={value}
onChange={(v) => onChange(v as number)}
tooltip={tooltipFormatter ? { formatter: (v) => tooltipFormatter(v as number) } : undefined}
/>
{marks && (
<div className="tts-slider-marks">
{marks.map((m, i) => (
<span key={i}>{m}</span>
))}
</div>
)}
</div>
)
}
export default TtsSlider
@@ -1,50 +0,0 @@
/**
* TTS 音色选择组件
*/
import React from "react"
import type { TTSVoice } from "@/api/tts"
import { VOICE_CATEGORY_MAP } from "../../constants/tts"
interface VoiceSelectorProps {
voices: TTSVoice[]
voicesLoading: boolean
selectedVoiceId: string
onVoiceSelect: (voiceId: string) => void
}
const VoiceSelector: React.FC<VoiceSelectorProps> = ({
voices,
voicesLoading,
selectedVoiceId,
onVoiceSelect,
}) => {
const voiceCategories = Object.entries(VOICE_CATEGORY_MAP)
return (
<div className="tts-voice-section">
<div className="tts-voice-label">
{voicesLoading && <span className="tts-voice-loading">...</span>}
</div>
<div className="tts-voice-grid">
{voiceCategories.map(([cat, info]) => {
const voice = voices.find((v) => v.category === cat)
const isSelected = voice && selectedVoiceId === voice.id
return (
<button
key={cat}
className={`tts-voice-card${isSelected ? " active" : ""}`}
onClick={() => voice && onVoiceSelect(voice.id)}
disabled={!voice || voicesLoading}
>
<span className="tts-voice-card-icon">{info.icon}</span>
<span className="tts-voice-card-name">{voice?.name || info.label}</span>
</button>
)
})}
</div>
</div>
)
}
export default VoiceSelector
@@ -1,78 +0,0 @@
/**
* 图片水印配置组件
*/
import React from "react"
interface ImageWatermarkSectionProps {
imageUrl: string
localImageUrl: string
width: number | undefined
height: number | undefined
onImageUrlChange: (url: string) => void
onWidthChange: (val: number) => void
onHeightChange: (val: number) => void
}
const ImageWatermarkSection: React.FC<ImageWatermarkSectionProps> = ({
imageUrl,
localImageUrl,
width,
height,
onImageUrlChange,
onWidthChange,
onHeightChange,
}) => {
const displayUrl = localImageUrl || imageUrl || ""
return (
<div className="wp-section">
<div className="wp-field">
<label className="wp-field-label"> URL</label>
<input
className="wp-input"
type="text"
placeholder="https://example.com/logo.png"
value={displayUrl}
onChange={(e) => onImageUrlChange(e.target.value)}
/>
{displayUrl && (
<div className="wp-image-preview">
<img
src={displayUrl}
alt="水印预览"
onError={(e) => {
;(e.target as HTMLImageElement).style.display = "none"
}}
/>
</div>
)}
</div>
<div className="wp-field">
<label className="wp-field-label">0 </label>
<input
className="wp-input"
type="number"
min={0}
max={2000}
value={width ?? 0}
onChange={(e) => onWidthChange(Number(e.target.value))}
/>
</div>
<div className="wp-field">
<label className="wp-field-label">0 </label>
<input
className="wp-input"
type="number"
min={0}
max={2000}
value={height ?? 0}
onChange={(e) => onHeightChange(Number(e.target.value))}
/>
</div>
</div>
)
}
export default ImageWatermarkSection
@@ -1,107 +0,0 @@
/**
* 滚动水印配置组件
*/
import React from "react"
import type { ScrollDirection } from "../../types"
import { SCROLL_DIRECTION_OPTIONS } from "../../constants/watermark"
interface ScrollWatermarkSectionProps {
text: string | undefined
scrollDirection: ScrollDirection | undefined
scrollSpeed: number | undefined
fontSize: number | undefined
color: string | undefined
onTextChange: (text: string) => void
onScrollDirectionChange: (dir: ScrollDirection) => void
onScrollSpeedChange: (speed: number) => void
onFontSizeChange: (size: number) => void
onColorChange: (color: string) => void
}
const ScrollWatermarkSection: React.FC<ScrollWatermarkSectionProps> = ({
text,
scrollDirection,
scrollSpeed,
fontSize,
color,
onTextChange,
onScrollDirectionChange,
onScrollSpeedChange,
onFontSizeChange,
onColorChange,
}) => {
return (
<div className="wp-section">
<div className="wp-field">
<label className="wp-field-label"></label>
<textarea
className="wp-textarea"
placeholder="输入滚动水印文字"
rows={2}
value={text ?? ""}
onChange={(e) => onTextChange(e.target.value)}
/>
</div>
<div className="wp-field">
<label className="wp-field-label"></label>
<select
className="wp-select"
value={scrollDirection ?? "horizontal"}
onChange={(e) => onScrollDirectionChange(e.target.value as ScrollDirection)}
>
{SCROLL_DIRECTION_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
<div className="wp-field">
<label className="wp-field-label"></label>
<div className="wp-slider-row">
<input
className="wp-slider"
type="range"
min={10}
max={200}
value={scrollSpeed ?? 50}
onChange={(e) => onScrollSpeedChange(Number(e.target.value))}
/>
<span className="wp-slider-value">{scrollSpeed ?? 50}px/s</span>
</div>
</div>
<div className="wp-field">
<label className="wp-field-label"></label>
<div className="wp-slider-row">
<input
className="wp-slider"
type="range"
min={12}
max={72}
value={fontSize ?? 24}
onChange={(e) => onFontSizeChange(Number(e.target.value))}
/>
<span className="wp-slider-value">{fontSize ?? 24}px</span>
</div>
</div>
<div className="wp-field">
<label className="wp-field-label"></label>
<div className="wp-color-row">
<input
className="wp-color-input"
type="color"
value={color ?? "#ffffff"}
onChange={(e) => onColorChange(e.target.value)}
/>
<span className="wp-color-value">{color ?? "#ffffff"}</span>
</div>
</div>
</div>
)
}
export default ScrollWatermarkSection
@@ -1,67 +0,0 @@
/**
* 文字水印配置组件
*/
import React from "react"
interface TextWatermarkSectionProps {
text: string | undefined
fontSize: number | undefined
color: string | undefined
onTextChange: (text: string) => void
onFontSizeChange: (size: number) => void
onColorChange: (color: string) => void
}
const TextWatermarkSection: React.FC<TextWatermarkSectionProps> = ({
text,
fontSize,
color,
onTextChange,
onFontSizeChange,
onColorChange,
}) => {
return (
<div className="wp-section">
<div className="wp-field">
<label className="wp-field-label"></label>
<textarea
className="wp-textarea"
placeholder="输入水印文字内容"
rows={3}
value={text ?? ""}
onChange={(e) => onTextChange(e.target.value)}
/>
</div>
<div className="wp-field">
<label className="wp-field-label"></label>
<div className="wp-slider-row">
<input
className="wp-slider"
type="range"
min={12}
max={72}
value={fontSize ?? 24}
onChange={(e) => onFontSizeChange(Number(e.target.value))}
/>
<span className="wp-slider-value">{fontSize ?? 24}px</span>
</div>
</div>
<div className="wp-field">
<label className="wp-field-label"></label>
<div className="wp-color-row">
<input
className="wp-color-input"
type="color"
value={color ?? "#ffffff"}
onChange={(e) => onColorChange(e.target.value)}
/>
<span className="wp-color-value">{color ?? "#ffffff"}</span>
</div>
</div>
</div>
)
}
export default TextWatermarkSection
@@ -1,60 +0,0 @@
/**
* 水印通用设置组件(位置 + 透明度)
*/
import React from "react"
import type { WatermarkPosition } from "../../types"
import { POSITION_OPTIONS } from "../../constants/watermark"
interface WatermarkCommonSectionProps {
position: WatermarkPosition
opacity: number
onPositionChange: (pos: WatermarkPosition) => void
onOpacityChange: (opacity: number) => void
}
const WatermarkCommonSection: React.FC<WatermarkCommonSectionProps> = ({
position,
opacity,
onPositionChange,
onOpacityChange,
}) => {
return (
<div className="wp-section wp-common-section">
<div className="wp-section-divider" />
<div className="wp-common-title"></div>
<div className="wp-field">
<label className="wp-field-label"></label>
<select
className="wp-select"
value={position}
onChange={(e) => onPositionChange(e.target.value as WatermarkPosition)}
>
{POSITION_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
<div className="wp-field">
<label className="wp-field-label"></label>
<div className="wp-slider-row">
<input
className="wp-slider"
type="range"
min={0}
max={1}
step={0.05}
value={opacity}
onChange={(e) => onOpacityChange(Number(e.target.value))}
/>
<span className="wp-slider-value">{Math.round(opacity * 100)}%</span>
</div>
</div>
</div>
)
}
export default WatermarkCommonSection
@@ -1,30 +0,0 @@
/**
* 水印类型 Tab 组件
*/
import React from "react"
import type { WatermarkType } from "../../types"
import { WATERMARK_TABS } from "../../constants/watermark"
interface WatermarkTypeTabsProps {
activeTab: WatermarkType
onTypeChange: (type: WatermarkType) => void
}
const WatermarkTypeTabs: React.FC<WatermarkTypeTabsProps> = ({ activeTab, onTypeChange }) => {
return (
<div className="wp-tabs">
{WATERMARK_TABS.map((tab) => (
<button
key={tab.key}
className={`wp-tab${activeTab === tab.key ? " active" : ""}`}
onClick={() => onTypeChange(tab.key)}
>
<span className="wp-tab-icon">{tab.icon}</span>
<span className="wp-tab-label">{tab.label}</span>
</button>
))}
</div>
)
}
export default WatermarkTypeTabs
@@ -1,12 +0,0 @@
import type { TemplateMode } from "@/api/editing-planner"
/* ──────────── 常量 ──────────── */
export const MODE_LIST: { key: TemplateMode; label: string; icon: string }[] = [
{ key: "pip", label: "混剪", icon: "🖼️" },
{ key: "voice_over", label: "人物口播", icon: "🎙️" },
{ key: "one_take", label: "一镜到底", icon: "🎥" },
{ key: "voice_pip", label: "口播+混剪", icon: "🎭" },
]
export const FILTER_CATEGORIES = ["全部", "种草", "知识", "日常", "推荐"]
@@ -1,34 +0,0 @@
/**
* ClipPropertiesPanel 常量定义
*/
import type { ClipType } from "@/pages/editing-planner/types"
export const POSITION_OPTIONS = [
{ value: "top", label: "顶部" },
{ value: "center", label: "居中" },
{ value: "bottom", label: "底部" },
]
export const FONT_OPTIONS = [
"思源黑体",
"思源宋体",
"苹方",
"PingFang",
"微软雅黑",
"楷体",
"华康俪金黑",
]
export const ANIMATION_OPTIONS = [
{ value: "none", label: "无" },
{ value: "fade", label: "淡入淡出" },
{ value: "slide", label: "滑动" },
{ value: "typewriter", label: "打字机" },
]
/** 片段类型图标/标签 */
export const CLIP_TYPE_ICONS: Record<ClipType, string> = { voice: "🎙️", pip: "🖼️" }
export const CLIP_TYPE_LABELS: Record<ClipType, string> = {
voice: "口播",
pip: "混剪",
}
@@ -1,42 +0,0 @@
/**
* FilterPanel 相关常量
*/
import type { FilterPreset } from "../types"
/** 所有预设列表 */
export const PRESET_LIST: FilterPreset[] = [
"none",
"original",
"fresh",
"warm",
"cool",
"vintage",
"cinema",
"bw",
"sunshine",
"film",
]
/** 预设对应的示例渐变色(用于视觉预览) */
export const PRESET_GRADIENTS: Record<FilterPreset, string> = {
none: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
original: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
fresh: "linear-gradient(135deg, #a8edea 0%, #fed6e3 100%)",
warm: "linear-gradient(135deg, #f093fb 0%, #f5576c 100%)",
cool: "linear-gradient(135deg, #4facfe 0%, #00f2fe 100%)",
vintage: "linear-gradient(135deg, #c79081 0%, #dfa579 100%)",
cinema: "linear-gradient(135deg, #2c3e50 0%, #4ca1af 100%)",
bw: "linear-gradient(135deg, #434343 0%, #000000 100%)",
sunshine: "linear-gradient(135deg, #f6d365 0%, #fda085 100%)",
film: "linear-gradient(135deg, #8e9eab 0%, #eef2f3 100%)",
}
/** 手动调节项配置 */
export const MANUAL_ADJUST_ITEMS = [
{ key: "brightness", label: "亮度", min: -100, max: 100 },
{ key: "contrast", label: "对比度", min: -100, max: 100 },
{ key: "saturation", label: "饱和度", min: -100, max: 100 },
{ key: "temperature", label: "色温", min: -100, max: 100 },
{ key: "tint", label: "色调", min: -100, max: 100 },
{ key: "sharpness", label: "锐度", min: 0, max: 100 },
] as const
@@ -1,10 +0,0 @@
/**
* IntroOutroPanel 相关常量
*/
import type { IntroOutroKind } from "../types"
export const KIND_OPTIONS: { value: IntroOutroKind; label: string; icon: string }[] = [
{ value: "none", label: "无", icon: "🚫" },
{ value: "video", label: "视频", icon: "🎬" },
{ value: "image", label: "图片", icon: "🖼️" },
]
@@ -1,55 +0,0 @@
/**
* PipConfigPanel 常量定义
*/
import type { PipGridPosition, PipAnimType, PipSlideDirection } from "@/pages/editing-planner/types"
/** 九宫格位置 → 百分比坐标映射 */
export const GRID_POSITION_MAP: Record<PipGridPosition, { x: number; y: number }> = {
top_left: { x: 5, y: 5 },
top_center: { x: 37.5, y: 5 },
top_right: { x: 70, y: 5 },
center_left: { x: 5, y: 37.5 },
center: { x: 37.5, y: 37.5 },
center_right: { x: 70, y: 37.5 },
bottom_left: { x: 5, y: 70 },
bottom_center: { x: 37.5, y: 70 },
bottom_right: { x: 70, y: 70 },
}
/** 九宫格位置选项 */
export const GRID_POSITIONS: PipGridPosition[] = [
"top_left",
"top_center",
"top_right",
"center_left",
"center",
"center_right",
"bottom_left",
"bottom_center",
"bottom_right",
]
/** 入场动画选项 */
export const ANIM_OPTIONS: { value: PipAnimType; label: string }[] = [
{ value: "none", label: "无" },
{ value: "fade_in", label: "淡入" },
{ value: "slide_in", label: "滑入" },
]
/** 滑入方向选项 */
export const SLIDE_DIR_OPTIONS: { value: PipSlideDirection; label: string }[] = [
{ value: "left", label: "← 左" },
{ value: "right", label: "→ 右" },
{ value: "up", label: "↑ 上" },
{ value: "down", label: "↓ 下" },
]
/** 预览图层颜色池 */
export const LAYER_COLORS = [
"rgba(22,119,255,0.5)",
"rgba(82,196,26,0.5)",
"rgba(250,173,20,0.5)",
"rgba(255,77,79,0.5)",
"rgba(114,46,209,0.5)",
"rgba(19,194,194,0.5)",
]
@@ -1,64 +0,0 @@
/**
* StickerPanel 常量定义
*/
import type { TextStickerPreset } from "@/pages/editing-planner/types"
import { TEXT_STICKER_PRESET_LABELS } from "@/pages/editing-planner/types"
export { TEXT_STICKER_PRESET_LABELS }
/** 常用 emoji 素材 */
export const EMOJI_LIST = [
"😀",
"😂",
"🥰",
"😎",
"🤩",
"😱",
"🤔",
"😴",
"🥳",
"😍",
"❤️",
"🔥",
"⭐",
"✨",
"💯",
"👍",
"👏",
"🎉",
"🎵",
"💪",
"📌",
"💡",
"🎯",
"✅",
"❌",
"⬆️",
"⬇️",
"➡️",
"⭕",
"🔔",
]
/** 贴纸类型 Tab */
export const STICKER_TYPE_TABS = [
{ value: "emoji" as const, label: "表情贴纸" },
{ value: "image" as const, label: "图片贴纸" },
{ value: "text" as const, label: "文字花字" },
]
/** 文字花字预设对应的 CSS 样式预览 */
export const TEXT_PRESET_STYLES: Record<TextStickerPreset, React.CSSProperties> = {
normal: { color: "#fff", textShadow: "none" },
highlight: { color: "#FFD700", textShadow: "0 0 8px rgba(255,215,0,0.6)" },
bubble: { color: "#fff", background: "rgba(0,0,0,0.5)", borderRadius: 8 },
neon: { color: "#0ff", textShadow: "0 0 6px #0ff, 0 0 12px #0ff" },
shadow: { color: "#fff", textShadow: "2px 2px 4px rgba(0,0,0,0.8)" },
outline: { color: "#fff", WebkitTextStroke: "1px #000" },
gradient: {
color: "transparent",
background: "linear-gradient(90deg,#f093fb,#f5576c)",
WebkitBackgroundClip: "text",
},
handwrite: { color: "#333", fontStyle: "italic", fontFamily: "cursive" },
}
@@ -1,30 +0,0 @@
/**
* SubtitleStylePanel 相关常量
*/
export const POSITION_OPTIONS = [
{ value: "top", label: "顶部" },
{ value: "center", label: "居中" },
{ value: "bottom", label: "底部" },
]
export const FONT_OPTIONS = [
"思源黑体",
"思源宋体",
"苹方",
"PingFang",
"微软雅黑",
"楷体",
"华康俪金黑",
]
export const ANIMATION_OPTIONS = [
{ value: "none", label: "无" },
{ value: "fade", label: "淡入淡出" },
{ value: "slide", label: "滑动" },
{ value: "typewriter", label: "打字机" },
]
export const ASR_LANGUAGE_OPTIONS = [
{ value: "zh", label: "中文" },
{ value: "en", label: "English" },
]
@@ -1,56 +0,0 @@
import type { ClipType } from "../types"
/** 片段类型图标 */
export const CLIP_TYPE_ICONS: Record<ClipType, string> = {
voice: "🎙️",
pip: "🖼️",
}
/** 片段类型标签 */
export const CLIP_TYPE_LABELS: Record<ClipType, string> = {
voice: "口播",
pip: "混剪",
}
/** 默认缩放:每秒像素数 */
export const DEFAULT_PIXELS_PER_SECOND = 40
/** 最小缩放 */
export const MIN_PIXELS_PER_SECOND = 10
/** 最大缩放 */
export const MAX_PIXELS_PER_SECOND = 120
/** 缩放步长 */
export const ZOOM_STEP = 10
/** 片段卡片最小宽度(px */
export const MIN_CLIP_WIDTH = 60
/** 添加面板宽度(px */
export const ADD_PICKER_WIDTH = 240
/** 轨道间距(px */
export const TRACK_GAP = 6
/** 最小裁剪时长(秒) */
export const MIN_TRIM_DURATION = 1
/** 默认添加时长(秒) */
export const DEFAULT_ADD_DURATION = 5
/** 最小添加时长(秒) */
export const MIN_ADD_DURATION = 1
/** 最大添加时长(秒) */
export const MAX_ADD_DURATION = 120
/** 轨道最小宽度(px */
export const MIN_TRACK_WIDTH = 300
/** 时间标尺刻度计算:根据总时长返回刻度步长(秒) */
export const getRulerStep = (totalDuration: number): number => {
if (totalDuration <= 30) return 5
if (totalDuration <= 60) return 10
return 15
}
@@ -1,19 +0,0 @@
/**
* TtsPanel 相关常量
*/
import type { TtsMode } from "../types"
export const VOICE_CATEGORY_MAP: Record<string, { icon: string; label: string }> = {
male: { icon: "👨", label: "男声" },
female: { icon: "👩", label: "女声" },
young: { icon: "🧑", label: "少年" },
service: { icon: "🎧", label: "客服" },
news: { icon: "📰", label: "新闻" },
emotion: { icon: "🎭", label: "情感" },
}
export const TTS_MODE_OPTIONS: { mode: TtsMode; icon: string; label: string }[] = [
{ mode: "none", icon: "🔇", label: "无配音" },
{ mode: "upload", icon: "📁", label: "上传配音" },
{ mode: "tts", icon: "🤖", label: "TTS 合成" },
]
@@ -1,25 +0,0 @@
/**
* WatermarkPanel 相关常量
*/
import type { WatermarkType, WatermarkPosition, ScrollDirection } from "../types"
export const WATERMARK_TABS: { key: WatermarkType; label: string; icon: string }[] = [
{ key: "none", label: "无水印", icon: "🚫" },
{ key: "image", label: "图片水印", icon: "🖼️" },
{ key: "text", label: "文字水印", icon: "📝" },
{ key: "scroll", label: "滚动水印", icon: "📜" },
]
export const POSITION_OPTIONS: { value: WatermarkPosition; label: string }[] = [
{ value: "top_left", label: "左上角" },
{ value: "top_right", label: "右上角" },
{ value: "bottom_left", label: "左下角" },
{ value: "bottom_right", label: "右下角" },
{ value: "center", label: "居中" },
]
export const SCROLL_DIRECTION_OPTIONS: { value: ScrollDirection; label: string }[] = [
{ value: "horizontal", label: "水平滚动" },
{ value: "vertical", label: "垂直滚动" },
{ value: "diagonal", label: "对角滚动" },
]

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