Compare commits
53 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8f99774620 | |||
| 3ff041440b | |||
| b896873ece | |||
| 4df4a937e4 | |||
| 673d18aa83 | |||
| 3b949a464f | |||
| daa0e1f7b5 | |||
| 5fa915cdad | |||
| 1242b62165 | |||
| 4251970b49 | |||
| 64387c00bb | |||
| eae6dcac4f | |||
| dab2a4fdb2 | |||
| 6a303a3b6e | |||
| 23573a8209 | |||
| 38e40d727b | |||
| dcfddc6695 | |||
| e43737658a | |||
| 30457629da | |||
| 7136773ee5 | |||
| 37d0694b68 | |||
| d932f6e0f7 | |||
| 35e171c2ac | |||
| 39c89f018a | |||
| c704f9b844 | |||
| c71352c2b7 | |||
| 7e63bf7e26 | |||
| 1943630f8a | |||
| eaf035626f | |||
| e946dbf625 | |||
| 56ab6bcef4 | |||
| 6fffcd105e | |||
| 7a8e99cd8e | |||
| 6a6f8e7a22 | |||
| 246367f6f0 | |||
| 107a1bd724 | |||
| cfc836484f | |||
| ef30c7db26 | |||
| 5d9544a76f | |||
| f6c366b979 | |||
| aeefd7aba5 | |||
| a5f1a31ca3 | |||
| 07805e72c5 | |||
| 4105a4df41 | |||
| a1bda3e484 | |||
| 7bf25023a0 | |||
| e8f7788e56 | |||
| 27c1f05f74 | |||
| de1311fe31 | |||
| 7ce78a3e8a | |||
| edb141b1da | |||
| 2e67be39f7 | |||
| f55d0d6f0e |
@@ -8,7 +8,7 @@ permissions:
|
||||
jobs:
|
||||
ci-health-report:
|
||||
name: CI健康度每日巡检
|
||||
runs-on: saas
|
||||
runs-on: ci-l2
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: Checkout code
|
||||
|
||||
@@ -58,24 +58,56 @@ jobs:
|
||||
run: |
|
||||
set -eu
|
||||
PR_NUMBER=$(echo "$GITHUB_REF" | sed 's|refs/pull/||; s|/.*||')
|
||||
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
|
||||
|
||||
# 分页获取所有变更文件(修复>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
|
||||
echo "skip_backend=true" >> $GITHUB_OUTPUT
|
||||
echo "skip_frontend=false" >> $GITHUB_OUTPUT
|
||||
echo "✅ 纯前端改动,跳过后端检查"
|
||||
elif [ "$FRONTEND_COUNT" = "0" ] && [ "$BACKEND_COUNT" -gt "0" ]; then
|
||||
elif [ "$PURE_BACKEND" = "true" ]; 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
|
||||
echo "🔧 包含全栈变更,运行完整CI"
|
||||
if [ "$INFRA_COUNT" -gt "0" ]; then
|
||||
echo "🏗️ 包含基础设施变更,强制运行完整CI"
|
||||
else
|
||||
echo "🔧 包含全栈变更,运行完整CI"
|
||||
fi
|
||||
fi
|
||||
|
||||
- name: Report CI trace
|
||||
@@ -1588,7 +1620,6 @@ jobs:
|
||||
set -eu
|
||||
python3 scripts/ci/acr_cleanup.py \
|
||||
--keep 20 \
|
||||
--pr-days 7 \
|
||||
--execute
|
||||
|
||||
- name: Job duration summary
|
||||
|
||||
@@ -16,15 +16,15 @@ permissions:
|
||||
jobs:
|
||||
monitor:
|
||||
name: Monitor CI Trigger Reliability
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ci-l2
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
# 网络波动自动重试2次
|
||||
retry:
|
||||
max_attempts: 2
|
||||
retry_on: error
|
||||
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: Check CI trigger status for all open PRs
|
||||
env:
|
||||
|
||||
@@ -15,20 +15,18 @@ concurrency:
|
||||
jobs:
|
||||
code-review:
|
||||
name: AI Code Review
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ci-l2
|
||||
# 跳过草稿 PR
|
||||
if: ${{ !gitea.event.pull_request.draft }}
|
||||
|
||||
steps:
|
||||
# actions/checkout 由 runner 在宿主机层面处理,不受容器网络影响
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
# 网络波动自动重试2次
|
||||
retry:
|
||||
max_attempts: 2
|
||||
retry_on: error
|
||||
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: Install dependencies
|
||||
run: |
|
||||
|
||||
@@ -12,7 +12,7 @@ jobs:
|
||||
# ── 1. 生产环境冒烟测试 ─────────────────────────────────────────────
|
||||
production-smoke:
|
||||
name: Production Smoke Test
|
||||
runs-on: saas
|
||||
runs-on: ci-l2
|
||||
timeout-minutes: 8
|
||||
outputs:
|
||||
report: ${{ steps.smoke.outputs.report }}
|
||||
@@ -121,7 +121,7 @@ jobs:
|
||||
# ── 2. Staging API 集成测试 ─────────────────────────────────────────
|
||||
staging-api-tests:
|
||||
name: Staging API Integration Tests
|
||||
runs-on: saas
|
||||
runs-on: ci-l2
|
||||
timeout-minutes: 10
|
||||
outputs:
|
||||
report: ${{ steps.smoke.outputs.report }}
|
||||
@@ -176,6 +176,9 @@ jobs:
|
||||
- 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)
|
||||
@@ -183,8 +186,8 @@ jobs:
|
||||
docker run --rm \
|
||||
-e BASE_URL=https://staging-api.xiaoxiajianji.com \
|
||||
-e WEB_URL=https://staging.xiaoxiajianji.com \
|
||||
-e TEST_USER=18314979086@163.com \
|
||||
-e TEST_PASSWORD=Ying1234 \
|
||||
-e TEST_USER="$STAGING_TEST_USER" \
|
||||
-e TEST_PASSWORD="$STAGING_TEST_PASSWORD" \
|
||||
-e CLEANUP_ENABLED=1 \
|
||||
-e PERF_CHECK_ENABLED=1 \
|
||||
-e PERF_WARN_THRESHOLD_MS=500 \
|
||||
@@ -270,7 +273,7 @@ jobs:
|
||||
# ── 3. Staging 浏览器 E2E ──────────────────────────────────────────
|
||||
staging-e2e:
|
||||
name: Staging Browser E2E
|
||||
runs-on: saas
|
||||
runs-on: ci-l2
|
||||
timeout-minutes: 15
|
||||
outputs:
|
||||
report: ${{ steps.smoke.outputs.report }}
|
||||
@@ -371,7 +374,7 @@ jobs:
|
||||
# ── 4. 性能基线巡检 ────────────────────────────────────────────────
|
||||
performance-check:
|
||||
name: Performance Baseline Check
|
||||
runs-on: saas
|
||||
runs-on: ci-l2
|
||||
timeout-minutes: 8
|
||||
outputs:
|
||||
report: ${{ steps.report.outputs.report }}
|
||||
@@ -380,6 +383,9 @@ 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)
|
||||
@@ -415,9 +421,10 @@ 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 '{"email":"18314979086@163.com","password":"Ying1234"}' \
|
||||
-d "$LOGIN_BODY" \
|
||||
"https://staging-api.xiaoxiajianji.com/api/v1/auth/login" \
|
||||
--max-time 10 2>&1)
|
||||
AUTH_CODE=$(echo "$AUTH_RESP" | tail -1)
|
||||
@@ -447,7 +454,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 '{\"email\":\"18314979086@163.com\",\"password\":\"Ying1234\"}'"
|
||||
CURL_ARGS="$CURL_ARGS -X POST -H 'Content-Type: application/json' -d \"$LOGIN_BODY\""
|
||||
fi
|
||||
if [ -n "$TOKEN" ] && [ "$name" != "健康检查" ]; then
|
||||
CURL_ARGS="$CURL_ARGS -H 'Authorization: Bearer $TOKEN'"
|
||||
@@ -495,6 +502,9 @@ 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 ""
|
||||
@@ -509,10 +519,11 @@ 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 '{"email":"18314979086@163.com","password":"Ying1234"}' \
|
||||
-d "$LOGIN_BODY" \
|
||||
"https://staging-api.xiaoxiajianji.com/api/v1/auth/login" \
|
||||
--max-time 10 2>&1)
|
||||
AUTH_CODE=$(echo "$AUTH_RESP" | tail -1)
|
||||
@@ -528,7 +539,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 '{\"email\":\"18314979086@163.com\",\"password\":\"Ying1234\"}'"
|
||||
CURL_ARGS="$CURL_ARGS -X POST -H 'Content-Type: application/json' -d \"$LOGIN_BODY\""
|
||||
fi
|
||||
if [ -n "$TOKEN" ] && [ "$name" != "健康检查" ]; then
|
||||
CURL_ARGS="$CURL_ARGS -H 'Authorization: Bearer $TOKEN'"
|
||||
@@ -631,7 +642,7 @@ jobs:
|
||||
# ── 5. 每日巡检汇总报告 ────────────────────────────────────────────
|
||||
daily-report:
|
||||
name: Daily Check Report
|
||||
runs-on: saas
|
||||
runs-on: ci-l2
|
||||
timeout-minutes: 2
|
||||
if: always()
|
||||
needs:
|
||||
|
||||
@@ -93,44 +93,31 @@ jobs:
|
||||
shell: sh
|
||||
run: |
|
||||
set -eu
|
||||
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
|
||||
cd apps/web
|
||||
|
||||
docker run --rm \
|
||||
-v "$PWD:/workspace" \
|
||||
-v "$NPM_CACHE_VOLUME:/workspace/apps/web/node_modules" \
|
||||
-w /workspace/apps/web \
|
||||
-e VITE_API_URL=https://staging-api.xiaoxiajianji.com \
|
||||
docker.m.daocloud.io/library/node:20 \
|
||||
sh -lc '
|
||||
PACKAGE_LOCK_HASH=$(md5sum package-lock.json 2>/dev/null | cut -d" " -f1)
|
||||
CACHE_HASH_FILE="node_modules/.package-lock-hash"
|
||||
CACHE_VALID=false
|
||||
if [ -f "$CACHE_HASH_FILE" ] && [ "$(cat "$CACHE_HASH_FILE")" = "$PACKAGE_LOCK_HASH" ] && [ -x "node_modules/.bin/vite" ] && [ -x "node_modules/.bin/tsc" ]; then
|
||||
CACHE_VALID=true
|
||||
echo "Cache hit: dependencies valid, skipping npm ci"
|
||||
fi
|
||||
if [ "$CACHE_VALID" = "false" ]; then
|
||||
echo "Cache miss or invalid: running npm ci..."
|
||||
if ! npm ci; then
|
||||
echo "npm ci failed, cleaning node_modules and retrying..."
|
||||
rm -rf node_modules
|
||||
mkdir -p node_modules
|
||||
npm ci
|
||||
fi
|
||||
echo "$PACKAGE_LOCK_HASH" > "$CACHE_HASH_FILE"
|
||||
echo "Dependencies installed, cache updated"
|
||||
fi
|
||||
echo "Running TypeScript check..."
|
||||
npx --no-install tsc
|
||||
echo "Running Vite build..."
|
||||
npx --no-install vite build
|
||||
echo "Build completed successfully"
|
||||
ls -la dist/
|
||||
'
|
||||
# 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/
|
||||
|
||||
- name: Install SSH client and rsync
|
||||
shell: sh
|
||||
|
||||
@@ -26,6 +26,13 @@ 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 (
|
||||
DEFAULT_CLIP_DURATION,
|
||||
create_clips_from_configs,
|
||||
distribute_assets,
|
||||
generate_default_clips,
|
||||
map_clip_types_for_mode,
|
||||
)
|
||||
from packages.domain.template_clip_config import ClipType, TemplateClipConfig
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -163,83 +170,18 @@ class PlanGeneratorService:
|
||||
plan_id: str,
|
||||
clip_configs: List[TemplateClipConfig],
|
||||
) -> List[EditPlanClip]:
|
||||
"""从 TemplateClipConfig 列表创建 EditPlanClip 列表(未持久化)"""
|
||||
clips: List[EditPlanClip] = []
|
||||
# 按 order 排序
|
||||
sorted_configs = sorted(clip_configs, key=lambda c: c.order)
|
||||
"""从 TemplateClipConfig 列表创建 EditPlanClip 列表(未持久化).
|
||||
|
||||
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
|
||||
委托给 plan_generator_utils.create_clips_from_configs 纯函数。
|
||||
"""
|
||||
return create_clips_from_configs(plan_id, clip_configs)
|
||||
|
||||
def _map_clip_types_for_mode(self, clips: List[EditPlanClip], editing_mode: str) -> None:
|
||||
"""将模板 clip_config 生成的 MAIN 类型片段,按 editing_mode 映射为对应角色类型。
|
||||
"""将 MAIN 类型片段按 editing_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 不变
|
||||
委托给 plan_generator_utils.map_clip_types_for_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 不变,无需处理
|
||||
map_clip_types_for_mode(clips, editing_mode)
|
||||
|
||||
def _generate_default_clips(
|
||||
self,
|
||||
@@ -247,101 +189,11 @@ class PlanGeneratorService:
|
||||
editing_mode: str,
|
||||
asset_count: int,
|
||||
) -> List[EditPlanClip]:
|
||||
"""无 clip_configs 时,根据 editing_mode 生成默认 clip 结构
|
||||
"""无 clip_configs 时,根据 editing_mode 生成默认 clip 结构.
|
||||
|
||||
- ONE_TAKE: N 个 main clips(N = asset_count,至少1个)
|
||||
- PIP: 1 个 main + (N-1) 个 overlay(N = asset_count)
|
||||
- VOICE_OVER: N 个 main clips + 标记需要配音
|
||||
- VOICE_PIP: 1 个 background + 1 个 corner_voice + (N-2) 个 b_roll
|
||||
委托给 plan_generator_utils.generate_default_clips 纯函数。
|
||||
"""
|
||||
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 clips(B-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
|
||||
return generate_default_clips(plan_id, editing_mode, asset_count)
|
||||
|
||||
def _distribute_assets(
|
||||
self,
|
||||
@@ -349,89 +201,9 @@ class PlanGeneratorService:
|
||||
asset_ids: List[str],
|
||||
editing_mode: str,
|
||||
) -> None:
|
||||
"""按 editing_mode 将素材分配到 clips(就地修改,未持久化)
|
||||
"""按 editing_mode 将素材分配到 clips(就地修改,未持久化).
|
||||
|
||||
分配策略:
|
||||
- ONE_TAKE: 素材按顺序依次分配给 main 类型 clips
|
||||
- PIP: 第1个素材→main(全屏背景),其余→交替分配给 overlay clips
|
||||
- VOICE_OVER: 素材→main clips (B-roll)
|
||||
- VOICE_PIP: 第1个→background, 第2个→corner_voice, 其余→b_roll
|
||||
委托给 plan_generator_utils.distribute_assets 纯函数。
|
||||
"""
|
||||
if not asset_ids or not clips:
|
||||
return
|
||||
distribute_assets(clips, asset_ids, editing_mode)
|
||||
|
||||
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])
|
||||
|
||||
@@ -13,57 +13,31 @@
|
||||
- 最低质量分门槛:自动过滤低质量素材
|
||||
- 时长多样性:保证选出的素材时长分布均匀(短/中/长各占一定比例)
|
||||
- 兼容全部模式:素材库模式和项目模式都可用
|
||||
|
||||
纯逻辑部分已抽离到 packages.domain.asset_scoring。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
|
||||
from packages.domain.asset_scoring import (
|
||||
MEDIUM_BUCKET_MAX as _MEDIUM_BUCKET_MAX,
|
||||
MIN_QUALITY_SCORE as _MIN_QUALITY_SCORE,
|
||||
OPTIMAL_DURATION_MAX as _OPTIMAL_DURATION_MAX,
|
||||
OPTIMAL_DURATION_MIN as _OPTIMAL_DURATION_MIN,
|
||||
SHORT_BUCKET_MAX as _SHORT_BUCKET_MAX,
|
||||
TARGET_HEIGHT as _TARGET_HEIGHT,
|
||||
TARGET_WIDTH as _TARGET_WIDTH,
|
||||
AssetScoreDetail,
|
||||
SmartSelectResult,
|
||||
diverse_selection,
|
||||
filter_candidates,
|
||||
score_asset_detail,
|
||||
)
|
||||
|
||||
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:
|
||||
"""智能素材选择器.
|
||||
@@ -74,9 +48,9 @@ class SmartAssetSelector:
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
min_quality_score: float = _MIN_QUALITY_SCORE,
|
||||
target_width: int = _TARGET_WIDTH,
|
||||
target_height: int = _TARGET_HEIGHT,
|
||||
min_quality_score: float = 30.0,
|
||||
target_width: int = 1920,
|
||||
target_height: int = 1080,
|
||||
):
|
||||
self.min_quality_score = min_quality_score
|
||||
self.target_width = target_width
|
||||
@@ -102,21 +76,7 @@ class SmartAssetSelector:
|
||||
SmartSelectResult 选择结果
|
||||
"""
|
||||
# 1. 过滤:只保留 ready 状态的视频素材 + 最低质量分门槛
|
||||
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)
|
||||
candidates, filtered_out = filter_candidates(assets, self.min_quality_score)
|
||||
|
||||
if not candidates:
|
||||
return SmartSelectResult(
|
||||
@@ -130,7 +90,16 @@ class SmartAssetSelector:
|
||||
# 2. 对每个候选素材评分
|
||||
scored: list[AssetScoreDetail] = []
|
||||
for asset in candidates:
|
||||
detail = self._score_asset(asset)
|
||||
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,
|
||||
)
|
||||
scored.append(detail)
|
||||
|
||||
# 3. 按总分降序排列
|
||||
@@ -138,7 +107,7 @@ class SmartAssetSelector:
|
||||
|
||||
# 4. 多样性选择(如果需要且数量有限制)
|
||||
if ensure_diversity and count > 0 and len(scored) > count:
|
||||
selected = self._diverse_selection(scored, count)
|
||||
selected = diverse_selection(scored, count)
|
||||
else:
|
||||
# 无数量限制或不要求多样性,直接按排名取
|
||||
selected = scored if count <= 0 else scored[:count]
|
||||
@@ -162,165 +131,39 @@ class SmartAssetSelector:
|
||||
)
|
||||
return result
|
||||
|
||||
# ── 内部方法 ──────────────────────────────────────────────────────────────
|
||||
# ── 向后兼容:私有方法别名(委托给 asset_scoring 纯函数) ────────────────
|
||||
|
||||
def _score_asset(self, asset) -> AssetScoreDetail:
|
||||
"""对单个素材进行多维度评分."""
|
||||
# 质量分
|
||||
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(
|
||||
"""对单个素材进行多维度评分(向后兼容)."""
|
||||
return score_asset_detail(
|
||||
asset_id=asset.id,
|
||||
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,
|
||||
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,
|
||||
)
|
||||
|
||||
def _score_resolution(self, width: int | None, height: int | None) -> float:
|
||||
"""分辨率评分:越接近目标分辨率得分越高,低于480p扣分严重."""
|
||||
if width is None or height is None or width <= 0 or height <= 0:
|
||||
return 0.5 # 未知分辨率给中评分
|
||||
"""分辨率评分(向后兼容)."""
|
||||
from packages.domain.asset_scoring import score_resolution
|
||||
|
||||
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))
|
||||
return score_resolution(width, height, self.target_width, self.target_height)
|
||||
|
||||
def _score_duration(self, duration: float | None) -> float:
|
||||
"""时长评分:3-30秒最佳,过短或过长都扣分."""
|
||||
if duration is None or duration <= 0:
|
||||
return 0.5 # 未知时长给中评分
|
||||
"""时长评分(向后兼容)."""
|
||||
from packages.domain.asset_scoring import score_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)
|
||||
return score_duration(duration)
|
||||
|
||||
def _score_bitrate(self, file_size: int, duration: float | None) -> float:
|
||||
"""码率评分:根据文件大小和时长估算码率,适中得分高."""
|
||||
if not file_size or not duration or duration <= 0:
|
||||
return 0.5 # 未知给中评分
|
||||
"""码率评分(向后兼容)."""
|
||||
from packages.domain.asset_scoring import score_bitrate
|
||||
|
||||
# 估算码率(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)
|
||||
return score_bitrate(file_size, duration)
|
||||
|
||||
def _diverse_selection(self, scored: list[AssetScoreDetail], count: int) -> list[AssetScoreDetail]:
|
||||
"""多样性选择:按时长分桶,保证每个桶都有素材.
|
||||
|
||||
策略:
|
||||
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]
|
||||
"""多样性选择(向后兼容)."""
|
||||
return diverse_selection(scored, count)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,99 +2,13 @@
|
||||
* 右栏设置面板 — V8 原型 1:1 还原
|
||||
* 字幕设置 + BGM设置 + 片段详情
|
||||
*/
|
||||
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: "混剪",
|
||||
}
|
||||
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"
|
||||
|
||||
const ClipPropertiesPanel: React.FC<ClipPropertiesPanelProps> = ({
|
||||
selectedClip,
|
||||
@@ -122,165 +36,19 @@ const ClipPropertiesPanel: React.FC<ClipPropertiesPanelProps> = ({
|
||||
onOpenGreenScreenDrawer,
|
||||
onOpenStickerDrawer,
|
||||
}) => {
|
||||
const navigate = useNavigate()
|
||||
const { previewingId, handlePreviewVoice, stopPreview } = useVoicePreview()
|
||||
|
||||
/* ── 配音试听 ── */
|
||||
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">
|
||||
{/* ═══ 字幕设置 ═══ */}
|
||||
<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>
|
||||
<SubtitleSettingsSection
|
||||
settings={subtitleSettings}
|
||||
onChange={onSubtitleSettingsChange}
|
||||
onOpenSubtitleDrawer={onOpenSubtitleDrawer}
|
||||
/>
|
||||
|
||||
{/* ═══ BGM 设置 ═══ */}
|
||||
<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>
|
||||
<BgmSettingsSection settings={bgmSettings} onOpenBgmDrawer={onOpenBgmDrawer} />
|
||||
|
||||
{/* ═══ 水印设置 ═══ */}
|
||||
<div className="ep-settings-section">
|
||||
@@ -374,258 +142,30 @@ const ClipPropertiesPanel: React.FC<ClipPropertiesPanelProps> = ({
|
||||
|
||||
{/* ═══ 片段详情(选中时显示) ═══ */}
|
||||
{selectedClip && (
|
||||
<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>
|
||||
<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}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ═══ 统计信息(始终显示) ═══ */}
|
||||
{/* ═══ 统计信息(未选中时显示) ═══ */}
|
||||
{!selectedClip && (
|
||||
<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>
|
||||
<StatsSection
|
||||
clipsCount={clipsCount}
|
||||
totalDuration={totalDuration}
|
||||
currentMode={currentMode}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,304 @@
|
||||
/**
|
||||
* 所有弹窗和 Drawer 组件的集合
|
||||
* 纯渲染层,业务逻辑和 state 留在父组件
|
||||
*/
|
||||
import React from "react"
|
||||
import type { TemplateCategory } from "@/api/editing-planner"
|
||||
import type {
|
||||
ClipData,
|
||||
TransitionConfig,
|
||||
SpeedConfig,
|
||||
TtsConfig,
|
||||
WatermarkConfig,
|
||||
IntroOutroConfig,
|
||||
PipConfig,
|
||||
FilterConfig,
|
||||
ChromaKeyConfig,
|
||||
StickerConfig,
|
||||
} from "../types"
|
||||
import { DEFAULT_TRANSITION, DEFAULT_SPEED, DEFAULT_TTS_CONFIG } from "../types"
|
||||
import type { SubtitleStyleConfig } from "../types/subtitle"
|
||||
import type { BgmMixConfig } from "@/api/bgm"
|
||||
import SaveModal from "./SaveModal"
|
||||
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"
|
||||
|
||||
interface EditingDrawersProps {
|
||||
/* 保存弹窗 */
|
||||
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 */
|
||||
bgmDrawerOpen: boolean
|
||||
bgmSettings: BgmMixConfig
|
||||
onCloseBgmDrawer: () => void
|
||||
onChangeBgmSettings: (config: BgmMixConfig) => void
|
||||
/* 字幕 */
|
||||
subtitleDrawerOpen: boolean
|
||||
subtitleSettings: SubtitleStyleConfig
|
||||
onCloseSubtitleDrawer: () => void
|
||||
onChangeSubtitleSettings: (config: SubtitleStyleConfig) => void
|
||||
/* 转场 */
|
||||
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
|
||||
/* TTS 配音 */
|
||||
ttsDrawerOpen: boolean
|
||||
ttsTargetClipId: string | null
|
||||
onCloseTtsDrawer: () => void
|
||||
onTtsChange: (config: TtsConfig) => void
|
||||
/* 水印 */
|
||||
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
|
||||
/* 共享数据 */
|
||||
clips: ClipData[]
|
||||
totalDuration: number
|
||||
}
|
||||
|
||||
const EditingDrawers: React.FC<EditingDrawersProps> = ({
|
||||
saveModalOpen,
|
||||
saveLoading,
|
||||
isUpdate,
|
||||
draftName,
|
||||
draftCategory,
|
||||
draftTags,
|
||||
categories,
|
||||
estimatedDuration,
|
||||
onNameChange,
|
||||
onCategoryChange,
|
||||
onTagsChange,
|
||||
onSave,
|
||||
onCancelSave,
|
||||
bgmDrawerOpen,
|
||||
bgmSettings,
|
||||
onCloseBgmDrawer,
|
||||
onChangeBgmSettings,
|
||||
subtitleDrawerOpen,
|
||||
subtitleSettings,
|
||||
onCloseSubtitleDrawer,
|
||||
onChangeSubtitleSettings,
|
||||
transitionDrawerOpen,
|
||||
transitionTargetClipId,
|
||||
onCloseTransitionDrawer,
|
||||
onTransitionChange,
|
||||
speedDrawerOpen,
|
||||
speedTargetClipId,
|
||||
onCloseSpeedDrawer,
|
||||
onSpeedChange,
|
||||
onApplySpeedAll,
|
||||
ttsDrawerOpen,
|
||||
ttsTargetClipId,
|
||||
onCloseTtsDrawer,
|
||||
onTtsChange,
|
||||
watermarkDrawerOpen,
|
||||
watermarkSettings,
|
||||
onCloseWatermarkDrawer,
|
||||
onWatermarkChange,
|
||||
introOutroDrawerOpen,
|
||||
introOutroSettings,
|
||||
onCloseIntroOutroDrawer,
|
||||
onIntroOutroChange,
|
||||
pipDrawerOpen,
|
||||
pipSettings,
|
||||
onClosePipDrawer,
|
||||
onPipChange,
|
||||
filterDrawerOpen,
|
||||
filterSettings,
|
||||
onCloseFilterDrawer,
|
||||
onFilterChange,
|
||||
chromaKeyDrawerOpen,
|
||||
chromaKeySettings,
|
||||
onCloseChromaKeyDrawer,
|
||||
onChromaKeyChange,
|
||||
stickerDrawerOpen,
|
||||
stickerSettings,
|
||||
onCloseStickerDrawer,
|
||||
onStickerChange,
|
||||
clips,
|
||||
totalDuration,
|
||||
}) => {
|
||||
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 (
|
||||
<>
|
||||
{/* ═══ 保存弹窗 ═══ */}
|
||||
<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}
|
||||
/>
|
||||
|
||||
{/* ═══ BGM 选择器 Drawer ═══ */}
|
||||
<BgmSelector
|
||||
open={bgmDrawerOpen}
|
||||
onClose={onCloseBgmDrawer}
|
||||
config={bgmSettings}
|
||||
onChange={onChangeBgmSettings}
|
||||
/>
|
||||
|
||||
{/* ═══ 字幕样式配置 Drawer ═══ */}
|
||||
<SubtitleStylePanel
|
||||
open={subtitleDrawerOpen}
|
||||
onClose={onCloseSubtitleDrawer}
|
||||
config={subtitleSettings}
|
||||
onChange={onChangeSubtitleSettings}
|
||||
/>
|
||||
|
||||
{/* ═══ 转场特效选择器 Drawer ═══ */}
|
||||
<TransitionSelector
|
||||
open={transitionDrawerOpen}
|
||||
onClose={onCloseTransitionDrawer}
|
||||
config={transitionConfig}
|
||||
onChange={onTransitionChange}
|
||||
title={transitionTitle}
|
||||
/>
|
||||
|
||||
{/* ═══ 片段调速面板 Drawer ═══ */}
|
||||
{speedTargetClipId && (
|
||||
<SpeedPanel
|
||||
open={speedDrawerOpen}
|
||||
onClose={onCloseSpeedDrawer}
|
||||
config={speedConfig}
|
||||
onChange={onSpeedChange}
|
||||
onApplyAll={onApplySpeedAll}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ═══ TTS 配音面板 Drawer ═══ */}
|
||||
{ttsTargetClipId && (
|
||||
<TtsPanel
|
||||
open={ttsDrawerOpen}
|
||||
onClose={onCloseTtsDrawer}
|
||||
config={ttsConfig}
|
||||
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 EditingDrawers
|
||||
@@ -0,0 +1,246 @@
|
||||
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,8 +4,11 @@
|
||||
*/
|
||||
import React, { useCallback } from "react"
|
||||
import { Drawer, Switch } from "antd"
|
||||
import type { FilterConfig, FilterPreset } from "../types"
|
||||
import { DEFAULT_FILTER_CONFIG, FILTER_PRESET_LABELS } from "../types"
|
||||
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"
|
||||
|
||||
interface FilterPanelProps {
|
||||
open: boolean
|
||||
@@ -14,34 +17,6 @@ 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>) => {
|
||||
@@ -54,7 +29,6 @@ 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") {
|
||||
@@ -70,6 +44,13 @@ 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="滤镜调色"
|
||||
@@ -90,110 +71,10 @@ const FilterPanel: React.FC<FilterPanelProps> = ({ open, onClose, config, onChan
|
||||
</div>
|
||||
|
||||
{/* 预设滤镜选择 */}
|
||||
<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>
|
||||
<FilterPresetGrid selectedPreset={config.preset} onPresetSelect={handlePresetSelect} />
|
||||
|
||||
{/* 手动调节 */}
|
||||
<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>
|
||||
<FilterManualAdjust config={config} onChange={handleManualChange} />
|
||||
|
||||
{/* 预览色块 */}
|
||||
<div className="filter-section">
|
||||
|
||||
@@ -5,19 +5,14 @@
|
||||
*/
|
||||
import React, { useCallback } from "react"
|
||||
import { Drawer } from "antd"
|
||||
import type { IntroOutroConfig, IntroOutroItem, IntroOutroKind, TransitionType } from "../types"
|
||||
import { DEFAULT_INTRO_OUTRO } from "../types"
|
||||
import { TRANSITION_OPTIONS } from "@/api/template-editor"
|
||||
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"
|
||||
|
||||
/* ──────────── 常量 ──────────── */
|
||||
|
||||
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
|
||||
@@ -73,210 +68,24 @@ const IntroOutroPanel: React.FC<IntroOutroPanelProps> = ({ open, onClose, config
|
||||
className="intro-outro-panel-drawer"
|
||||
>
|
||||
{/* ═══ 片头区块 ═══ */}
|
||||
<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.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.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>
|
||||
<IntroOutroBlock
|
||||
title="片尾"
|
||||
icon="🏁"
|
||||
item={config.outro}
|
||||
transitionLabel="退出过渡动画"
|
||||
onKindChange={handleOutroKindChange}
|
||||
onChange={handleOutroChange}
|
||||
/>
|
||||
|
||||
{/* ── 底部操作 ── */}
|
||||
<div className="iop-footer">
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
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,65 +2,12 @@
|
||||
* 混剪配置面板 — Drawer 形式
|
||||
* 左侧图层列表 + 右侧单图层配置 + 迷你预览区
|
||||
*/
|
||||
import React, { useCallback, useMemo } from "react"
|
||||
import React from "react"
|
||||
import { Drawer, Switch } from "antd"
|
||||
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 ──────────── */
|
||||
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"
|
||||
|
||||
interface PipConfigPanelProps {
|
||||
open: boolean
|
||||
@@ -70,13 +17,6 @@ interface PipConfigPanelProps {
|
||||
totalDuration: number
|
||||
}
|
||||
|
||||
/* ──────────── 辅助函数 ──────────── */
|
||||
|
||||
let layerIdCounter = 0
|
||||
const genLayerId = () => `pip_layer_${Date.now()}_${++layerIdCounter}`
|
||||
|
||||
/* ──────────── 组件 ──────────── */
|
||||
|
||||
const PipConfigPanel: React.FC<PipConfigPanelProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
@@ -84,106 +24,19 @@ const PipConfigPanel: React.FC<PipConfigPanelProps> = ({
|
||||
onChange,
|
||||
totalDuration,
|
||||
}) => {
|
||||
/** 当前选中图层 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],
|
||||
)
|
||||
const {
|
||||
selectedId,
|
||||
setSelectedId,
|
||||
selectedLayer,
|
||||
handleAddLayer,
|
||||
handleDeleteLayer,
|
||||
updateLayer,
|
||||
handleEnableToggle,
|
||||
handleReset,
|
||||
handleGridClick,
|
||||
handleWidthChange,
|
||||
handleHeightChange,
|
||||
} = usePipLayers({ config, onChange })
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
@@ -197,9 +50,7 @@ const PipConfigPanel: React.FC<PipConfigPanelProps> = ({
|
||||
{/* ═══ 顶部工具栏 ═══ */}
|
||||
<div className="pip-toolbar">
|
||||
<div className="pip-toolbar-left">
|
||||
<button className="pip-add-btn" onClick={handleAddLayer}>
|
||||
+ 添加图层
|
||||
</button>
|
||||
<span style={{ fontSize: 13, color: "#666" }}>共 {config.layers.length} 个图层</span>
|
||||
</div>
|
||||
<div className="pip-enable-switch">
|
||||
<span>启用</span>
|
||||
@@ -209,329 +60,22 @@ const PipConfigPanel: React.FC<PipConfigPanelProps> = ({
|
||||
|
||||
{/* ═══ 主体:图层列表 + 配置区 ═══ */}
|
||||
<div className="pip-body">
|
||||
{/* 左侧图层列表 */}
|
||||
<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>
|
||||
<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>
|
||||
|
||||
{/* ── 底部操作 ── */}
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
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
|
||||
@@ -0,0 +1,32 @@
|
||||
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,10 +2,13 @@
|
||||
* 贴纸配置面板
|
||||
* 贴纸素材库(emoji / 图片)+ 文字花字 + 位置大小调整
|
||||
*/
|
||||
import React, { useCallback, useState } from "react"
|
||||
import React from "react"
|
||||
import { Drawer, Switch } from "antd"
|
||||
import type { StickerConfig, StickerItem, StickerType, TextStickerPreset } from "../types"
|
||||
import { DEFAULT_STICKER_CONFIG, DEFAULT_STICKER_ITEM, TEXT_STICKER_PRESET_LABELS } from "../types"
|
||||
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"
|
||||
|
||||
interface StickerPanelProps {
|
||||
open: boolean
|
||||
@@ -15,59 +18,6 @@ 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,
|
||||
@@ -75,63 +25,17 @@ const StickerPanel: React.FC<StickerPanelProps> = ({
|
||||
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("")
|
||||
const {
|
||||
selectedId,
|
||||
setSelectedId,
|
||||
activeTab,
|
||||
setActiveTab,
|
||||
selectedSticker,
|
||||
updateItem,
|
||||
addSticker,
|
||||
removeSticker,
|
||||
handleReset,
|
||||
} = useStickerItems({ config, onChange, totalDuration })
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
@@ -152,355 +56,24 @@ const StickerPanel: React.FC<StickerPanelProps> = ({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 类型 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>
|
||||
{/* 素材库 + 类型Tab */}
|
||||
<StickerLibrary activeTab={activeTab} onTabChange={setActiveTab} onAddSticker={addSticker} />
|
||||
|
||||
{/* 已添加贴纸列表 */}
|
||||
{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>
|
||||
)}
|
||||
<StickerList
|
||||
items={config.items}
|
||||
selectedId={selectedId}
|
||||
onSelect={setSelectedId}
|
||||
onDelete={removeSticker}
|
||||
/>
|
||||
|
||||
{/* 选中贴纸的属性编辑 */}
|
||||
{selectedSticker && (
|
||||
<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>
|
||||
<StickerPropsEditor
|
||||
sticker={selectedSticker}
|
||||
totalDuration={totalDuration}
|
||||
onUpdate={updateItem}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 底部重置 */}
|
||||
|
||||
@@ -5,31 +5,14 @@
|
||||
import React from "react"
|
||||
import { Drawer, Slider, ColorPicker, Select } from "antd"
|
||||
import type { Color } from "antd/es/color-picker"
|
||||
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 ──────────── */
|
||||
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"
|
||||
|
||||
interface SubtitleStylePanelProps {
|
||||
open: boolean
|
||||
@@ -197,23 +180,7 @@ const SubtitleStylePanel: React.FC<SubtitleStylePanelProps> = ({
|
||||
</div>
|
||||
|
||||
{/* ── 预览 ── */}
|
||||
<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>
|
||||
<SubtitlePreview config={config} />
|
||||
</>
|
||||
)}
|
||||
</Drawer>
|
||||
|
||||
@@ -10,7 +10,24 @@
|
||||
*/
|
||||
import React, { useState, useRef, useCallback, useEffect, useLayoutEffect, useMemo } from "react"
|
||||
import type { ClipData, ClipType, TrimConfig } from "../types"
|
||||
import { TRANSITION_OPTIONS } from "@/api/template-editor"
|
||||
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"
|
||||
|
||||
interface TimelinePanelProps {
|
||||
clips: ClipData[]
|
||||
@@ -38,18 +55,6 @@ 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"
|
||||
|
||||
@@ -135,7 +140,7 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
|
||||
|
||||
/* ── "+" 卡片:类型+时长选择状态 ── */
|
||||
const [addType, setAddType] = useState<ClipType>(defaultAddType)
|
||||
const [addDuration, setAddDuration] = useState<number>(5)
|
||||
const [addDuration, setAddDuration] = useState<number>(DEFAULT_ADD_DURATION)
|
||||
|
||||
/* ── 模式切换时自动同步默认添加类型 ── */
|
||||
useEffect(() => {
|
||||
@@ -144,21 +149,17 @@ 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 - GAP - roughHeight
|
||||
let top = rect.top - TRACK_GAP - roughHeight
|
||||
if (top < 8) top = 8
|
||||
let right = vw - rect.right
|
||||
if (rect.right - PICKER_W < 8) {
|
||||
right = vw - PICKER_W - 8
|
||||
if (rect.right - ADD_PICKER_WIDTH < 8) {
|
||||
right = vw - ADD_PICKER_WIDTH - 8
|
||||
}
|
||||
setPickerPos({ top, right })
|
||||
}, [])
|
||||
@@ -181,9 +182,9 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
|
||||
const pickerH = pickerEl.offsetHeight
|
||||
const vh = window.innerHeight
|
||||
const vw = window.innerWidth
|
||||
let top = addRect.top - GAP - pickerH
|
||||
let top = addRect.top - TRACK_GAP - pickerH
|
||||
if (top < 8) {
|
||||
top = addRect.bottom + GAP
|
||||
top = addRect.bottom + TRACK_GAP
|
||||
if (top + pickerH > vh - 8) {
|
||||
top = vh - 8 - pickerH
|
||||
if (top < 8) top = 8
|
||||
@@ -192,7 +193,7 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
|
||||
let right = vw - addRect.right
|
||||
const pickerRect = pickerEl.getBoundingClientRect()
|
||||
if (pickerRect.left < 8) {
|
||||
right = vw - PICKER_W - 8
|
||||
right = vw - ADD_PICKER_WIDTH - 8
|
||||
}
|
||||
setPickerPos({ top, right })
|
||||
}, [showAddPicker])
|
||||
@@ -224,7 +225,7 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
|
||||
}, [contextMenu])
|
||||
|
||||
/* ── 缩放 & 时长 ── */
|
||||
const pps = pixelsPerSecond ?? 40
|
||||
const pps = pixelsPerSecond ?? DEFAULT_PIXELS_PER_SECOND
|
||||
const totalDuration = totalDurationProp ?? clips.reduce((s, c) => s + c.duration, 0)
|
||||
|
||||
/* ── 播放头拖拽全局 mousemove/mouseup ── */
|
||||
@@ -344,11 +345,9 @@ 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 / PX_PER_SECOND
|
||||
const dtSec = dx / pps
|
||||
const clip = clips.find((c) => c.id === trimDrag.clipId)
|
||||
if (!clip) return
|
||||
|
||||
@@ -359,10 +358,13 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
|
||||
|
||||
if (trimDrag.direction === "left") {
|
||||
// 左手柄:调整入点
|
||||
newStart = Math.max(0, Math.min(origTrim.start_time + dtSec, newEnd - 1))
|
||||
newStart = Math.max(0, Math.min(origTrim.start_time + dtSec, newEnd - MIN_TRIM_DURATION))
|
||||
} else {
|
||||
// 右手柄:调整出点
|
||||
newEnd = Math.max(origTrim.start_time + 1, Math.min(origTrim.end_time + dtSec, origDur))
|
||||
newEnd = Math.max(
|
||||
origTrim.start_time + MIN_TRIM_DURATION,
|
||||
Math.min(origTrim.end_time + dtSec, origDur),
|
||||
)
|
||||
}
|
||||
|
||||
const newDuration = Math.round((newEnd - newStart) * 10) / 10
|
||||
@@ -396,7 +398,7 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
|
||||
document.removeEventListener("mousemove", handleMouseMove)
|
||||
document.removeEventListener("mouseup", handleMouseUp)
|
||||
}
|
||||
}, [trimDrag, trimPreview, clips, onClipTrim, pixelsPerSecond])
|
||||
}, [trimDrag, trimPreview, clips, onClipTrim, pps])
|
||||
|
||||
/* ── 右键菜单 ── */
|
||||
const handleContextMenu = useCallback((e: React.MouseEvent, clipId: string) => {
|
||||
@@ -428,25 +430,6 @@ 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">
|
||||
{/* 时间线头部 */}
|
||||
@@ -460,7 +443,7 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
|
||||
<div className="ep-timeline-zoom">
|
||||
<button
|
||||
className="ep-zoom-btn"
|
||||
onClick={() => onZoomChange?.(Math.max(10, pps - 10))}
|
||||
onClick={() => onZoomChange?.(Math.max(MIN_PIXELS_PER_SECOND, pps - ZOOM_STEP))}
|
||||
title="缩小"
|
||||
>
|
||||
−
|
||||
@@ -468,15 +451,15 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
|
||||
<input
|
||||
type="range"
|
||||
className="ep-zoom-slider"
|
||||
min={10}
|
||||
max={120}
|
||||
min={MIN_PIXELS_PER_SECOND}
|
||||
max={MAX_PIXELS_PER_SECOND}
|
||||
step={5}
|
||||
value={pps}
|
||||
onChange={(e) => onZoomChange?.(Number(e.target.value))}
|
||||
/>
|
||||
<button
|
||||
className="ep-zoom-btn"
|
||||
onClick={() => onZoomChange?.(Math.min(120, pps + 10))}
|
||||
onClick={() => onZoomChange?.(Math.min(MAX_PIXELS_PER_SECOND, pps + ZOOM_STEP))}
|
||||
title="放大"
|
||||
>
|
||||
+
|
||||
@@ -506,15 +489,7 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
|
||||
|
||||
{/* 时间标尺 */}
|
||||
{currentMode !== "one_take" && (
|
||||
<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>
|
||||
<TimeRuler totalDuration={totalDuration} pps={pps} onClick={handleRulerClick} />
|
||||
)}
|
||||
|
||||
{/* 水平片段轨道 */}
|
||||
@@ -536,115 +511,30 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
|
||||
<div className="ep-track-empty-text">点击右侧 + 添加片段</div>
|
||||
</div>
|
||||
) : (
|
||||
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>
|
||||
)
|
||||
})
|
||||
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}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
|
||||
{/* ── 轨道末尾 "+" 添加卡片 ── */}
|
||||
@@ -663,109 +553,42 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
|
||||
|
||||
{/* 裁剪预览 tooltip */}
|
||||
{trimPreview && (
|
||||
<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>
|
||||
<TrimPreview
|
||||
startTime={trimPreview.startTime}
|
||||
endTime={trimPreview.endTime}
|
||||
duration={trimPreview.duration}
|
||||
x={trimPreview.x}
|
||||
y={trimPreview.y}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 右键菜单 */}
|
||||
{contextMenu && (
|
||||
<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>
|
||||
<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}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 类型+时长选择面板 */}
|
||||
{showAddPicker && (
|
||||
<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>
|
||||
<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>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
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
|
||||
Regular → Executable
+62
-220
@@ -2,23 +2,14 @@
|
||||
* TTS 配音面板 — Drawer 形式
|
||||
* 配音模式切换 + 文本输入 + 音色选择 + 语速/语调/音量 + 试听 + 字幕联动
|
||||
*/
|
||||
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"
|
||||
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"
|
||||
|
||||
/* ──────────── 音色卡片分类图标 ──────────── */
|
||||
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
|
||||
@@ -28,126 +19,21 @@ interface TtsPanelProps {
|
||||
}
|
||||
|
||||
const TtsPanel: React.FC<TtsPanelProps> = ({ open, onClose, config, onChange }) => {
|
||||
/* ── 音色列表 ── */
|
||||
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)
|
||||
const {
|
||||
voices,
|
||||
voicesLoading,
|
||||
previewLoading,
|
||||
handleModeChange,
|
||||
handleTextChange,
|
||||
handleVoiceSelect,
|
||||
handleSpeedChange,
|
||||
handlePitchChange,
|
||||
handleVolumeChange,
|
||||
handleSubtitleSyncToggle,
|
||||
handlePreview,
|
||||
handleReset,
|
||||
handleClose,
|
||||
} = useTtsPanel({ open, config, onChange, onClose })
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
@@ -162,11 +48,7 @@ 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">
|
||||
{[
|
||||
{ mode: "none" as TtsMode, icon: "🔇", label: "无配音" },
|
||||
{ mode: "upload" as TtsMode, icon: "📁", label: "上传配音" },
|
||||
{ mode: "tts" as TtsMode, icon: "🤖", label: "TTS 合成" },
|
||||
].map((m) => (
|
||||
{TTS_MODE_OPTIONS.map((m) => (
|
||||
<button
|
||||
key={m.mode}
|
||||
className={`tts-mode-btn${config.mode === m.mode ? " active" : ""}`}
|
||||
@@ -192,97 +74,57 @@ const TtsPanel: React.FC<TtsPanelProps> = ({ open, onClose, config, onChange })
|
||||
className="tts-text-input"
|
||||
placeholder="请输入需要合成的文本内容..."
|
||||
value={config.text}
|
||||
onChange={handleTextChange}
|
||||
onChange={(e) => handleTextChange(e.target.value)}
|
||||
maxLength={5000}
|
||||
rows={5}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 音色选择 */}
|
||||
<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>
|
||||
<VoiceSelector
|
||||
voices={voices}
|
||||
voicesLoading={voicesLoading}
|
||||
selectedVoiceId={config.voice_id}
|
||||
onVoiceSelect={handleVoiceSelect}
|
||||
/>
|
||||
|
||||
{/* 语速滑块 */}
|
||||
<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.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.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.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.volume}%</span>
|
||||
</div>
|
||||
<Slider
|
||||
min={0}
|
||||
max={100}
|
||||
step={1}
|
||||
value={config.volume}
|
||||
onChange={handleVolumeChange}
|
||||
tooltip={{ formatter: (v) => `${v}%` }}
|
||||
/>
|
||||
</div>
|
||||
<TtsSlider
|
||||
label="音量"
|
||||
value={config.volume}
|
||||
min={0}
|
||||
max={100}
|
||||
step={1}
|
||||
unit="%"
|
||||
onChange={handleVolumeChange}
|
||||
tooltipFormatter={(v) => `${v}%`}
|
||||
/>
|
||||
|
||||
{/* 试听按钮 */}
|
||||
<div className="tts-preview-section">
|
||||
|
||||
@@ -3,38 +3,16 @@
|
||||
* 三个 Tab:图片水印 / 文字水印 / 滚动水印
|
||||
* 通用设置:位置、不透明度
|
||||
*/
|
||||
import React, { useCallback, useState } from "react"
|
||||
import React from "react"
|
||||
import { Drawer } from "antd"
|
||||
import type { WatermarkConfig, WatermarkType, WatermarkPosition, ScrollDirection } from "../types"
|
||||
import { DEFAULT_WATERMARK } from "../types"
|
||||
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"
|
||||
|
||||
/* ──────────── 常量 ──────────── */
|
||||
|
||||
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
|
||||
@@ -43,100 +21,21 @@ interface WatermarkPanelProps {
|
||||
}
|
||||
|
||||
const WatermarkPanel: React.FC<WatermarkPanelProps> = ({ open, onClose, 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
|
||||
const {
|
||||
localImageUrl,
|
||||
handleTypeChange,
|
||||
handlePositionChange,
|
||||
handleOpacityChange,
|
||||
handleImageUrlChange,
|
||||
handleImageWidthChange,
|
||||
handleImageHeightChange,
|
||||
handleTextChange,
|
||||
handleFontSizeChange,
|
||||
handleColorChange,
|
||||
handleScrollDirectionChange,
|
||||
handleScrollSpeedChange,
|
||||
handleReset,
|
||||
} = useWatermarkConfig({ config, onChange })
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
@@ -148,18 +47,7 @@ const WatermarkPanel: React.FC<WatermarkPanelProps> = ({ open, onClose, config,
|
||||
className="watermark-panel-drawer"
|
||||
>
|
||||
{/* ── Tab 切换 ── */}
|
||||
<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>
|
||||
<WatermarkTypeTabs activeTab={config.type} onTypeChange={handleTypeChange} />
|
||||
|
||||
{/* ── 无水印提示 ── */}
|
||||
{config.type === "none" && (
|
||||
@@ -172,210 +60,53 @@ const WatermarkPanel: React.FC<WatermarkPanelProps> = ({ open, onClose, config,
|
||||
|
||||
{/* ── 图片水印配置 ── */}
|
||||
{config.type === "image" && (
|
||||
<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>
|
||||
<ImageWatermarkSection
|
||||
imageUrl={config.image_url || ""}
|
||||
localImageUrl={localImageUrl}
|
||||
width={config.width}
|
||||
height={config.height}
|
||||
onImageUrlChange={handleImageUrlChange}
|
||||
onWidthChange={handleImageWidthChange}
|
||||
onHeightChange={handleImageHeightChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ── 文字水印配置 ── */}
|
||||
{config.type === "text" && (
|
||||
<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>
|
||||
<TextWatermarkSection
|
||||
text={config.text || ""}
|
||||
fontSize={config.font_size ?? 24}
|
||||
color={config.color ?? "#ffffff"}
|
||||
onTextChange={handleTextChange}
|
||||
onFontSizeChange={handleFontSizeChange}
|
||||
onColorChange={handleColorChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ── 滚动水印配置 ── */}
|
||||
{config.type === "scroll" && (
|
||||
<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>
|
||||
<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}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ── 通用设置(非 none 时显示) ── */}
|
||||
{config.type !== "none" && (
|
||||
<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>
|
||||
<WatermarkCommonSection
|
||||
position={config.position}
|
||||
opacity={config.opacity}
|
||||
onPositionChange={handlePositionChange}
|
||||
onOpacityChange={handleOpacityChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ── 底部操作 ── */}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* 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
|
||||
@@ -0,0 +1,266 @@
|
||||
/**
|
||||
* 片段详情区块
|
||||
*/
|
||||
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
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* 编辑统计区块
|
||||
*/
|
||||
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
|
||||
+113
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* 字幕设置区块
|
||||
*/
|
||||
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
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* 滤镜手动调节组件
|
||||
*/
|
||||
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
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* 滤镜预设选择组件
|
||||
*/
|
||||
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
|
||||
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* 片头/片尾通用区块组件(片头片尾结构对称,复用同一个组件)
|
||||
*/
|
||||
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
|
||||
@@ -0,0 +1,281 @@
|
||||
/**
|
||||
* 混剪单图层配置区
|
||||
*/
|
||||
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
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* 混剪图层列表
|
||||
*/
|
||||
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
|
||||
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* 贴纸素材库(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
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* 已添加贴纸列表
|
||||
*/
|
||||
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
|
||||
@@ -0,0 +1,200 @@
|
||||
/**
|
||||
* 选中贴纸的属性编辑器
|
||||
*/
|
||||
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
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* 字幕预览组件
|
||||
*/
|
||||
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
|
||||
@@ -0,0 +1,80 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* 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
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* 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
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* 图片水印配置组件
|
||||
*/
|
||||
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
|
||||
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* 滚动水印配置组件
|
||||
*/
|
||||
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
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* 文字水印配置组件
|
||||
*/
|
||||
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
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* 水印通用设置组件(位置 + 透明度)
|
||||
*/
|
||||
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
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* 水印类型 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
|
||||
@@ -0,0 +1,12 @@
|
||||
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 = ["全部", "种草", "知识", "日常", "推荐"]
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* 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: "混剪",
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* 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
|
||||
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* 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: "🖼️" },
|
||||
]
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* 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)",
|
||||
]
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* 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" },
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* 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" },
|
||||
]
|
||||
@@ -0,0 +1,56 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* 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 合成" },
|
||||
]
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* 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: "对角滚动" },
|
||||
]
|
||||
@@ -0,0 +1,238 @@
|
||||
import { useState, useCallback, useMemo } from "react"
|
||||
import { Modal, message } from "antd"
|
||||
import type {
|
||||
ClipData,
|
||||
ClipType,
|
||||
TransitionConfig,
|
||||
SpeedConfig,
|
||||
TtsConfig,
|
||||
TrimConfig,
|
||||
} from "../types"
|
||||
import type { AssetItem } from "@/api/assets"
|
||||
|
||||
interface UseClipOperationsParams {
|
||||
clips: ClipData[]
|
||||
setClips: (updater: (prev: ClipData[]) => ClipData[]) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 片段操作 Hook
|
||||
* 片段增删改查、排序、裁剪、分割、转场、调速、TTS、配音选择
|
||||
*/
|
||||
export const useClipOperations = ({ clips, setClips }: UseClipOperationsParams) => {
|
||||
const [selectedClipId, setSelectedClipId] = useState<string | null>(null)
|
||||
|
||||
const selectedClip = useMemo(
|
||||
() => clips.find((c) => c.id === selectedClipId) || null,
|
||||
[clips, selectedClipId],
|
||||
)
|
||||
|
||||
/* ── 选中 / 重排 / 删除 ── */
|
||||
|
||||
const handleClipSelect = useCallback((clipId: string) => {
|
||||
setSelectedClipId(clipId)
|
||||
}, [])
|
||||
|
||||
const handleClipReorder = useCallback(
|
||||
(fromIdx: number, toIdx: number) => {
|
||||
setClips((prev) => {
|
||||
const updated = [...prev]
|
||||
const [moved] = updated.splice(fromIdx, 1)
|
||||
updated.splice(toIdx, 0, moved)
|
||||
return updated.map((c, i) => ({ ...c, order: i }))
|
||||
})
|
||||
},
|
||||
[setClips],
|
||||
)
|
||||
|
||||
const handleClipRemove = useCallback(
|
||||
(clipId: string) => {
|
||||
Modal.confirm({
|
||||
title: "删除片段",
|
||||
content: "确定要删除这个片段吗?此操作可通过撤销恢复。",
|
||||
okText: "删除",
|
||||
okType: "danger",
|
||||
cancelText: "取消",
|
||||
onOk: () => {
|
||||
setClips((prev) => prev.filter((c) => c.id !== clipId))
|
||||
if (selectedClipId === clipId) setSelectedClipId(null)
|
||||
},
|
||||
})
|
||||
},
|
||||
[setClips, selectedClipId],
|
||||
)
|
||||
|
||||
const handleClipUpdate = useCallback(
|
||||
(clipId: string, data: Partial<ClipData>) => {
|
||||
setClips((prev) => prev.map((c) => (c.id === clipId ? { ...c, ...data } : c)))
|
||||
},
|
||||
[setClips],
|
||||
)
|
||||
|
||||
/**
|
||||
* 添加片段(不绑定任何素材)
|
||||
* 片段 = 时间规划 + 类型标记
|
||||
*/
|
||||
const handleAddClip = useCallback(
|
||||
(type: ClipType, duration: number) => {
|
||||
const newClip: ClipData = {
|
||||
id: `clip-${Date.now()}`,
|
||||
type,
|
||||
duration,
|
||||
startOffset: 0,
|
||||
order: clips.length,
|
||||
}
|
||||
setClips((prev) => [...prev, newClip])
|
||||
},
|
||||
[clips.length, setClips],
|
||||
)
|
||||
|
||||
/* ── 裁剪 / 分割 / 重置 ── */
|
||||
|
||||
const handleClipTrim = useCallback(
|
||||
(clipId: string, trimConfig: TrimConfig, newDuration: number) => {
|
||||
setClips((prev) =>
|
||||
prev.map((c) =>
|
||||
c.id === clipId ? { ...c, trim_config: trimConfig, duration: newDuration } : c,
|
||||
),
|
||||
)
|
||||
},
|
||||
[setClips],
|
||||
)
|
||||
|
||||
const handleClipSplit = useCallback(
|
||||
(clipId: string, splitRatio: number) => {
|
||||
setClips((prev) => {
|
||||
const idx = prev.findIndex((c) => c.id === clipId)
|
||||
if (idx === -1) return prev
|
||||
const clip = prev[idx]
|
||||
const splitPoint = Math.round(clip.duration * splitRatio * 10) / 10
|
||||
if (splitPoint < 0.5 || splitPoint >= clip.duration - 0.5) return prev
|
||||
|
||||
// 前半段
|
||||
const firstHalf: ClipData = {
|
||||
...clip,
|
||||
duration: splitPoint,
|
||||
trim_config: clip.trim_config
|
||||
? {
|
||||
...clip.trim_config,
|
||||
end_time: clip.trim_config.start_time + splitPoint,
|
||||
}
|
||||
: undefined,
|
||||
}
|
||||
|
||||
// 后半段
|
||||
const secondHalf: ClipData = {
|
||||
...clip,
|
||||
id: `clip-${Date.now()}`,
|
||||
duration: clip.duration - splitPoint,
|
||||
startOffset: clip.startOffset + splitPoint,
|
||||
trim_config: clip.trim_config
|
||||
? {
|
||||
...clip.trim_config,
|
||||
start_time: clip.trim_config.start_time + splitPoint,
|
||||
}
|
||||
: undefined,
|
||||
order: (clip.order ?? idx) + 1,
|
||||
}
|
||||
|
||||
const updated = [...prev]
|
||||
updated[idx] = firstHalf
|
||||
updated.splice(idx + 1, 0, secondHalf)
|
||||
return updated.map((c, i) => ({ ...c, order: i }))
|
||||
})
|
||||
},
|
||||
[setClips],
|
||||
)
|
||||
|
||||
const handleClipResetTrim = useCallback(
|
||||
(clipId: string) => {
|
||||
setClips((prev) =>
|
||||
prev.map((c) => {
|
||||
if (c.id !== clipId || !c.trim_config) return c
|
||||
const originalDuration = c.trim_config.original_duration ?? c.duration
|
||||
return {
|
||||
...c,
|
||||
duration: originalDuration,
|
||||
trim_config: undefined,
|
||||
}
|
||||
}),
|
||||
)
|
||||
},
|
||||
[setClips],
|
||||
)
|
||||
|
||||
/* ── 转场 / 调速 / TTS ── */
|
||||
|
||||
const handleTransitionChange = useCallback(
|
||||
(targetClipId: string | null, config: TransitionConfig) => {
|
||||
if (targetClipId) {
|
||||
handleClipUpdate(targetClipId, { transition: config })
|
||||
}
|
||||
// 同时更新全局默认转场(供新片段使用)—— 暂未实现全局默认
|
||||
},
|
||||
[handleClipUpdate],
|
||||
)
|
||||
|
||||
const handleSpeedChange = useCallback(
|
||||
(targetClipId: string | null, config: SpeedConfig) => {
|
||||
if (targetClipId) {
|
||||
handleClipUpdate(targetClipId, { speed: config })
|
||||
}
|
||||
},
|
||||
[handleClipUpdate],
|
||||
)
|
||||
|
||||
const handleApplySpeedAll = useCallback(
|
||||
(config: SpeedConfig) => {
|
||||
setClips((prev) => prev.map((c) => ({ ...c, speed: { ...config } })))
|
||||
message.success("已应用到所有片段")
|
||||
},
|
||||
[setClips],
|
||||
)
|
||||
|
||||
const handleTtsChange = useCallback(
|
||||
(targetClipId: string | null, ttsConfig: TtsConfig) => {
|
||||
if (!targetClipId) return
|
||||
handleClipUpdate(targetClipId, { tts_config: ttsConfig })
|
||||
},
|
||||
[handleClipUpdate],
|
||||
)
|
||||
|
||||
/** 为片段选择配音素材 */
|
||||
const handleClipVoiceSelect = useCallback(
|
||||
(clipId: string, asset: AssetItem | null) => {
|
||||
setClips((prev) =>
|
||||
prev.map((c) =>
|
||||
c.id === clipId
|
||||
? {
|
||||
...c,
|
||||
voice_asset_id: asset?.id ?? undefined,
|
||||
voice_file_url: asset?.file_url ?? undefined,
|
||||
}
|
||||
: c,
|
||||
),
|
||||
)
|
||||
},
|
||||
[setClips],
|
||||
)
|
||||
|
||||
return {
|
||||
selectedClipId,
|
||||
setSelectedClipId,
|
||||
selectedClip,
|
||||
handleClipSelect,
|
||||
handleClipReorder,
|
||||
handleClipRemove,
|
||||
handleClipUpdate,
|
||||
handleAddClip,
|
||||
handleClipTrim,
|
||||
handleClipSplit,
|
||||
handleClipResetTrim,
|
||||
handleTransitionChange,
|
||||
handleSpeedChange,
|
||||
handleApplySpeedAll,
|
||||
handleTtsChange,
|
||||
handleClipVoiceSelect,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { useState, useCallback } from "react"
|
||||
|
||||
/**
|
||||
* 编辑器 Drawer 开关管理
|
||||
* 集中管理 11 个抽屉的开关状态 + 3 个目标片段 ID + 快捷打开方法
|
||||
*/
|
||||
export const useEditorDrawers = () => {
|
||||
/* ── 抽屉开关 ── */
|
||||
const [bgmDrawerOpen, setBgmDrawerOpen] = useState(false)
|
||||
const [subtitleDrawerOpen, setSubtitleDrawerOpen] = useState(false)
|
||||
const [transitionDrawerOpen, setTransitionDrawerOpen] = useState(false)
|
||||
const [speedDrawerOpen, setSpeedDrawerOpen] = useState(false)
|
||||
const [ttsDrawerOpen, setTtsDrawerOpen] = useState(false)
|
||||
const [watermarkDrawerOpen, setWatermarkDrawerOpen] = useState(false)
|
||||
const [introOutroDrawerOpen, setIntroOutroDrawerOpen] = useState(false)
|
||||
const [pipDrawerOpen, setPipDrawerOpen] = useState(false)
|
||||
const [filterDrawerOpen, setFilterDrawerOpen] = useState(false)
|
||||
const [chromaKeyDrawerOpen, setChromaKeyDrawerOpen] = useState(false)
|
||||
const [stickerDrawerOpen, setStickerDrawerOpen] = useState(false)
|
||||
|
||||
/* ── 目标片段 ID ── */
|
||||
/** 当前正在编辑转场的片段 ID(null = 全局默认转场) */
|
||||
const [transitionTargetClipId, setTransitionTargetClipId] = useState<string | null>(null)
|
||||
/** 当前正在调速的片段 ID */
|
||||
const [speedTargetClipId, setSpeedTargetClipId] = useState<string | null>(null)
|
||||
/** 当前正在配置 TTS 的片段 ID */
|
||||
const [ttsTargetClipId, setTtsTargetClipId] = useState<string | null>(null)
|
||||
|
||||
/* ── 快捷打开 ── */
|
||||
const openTransitionDrawer = useCallback((clipId?: string) => {
|
||||
setTransitionTargetClipId(clipId ?? null)
|
||||
setTransitionDrawerOpen(true)
|
||||
}, [])
|
||||
|
||||
const openSpeedDrawer = useCallback((clipId: string) => {
|
||||
setSpeedTargetClipId(clipId)
|
||||
setSpeedDrawerOpen(true)
|
||||
}, [])
|
||||
|
||||
const openTtsDrawer = useCallback((clipId: string) => {
|
||||
setTtsTargetClipId(clipId)
|
||||
setTtsDrawerOpen(true)
|
||||
}, [])
|
||||
|
||||
return {
|
||||
// 开关 state
|
||||
bgmDrawerOpen,
|
||||
setBgmDrawerOpen,
|
||||
subtitleDrawerOpen,
|
||||
setSubtitleDrawerOpen,
|
||||
transitionDrawerOpen,
|
||||
setTransitionDrawerOpen,
|
||||
speedDrawerOpen,
|
||||
setSpeedDrawerOpen,
|
||||
ttsDrawerOpen,
|
||||
setTtsDrawerOpen,
|
||||
watermarkDrawerOpen,
|
||||
setWatermarkDrawerOpen,
|
||||
introOutroDrawerOpen,
|
||||
setIntroOutroDrawerOpen,
|
||||
pipDrawerOpen,
|
||||
setPipDrawerOpen,
|
||||
filterDrawerOpen,
|
||||
setFilterDrawerOpen,
|
||||
chromaKeyDrawerOpen,
|
||||
setChromaKeyDrawerOpen,
|
||||
stickerDrawerOpen,
|
||||
setStickerDrawerOpen,
|
||||
// 目标 ID
|
||||
transitionTargetClipId,
|
||||
speedTargetClipId,
|
||||
ttsTargetClipId,
|
||||
// 快捷方法
|
||||
openTransitionDrawer,
|
||||
openSpeedDrawer,
|
||||
openTtsDrawer,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* 混剪图层管理 Hook
|
||||
*/
|
||||
import { useState, useCallback, useMemo } from "react"
|
||||
import type { PipConfig, PipLayer, PipGridPosition } from "@/pages/editing-planner/types"
|
||||
import { DEFAULT_PIP_LAYER, DEFAULT_PIP_CONFIG } from "@/pages/editing-planner/types"
|
||||
import { GRID_POSITION_MAP } from "@/pages/editing-planner/constants/pipConfig"
|
||||
|
||||
let layerIdCounter = 0
|
||||
const genLayerId = () => `pip_layer_${Date.now()}_${++layerIdCounter}`
|
||||
|
||||
interface UsePipLayersOptions {
|
||||
config: PipConfig
|
||||
onChange: (config: PipConfig) => void
|
||||
}
|
||||
|
||||
export const usePipLayers = ({ config, onChange }: UsePipLayersOptions) => {
|
||||
const [selectedId, setSelectedId] = 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) {
|
||||
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 {
|
||||
selectedId,
|
||||
setSelectedId,
|
||||
selectedLayer,
|
||||
handleAddLayer,
|
||||
handleDeleteLayer,
|
||||
updateLayer,
|
||||
handleEnableToggle,
|
||||
handleReset,
|
||||
handleGridClick,
|
||||
handleWidthChange,
|
||||
handleHeightChange,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { useState, useCallback, useEffect, useRef } from "react"
|
||||
|
||||
/**
|
||||
* 播放控制 Hook
|
||||
* 播放/暂停、rAF 帧推进、时间线缩放、seek
|
||||
*/
|
||||
export const usePlaybackControl = (totalDuration: number) => {
|
||||
const [isPlaying, setIsPlaying] = useState(false)
|
||||
const [currentTime, setCurrentTime] = useState(0)
|
||||
const [pixelsPerSecond, setPixelsPerSecond] = useState(40)
|
||||
const prevFrameTimeRef = useRef<number | null>(null)
|
||||
|
||||
/** 播放头跳转 */
|
||||
const handleSeek = useCallback((time: number) => {
|
||||
setCurrentTime(Math.max(0, time))
|
||||
}, [])
|
||||
|
||||
/** 轨道缩放 */
|
||||
const handleZoomChange = useCallback((pps: number) => {
|
||||
setPixelsPerSecond(pps)
|
||||
}, [])
|
||||
|
||||
/** rAF 帧推进 — 播放时平滑更新播放头位置 */
|
||||
useEffect(() => {
|
||||
if (!isPlaying) {
|
||||
prevFrameTimeRef.current = null
|
||||
return
|
||||
}
|
||||
let rafId: number
|
||||
const tick = (timestamp: number) => {
|
||||
if (prevFrameTimeRef.current !== null) {
|
||||
const delta = (timestamp - prevFrameTimeRef.current) / 1000
|
||||
setCurrentTime((prev) => {
|
||||
const next = prev + delta
|
||||
return next >= totalDuration ? totalDuration : next
|
||||
})
|
||||
}
|
||||
prevFrameTimeRef.current = timestamp
|
||||
rafId = requestAnimationFrame(tick)
|
||||
}
|
||||
rafId = requestAnimationFrame(tick)
|
||||
return () => {
|
||||
cancelAnimationFrame(rafId)
|
||||
prevFrameTimeRef.current = null
|
||||
}
|
||||
}, [isPlaying, totalDuration])
|
||||
|
||||
return {
|
||||
isPlaying,
|
||||
setIsPlaying,
|
||||
currentTime,
|
||||
pixelsPerSecond,
|
||||
handleSeek,
|
||||
handleZoomChange,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* 贴纸项管理 Hook
|
||||
*/
|
||||
import { useState, useCallback } from "react"
|
||||
import type { StickerConfig, StickerItem, StickerType } from "@/pages/editing-planner/types"
|
||||
import { DEFAULT_STICKER_ITEM, DEFAULT_STICKER_CONFIG } from "@/pages/editing-planner/types"
|
||||
|
||||
const genId = () => `sticker_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
|
||||
|
||||
interface UseStickerItemsOptions {
|
||||
config: StickerConfig
|
||||
onChange: (config: StickerConfig) => void
|
||||
totalDuration: number
|
||||
}
|
||||
|
||||
export const useStickerItems = ({ config, onChange, totalDuration }: UseStickerItemsOptions) => {
|
||||
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])
|
||||
|
||||
return {
|
||||
selectedId,
|
||||
setSelectedId,
|
||||
activeTab,
|
||||
setActiveTab,
|
||||
selectedSticker,
|
||||
updateItem,
|
||||
addSticker,
|
||||
removeSticker,
|
||||
handleReset,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,466 @@
|
||||
import { useState, useCallback, useEffect, type Dispatch, type SetStateAction } from "react"
|
||||
import { FILTER_CATEGORIES } from "../constants"
|
||||
import { message } from "antd"
|
||||
import type {
|
||||
EditingTemplate,
|
||||
TemplateCategory,
|
||||
TemplateMode,
|
||||
SaveTemplatePayload,
|
||||
} from "@/api/editing-planner"
|
||||
import {
|
||||
getEditingTemplates,
|
||||
getEditingTemplate,
|
||||
createEditingTemplate,
|
||||
updateEditingTemplate,
|
||||
getTemplateCategories,
|
||||
} from "@/api/editing-planner"
|
||||
import type { MediaAsset, TitleConfig, TransitionEffect } from "@/api/template-editor"
|
||||
import { getMediaAssets, getEditPlan, getEditPlanClips } from "@/api/template-editor"
|
||||
import type {
|
||||
ClipData,
|
||||
ClipType,
|
||||
TtsConfig,
|
||||
TtsMode,
|
||||
TrimConfig,
|
||||
WatermarkConfig,
|
||||
IntroOutroConfig,
|
||||
PipConfig,
|
||||
FilterConfig,
|
||||
ChromaKeyConfig,
|
||||
StickerConfig,
|
||||
CoverConfig,
|
||||
} from "../types"
|
||||
import type { SubtitleStyleConfig } from "../types/subtitle"
|
||||
import type { BgmMixConfig } from "@/api/bgm"
|
||||
|
||||
interface UseTemplateManagementParams {
|
||||
urlTemplateId: string
|
||||
urlPlanId: string
|
||||
resetClips: (clips: ClipData[]) => void
|
||||
setClips: (updater: (prev: ClipData[]) => ClipData[]) => void
|
||||
setSelectedClipId: (id: string | null) => void
|
||||
setMediaAssets: (assets: MediaAsset[]) => void
|
||||
setTitleConfig: Dispatch<SetStateAction<TitleConfig>>
|
||||
setSubtitleSettings: Dispatch<SetStateAction<SubtitleStyleConfig>>
|
||||
setBgmSettings: Dispatch<SetStateAction<BgmMixConfig>>
|
||||
setCoverConfig: Dispatch<SetStateAction<CoverConfig>>
|
||||
// 保存时需要的配置
|
||||
clips: ClipData[]
|
||||
totalDuration: number
|
||||
titleConfig: TitleConfig
|
||||
subtitleSettings: SubtitleStyleConfig
|
||||
bgmSettings: BgmMixConfig
|
||||
watermarkSettings: WatermarkConfig
|
||||
introOutroSettings: IntroOutroConfig
|
||||
pipSettings: PipConfig
|
||||
filterSettings: FilterConfig
|
||||
chromaKeySettings: ChromaKeyConfig
|
||||
stickerSettings: StickerConfig
|
||||
coverConfig: CoverConfig
|
||||
}
|
||||
|
||||
/**
|
||||
* 模板管理 Hook
|
||||
* 模板列表/分类/加载/保存/模式切换/筛选搜索 + 3 个 useEffect
|
||||
*/
|
||||
export const useTemplateManagement = (params: UseTemplateManagementParams) => {
|
||||
const {
|
||||
urlTemplateId,
|
||||
urlPlanId,
|
||||
resetClips,
|
||||
setClips,
|
||||
setSelectedClipId,
|
||||
setMediaAssets,
|
||||
setTitleConfig,
|
||||
setSubtitleSettings,
|
||||
setBgmSettings,
|
||||
setCoverConfig,
|
||||
clips,
|
||||
totalDuration,
|
||||
titleConfig,
|
||||
subtitleSettings,
|
||||
bgmSettings,
|
||||
watermarkSettings,
|
||||
introOutroSettings,
|
||||
pipSettings,
|
||||
filterSettings,
|
||||
chromaKeySettings,
|
||||
stickerSettings,
|
||||
coverConfig,
|
||||
} = params
|
||||
|
||||
/* ── 模板列表 ── */
|
||||
const [templates, setTemplates] = useState<EditingTemplate[]>([])
|
||||
const [categories, setCategories] = useState<TemplateCategory[]>([])
|
||||
const [loadingTemplates, setLoadingTemplates] = useState(false)
|
||||
const [loadedTemplateId, setLoadedTemplateId] = useState<string | null>(urlTemplateId || null)
|
||||
const [currentMode, setCurrentMode] = useState<TemplateMode>("pip")
|
||||
|
||||
/* ── 左栏筛选 ── */
|
||||
const [currentFilter, setCurrentFilter] = useState("全部")
|
||||
const [searchQuery, setSearchQuery] = useState("")
|
||||
|
||||
/* ── 保存弹窗 ── */
|
||||
const [saveModalOpen, setSaveModalOpen] = useState(false)
|
||||
const [draftName, setDraftName] = useState("")
|
||||
const [draftCategory, setDraftCategory] = useState("")
|
||||
const [draftTags, setDraftTags] = useState("")
|
||||
const [saveLoading, setSaveLoading] = useState(false)
|
||||
|
||||
/* ── 计划 ID(从 URL 传入,不变) ── */
|
||||
const [loadedPlanId] = useState<string | null>(urlPlanId || null)
|
||||
|
||||
/* ── 计算 ── */
|
||||
const filteredTemplates = templates.filter((t) => {
|
||||
if (currentFilter !== "全部" && t.category !== currentFilter) return false
|
||||
if (searchQuery && !t.name.toLowerCase().includes(searchQuery.toLowerCase())) return false
|
||||
return true
|
||||
})
|
||||
|
||||
const currentTemplate = templates.find((t) => t.id === loadedTemplateId) || null
|
||||
|
||||
/* ──────────── 加载 ──────────── */
|
||||
|
||||
/**
|
||||
* 并行加载模板列表、分类、素材库
|
||||
* 首次挂载时调用,三个接口无依赖关系,用 Promise.all 并发
|
||||
*/
|
||||
const loadTemplates = useCallback(async () => {
|
||||
setLoadingTemplates(true)
|
||||
try {
|
||||
const [tpls, cats, assets] = await Promise.all([
|
||||
getEditingTemplates(),
|
||||
getTemplateCategories(),
|
||||
getMediaAssets(),
|
||||
])
|
||||
setTemplates(tpls)
|
||||
setCategories(cats)
|
||||
setMediaAssets(assets)
|
||||
} catch {
|
||||
message.error("加载模板失败")
|
||||
} finally {
|
||||
setLoadingTemplates(false)
|
||||
}
|
||||
}, [setMediaAssets])
|
||||
|
||||
useEffect(() => {
|
||||
loadTemplates()
|
||||
}, [loadTemplates])
|
||||
|
||||
/**
|
||||
* 加载模板详情并初始化片段列表
|
||||
* 将后端 segments 映射为前端 ClipData,取 duration_min/max 均值作为默认时长
|
||||
* 同时还原标题/字幕/BGM 配置
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!loadedTemplateId) return
|
||||
getEditingTemplate(loadedTemplateId)
|
||||
.then((tpl) => {
|
||||
if (!tpl) return
|
||||
setCurrentMode(tpl.mode)
|
||||
const mapped: ClipData[] = tpl.segments.map((seg, idx) => ({
|
||||
id: seg.id || `seg-${idx}`,
|
||||
template_segment_id: seg.id || `seg-${idx}`,
|
||||
type: (seg.material_type === "voiceover" ? "voice" : "pip") as ClipType,
|
||||
duration: (seg.duration_min + seg.duration_max) / 2,
|
||||
startOffset: 0,
|
||||
script_text: "",
|
||||
order: seg.segment_order,
|
||||
}))
|
||||
resetClips(mapped)
|
||||
|
||||
setTitleConfig({
|
||||
ai_auto_select: tpl.title_config.ai_auto_select,
|
||||
content: tpl.title_config.content,
|
||||
position: tpl.title_config.position,
|
||||
font_preset: tpl.title_config.font_preset,
|
||||
font_size: tpl.title_config.font_size,
|
||||
font_color: tpl.title_config.font_color || "#ffffff",
|
||||
})
|
||||
setSubtitleSettings((prev) => ({
|
||||
...prev,
|
||||
enabled: tpl.subtitle_config.enabled,
|
||||
position: (tpl.subtitle_config.position || "bottom") as SubtitleStyleConfig["position"],
|
||||
font: tpl.subtitle_config.font,
|
||||
fontSize: tpl.subtitle_config.size,
|
||||
fontColor: tpl.subtitle_config.color || "#ffffff",
|
||||
animation: tpl.subtitle_config.animation,
|
||||
}))
|
||||
setBgmSettings((prev) => ({
|
||||
...prev,
|
||||
enabled: tpl.bgm_config.enabled,
|
||||
music_id: tpl.bgm_config.music_id || "",
|
||||
}))
|
||||
setDraftName(tpl.name)
|
||||
setDraftCategory(tpl.category)
|
||||
setDraftTags(tpl.tags.join(", "))
|
||||
})
|
||||
.catch(() => message.error("加载模板详情失败"))
|
||||
}, [loadedTemplateId, resetClips, setTitleConfig, setSubtitleSettings, setBgmSettings])
|
||||
|
||||
/**
|
||||
* 加载已有模板草稿数据到编辑器
|
||||
* 从列表页"编辑"按钮进入时,URL 带 planId,需要还原计划配置
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!loadedPlanId) return
|
||||
|
||||
// 并行加载计划基本信息 + 片段列表
|
||||
Promise.all([
|
||||
getEditPlan(loadedPlanId),
|
||||
getEditPlanClips(loadedPlanId, { limit: 500 }).catch(() => ({
|
||||
items: [],
|
||||
total: 0,
|
||||
})),
|
||||
])
|
||||
.then(([plan, clipsRes]) => {
|
||||
// 设置关联的模板(触发模板加载 effect)
|
||||
setLoadedTemplateId(plan.template_id)
|
||||
|
||||
// 还原基本信息
|
||||
setDraftName(plan.name)
|
||||
|
||||
// 还原 config 中的编辑器状态
|
||||
const cfg = plan.config
|
||||
if (cfg.title_config) {
|
||||
setTitleConfig({
|
||||
ai_auto_select: cfg.title_config!.ai_auto_select,
|
||||
content: cfg.title_config!.content,
|
||||
position: cfg.title_config!.position,
|
||||
font_preset: cfg.title_config!.font_preset,
|
||||
font_size: cfg.title_config!.font_size,
|
||||
font_color: cfg.title_config!.font_color || "#ffffff",
|
||||
})
|
||||
}
|
||||
if (cfg.subtitle_config) {
|
||||
setSubtitleSettings((prev) => ({
|
||||
...prev,
|
||||
enabled: cfg.subtitle_config!.enabled,
|
||||
position: (cfg.subtitle_config!.position ||
|
||||
"bottom") as SubtitleStyleConfig["position"],
|
||||
font: cfg.subtitle_config!.font,
|
||||
fontSize: cfg.subtitle_config!.size,
|
||||
fontColor: cfg.subtitle_config!.color || "#ffffff",
|
||||
animation: cfg.subtitle_config!.animation,
|
||||
}))
|
||||
}
|
||||
if (cfg.bgm_config) {
|
||||
setBgmSettings((prev) => ({
|
||||
...prev,
|
||||
enabled: cfg.bgm_config!.enabled,
|
||||
music_id: cfg.bgm_config!.music_id || "",
|
||||
}))
|
||||
}
|
||||
// 还原封面配置
|
||||
if (cfg.cover_config) {
|
||||
setCoverConfig((prev) => ({
|
||||
...prev,
|
||||
enabled: cfg.cover_config!.enabled ?? prev.enabled,
|
||||
mode: (cfg.cover_config!.mode as CoverConfig["mode"]) || prev.mode,
|
||||
frame_time: cfg.cover_config!.frame_time ?? prev.frame_time,
|
||||
upload_url: cfg.cover_config!.upload_url || prev.upload_url,
|
||||
thumbnail_url: cfg.cover_config!.thumbnail_url || prev.thumbnail_url,
|
||||
ai_suggested_time: cfg.cover_config!.ai_suggested_time ?? prev.ai_suggested_time,
|
||||
}))
|
||||
}
|
||||
|
||||
// 还原片段:优先从后端 clips 表,其次从 config.segments 兜底
|
||||
const backendClips = clipsRes?.items || []
|
||||
if (backendClips.length > 0) {
|
||||
// 从后端 clips 表还原
|
||||
const sorted = [...backendClips].sort((a, b) => a.order - b.order)
|
||||
const mapped: ClipData[] = sorted.map((clip) => ({
|
||||
id: clip.id,
|
||||
template_segment_id: (clip.config?.template_segment_id as string) || "",
|
||||
type: (clip.clip_type === "voiceover" ? "voice" : "pip") as ClipType,
|
||||
duration: clip.duration || 3,
|
||||
startOffset: 0,
|
||||
script_text: clip.text_content || "",
|
||||
order: clip.order,
|
||||
media_asset_id: clip.asset_id || undefined,
|
||||
transition:
|
||||
clip.transition_effect && clip.transition_effect !== "none"
|
||||
? {
|
||||
type: clip.transition_effect as TransitionEffect["type"],
|
||||
duration: clip.transition_duration || 0.3,
|
||||
}
|
||||
: undefined,
|
||||
speed: clip.playback_speed
|
||||
? { rate: clip.playback_speed, pitchCorrection: true }
|
||||
: undefined,
|
||||
tts_config: (clip.config?.tts_config as TtsConfig) || undefined,
|
||||
trim_config: (clip.config?.trim_config as TrimConfig) || undefined,
|
||||
}))
|
||||
setTimeout(() => resetClips(mapped), 100)
|
||||
} else if (cfg.segments && cfg.segments.length > 0) {
|
||||
// 兜底:从 config.segments 还原(老数据兼容)
|
||||
const mapped: ClipData[] = cfg.segments.map((seg, idx) => ({
|
||||
id: `seg-${idx}`,
|
||||
template_segment_id: `seg-${idx}`,
|
||||
type: (seg.material_type === "voiceover" ? "voice" : "pip") as ClipType,
|
||||
duration: (seg.duration_min + seg.duration_max) / 2,
|
||||
startOffset: 0,
|
||||
script_text: "",
|
||||
order: seg.segment_order,
|
||||
transition: seg.transition
|
||||
? {
|
||||
type: seg.transition.type as TransitionEffect["type"],
|
||||
duration: seg.transition.duration,
|
||||
}
|
||||
: undefined,
|
||||
speed: seg.playback_speed
|
||||
? { rate: seg.playback_speed, pitchCorrection: true }
|
||||
: undefined,
|
||||
tts_config: seg.tts_config
|
||||
? { ...seg.tts_config, mode: seg.tts_config.mode as TtsMode }
|
||||
: undefined,
|
||||
trim_config: seg.trim_config || undefined,
|
||||
}))
|
||||
setTimeout(() => resetClips(mapped), 100)
|
||||
}
|
||||
})
|
||||
.catch(() => message.error("加载模板草稿失败"))
|
||||
}, [
|
||||
loadedPlanId,
|
||||
resetClips,
|
||||
setTitleConfig,
|
||||
setSubtitleSettings,
|
||||
setBgmSettings,
|
||||
setCoverConfig,
|
||||
])
|
||||
|
||||
/* ──────────── 事件 ──────────── */
|
||||
|
||||
const handleLoadTemplate = (templateId: string) => {
|
||||
setLoadedTemplateId(templateId)
|
||||
setSelectedClipId(null)
|
||||
}
|
||||
|
||||
const handleModeChange = (mode: TemplateMode) => {
|
||||
setCurrentMode(mode)
|
||||
// 切换纯单类型模式时,自动转换所有已有片段的类型
|
||||
if (mode === "voice_over") {
|
||||
setClips((prev) => prev.map((c) => ({ ...c, type: "voice" as const })))
|
||||
} else if (mode === "pip") {
|
||||
setClips((prev) => prev.map((c) => ({ ...c, type: "pip" as const })))
|
||||
}
|
||||
// 混合模式(voice_pip)和一镜到底(one_take)不自动转换,保留原有类型
|
||||
}
|
||||
|
||||
const handleOpenSaveModal = () => {
|
||||
setSaveModalOpen(true)
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!draftName.trim()) {
|
||||
message.warning("请输入模板名称")
|
||||
return
|
||||
}
|
||||
setSaveLoading(true)
|
||||
try {
|
||||
const payload: SaveTemplatePayload = {
|
||||
name: draftName,
|
||||
mode: currentMode,
|
||||
category: draftCategory,
|
||||
tags: draftTags
|
||||
.split(",")
|
||||
.map((t) => t.trim())
|
||||
.filter(Boolean),
|
||||
title_config: titleConfig,
|
||||
subtitle_config: {
|
||||
enabled: subtitleSettings.enabled,
|
||||
position: subtitleSettings.position,
|
||||
font: subtitleSettings.font,
|
||||
color: subtitleSettings.fontColor,
|
||||
size: subtitleSettings.fontSize,
|
||||
animation: subtitleSettings.animation,
|
||||
},
|
||||
bgm_config: {
|
||||
enabled: bgmSettings.enabled,
|
||||
music_id: bgmSettings.music_id,
|
||||
},
|
||||
estimated_duration: totalDuration,
|
||||
segments: clips.map((c, i) => ({
|
||||
segment_order: i,
|
||||
duration_min: Math.max(1, c.duration - 2),
|
||||
duration_max: c.duration + 2,
|
||||
material_type: c.type === "voice" ? "voiceover" : "video",
|
||||
transition: c.transition
|
||||
? { type: c.transition.type, duration: c.transition.duration }
|
||||
: undefined,
|
||||
playback_speed: c.speed ? c.speed.rate : undefined,
|
||||
tts_config: c.tts_config
|
||||
? {
|
||||
mode: c.tts_config.mode,
|
||||
text: c.tts_config.text,
|
||||
voice_id: c.tts_config.voice_id,
|
||||
speed: c.tts_config.speed,
|
||||
pitch: c.tts_config.pitch,
|
||||
volume: c.tts_config.volume,
|
||||
subtitle_sync: c.tts_config.subtitle_sync,
|
||||
}
|
||||
: undefined,
|
||||
trim_config: c.trim_config
|
||||
? {
|
||||
start_time: c.trim_config.start_time,
|
||||
end_time: c.trim_config.end_time,
|
||||
}
|
||||
: undefined,
|
||||
})),
|
||||
watermark_config: { ...watermarkSettings },
|
||||
intro_outro_config: { ...introOutroSettings },
|
||||
pip_config: { ...pipSettings },
|
||||
filter_config: { ...filterSettings },
|
||||
green_screen_config: { ...chromaKeySettings },
|
||||
sticker_config: { ...stickerSettings },
|
||||
cover_config: { ...coverConfig },
|
||||
}
|
||||
if (loadedTemplateId) {
|
||||
await updateEditingTemplate(loadedTemplateId, payload)
|
||||
} else {
|
||||
await createEditingTemplate(payload)
|
||||
}
|
||||
message.success(loadedTemplateId ? "模板保存成功" : "模板创建成功")
|
||||
setSaveModalOpen(false)
|
||||
loadTemplates()
|
||||
} catch {
|
||||
message.error("保存失败")
|
||||
} finally {
|
||||
setSaveLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
// state
|
||||
templates,
|
||||
categories,
|
||||
loadingTemplates,
|
||||
loadedTemplateId,
|
||||
setLoadedTemplateId,
|
||||
currentMode,
|
||||
setCurrentMode,
|
||||
currentFilter,
|
||||
setCurrentFilter,
|
||||
searchQuery,
|
||||
setSearchQuery,
|
||||
saveModalOpen,
|
||||
setSaveModalOpen,
|
||||
draftName,
|
||||
setDraftName,
|
||||
draftCategory,
|
||||
setDraftCategory,
|
||||
draftTags,
|
||||
setDraftTags,
|
||||
saveLoading,
|
||||
filteredTemplates,
|
||||
currentTemplate,
|
||||
// methods
|
||||
loadTemplates,
|
||||
handleLoadTemplate,
|
||||
handleModeChange,
|
||||
handleOpenSaveModal,
|
||||
handleSave,
|
||||
}
|
||||
}
|
||||
|
||||
export { FILTER_CATEGORIES }
|
||||
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* TTS 配音 Hook
|
||||
* 管理音色加载、试听、配置变更
|
||||
*/
|
||||
import { useState, useCallback, useEffect, useRef } from "react"
|
||||
import { message } from "antd"
|
||||
import type { TtsConfig, TtsMode } from "../types"
|
||||
import { DEFAULT_TTS_CONFIG } from "../types"
|
||||
import { getTtsVoices, previewTts, type TTSVoice } from "@/api/tts"
|
||||
|
||||
interface UseTtsPanelOptions {
|
||||
open: boolean
|
||||
config: TtsConfig
|
||||
onChange: (config: TtsConfig) => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export const useTtsPanel = ({ open, config, onChange, onClose }: UseTtsPanelOptions) => {
|
||||
/* ── 音色列表 ── */
|
||||
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(
|
||||
(text: string) => {
|
||||
onChange({ ...config, text: text.slice(0, 5000) })
|
||||
},
|
||||
[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),
|
||||
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])
|
||||
|
||||
return {
|
||||
voices,
|
||||
voicesLoading,
|
||||
previewLoading,
|
||||
handleModeChange,
|
||||
handleTextChange,
|
||||
handleVoiceSelect,
|
||||
handleSpeedChange,
|
||||
handlePitchChange,
|
||||
handleVolumeChange,
|
||||
handleSubtitleSyncToggle,
|
||||
handlePreview,
|
||||
handleReset,
|
||||
handleClose,
|
||||
}
|
||||
}
|
||||
|
||||
export default useTtsPanel
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* 配音试听 Hook
|
||||
*/
|
||||
import { useRef, useState, useCallback } from "react"
|
||||
import type { AssetItem } from "@/api/assets"
|
||||
|
||||
export const useVoicePreview = () => {
|
||||
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],
|
||||
)
|
||||
|
||||
/** 停止试听 */
|
||||
const stopPreview = useCallback(() => {
|
||||
audioRef.current?.pause()
|
||||
setPreviewingId(null)
|
||||
}, [])
|
||||
|
||||
return {
|
||||
previewingId,
|
||||
handlePreviewVoice,
|
||||
stopPreview,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* 水印配置 Hook
|
||||
* 管理水印类型切换、各项配置变更
|
||||
*/
|
||||
import { useState, useCallback } from "react"
|
||||
import type { WatermarkConfig, WatermarkType, WatermarkPosition, ScrollDirection } from "../types"
|
||||
import { DEFAULT_WATERMARK } from "../types"
|
||||
|
||||
interface UseWatermarkConfigOptions {
|
||||
config: WatermarkConfig
|
||||
onChange: (config: WatermarkConfig) => void
|
||||
}
|
||||
|
||||
export const useWatermarkConfig = ({ config, onChange }: UseWatermarkConfigOptions) => {
|
||||
/* ── 图片上传预览 URL(本地预览用) ── */
|
||||
const [localImageUrl, setLocalImageUrl] = useState<string>("")
|
||||
|
||||
/* ── 切换水印类型 ── */
|
||||
const handleTypeChange = useCallback(
|
||||
(type: WatermarkType) => {
|
||||
setLocalImageUrl("")
|
||||
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])
|
||||
|
||||
return {
|
||||
localImageUrl,
|
||||
activeTab: config.type,
|
||||
handleTypeChange,
|
||||
handlePositionChange,
|
||||
handleOpacityChange,
|
||||
handleImageUrlChange,
|
||||
handleImageWidthChange,
|
||||
handleImageHeightChange,
|
||||
handleTextChange,
|
||||
handleFontSizeChange,
|
||||
handleColorChange,
|
||||
handleScrollDirectionChange,
|
||||
handleScrollSpeedChange,
|
||||
handleReset,
|
||||
}
|
||||
}
|
||||
|
||||
export default useWatermarkConfig
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* ClipPropertiesPanel 相关类型定义
|
||||
*/
|
||||
import type { ClipData } from "@/pages/editing-planner/types"
|
||||
import type { TemplateMode } from "@/api/editing-planner"
|
||||
import type { AssetItem } from "@/api/assets"
|
||||
|
||||
export interface SubtitleSettings {
|
||||
enabled: boolean
|
||||
position: string
|
||||
font: string
|
||||
fontSize: number
|
||||
fontColor: string
|
||||
animation: string
|
||||
mode?: string
|
||||
stroke?: boolean
|
||||
shadow?: boolean
|
||||
asrLanguage?: string
|
||||
}
|
||||
|
||||
export interface BgmSettings {
|
||||
enabled: boolean
|
||||
music_id: string
|
||||
volume?: number
|
||||
fade_in?: number
|
||||
fade_out?: number
|
||||
voice_dodge?: boolean
|
||||
}
|
||||
|
||||
export 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 */
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* ClipPropertiesPanel 工具函数
|
||||
*/
|
||||
import type { AssetItem } from "@/api/assets"
|
||||
import type { TemplateMode } from "@/api/editing-planner"
|
||||
|
||||
/** 从 metadata 取性别标签 */
|
||||
export const getGenderLabel = (m: AssetItem): string => {
|
||||
const g = (m.metadata?.gender as string) || ""
|
||||
if (g === "male") return "男"
|
||||
if (g === "female") return "女"
|
||||
return ""
|
||||
}
|
||||
|
||||
/** 格式化模式标签 */
|
||||
export const formatModeLabel = (mode: TemplateMode): string => {
|
||||
if (mode === "pip") return "混剪"
|
||||
if (mode === "voice_over") return "人物口播"
|
||||
if (mode === "one_take") return "一镜到底"
|
||||
return "口播+混剪"
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { EditingTemplate } from "@/api/editing-planner"
|
||||
import type { ClipData } from "../types"
|
||||
|
||||
/**
|
||||
* 计算所有片段的总时长
|
||||
*/
|
||||
export function calculateTotalDuration(clips: ClipData[]): number {
|
||||
return clips.reduce((sum, c) => sum + c.duration, 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前选中的模板
|
||||
*/
|
||||
export function getCurrentTemplate(
|
||||
templates: EditingTemplate[],
|
||||
loadedTemplateId: string | null,
|
||||
): EditingTemplate | undefined {
|
||||
return templates.find((t) => t.id === loadedTemplateId)
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据分类和搜索词筛选模板
|
||||
*/
|
||||
export function getFilteredTemplates(
|
||||
templates: EditingTemplate[],
|
||||
currentFilter: string,
|
||||
searchQuery: string,
|
||||
): EditingTemplate[] {
|
||||
return templates.filter((t) => {
|
||||
if (currentFilter !== "全部" && t.category !== currentFilter) return false
|
||||
if (searchQuery && !t.name.toLowerCase().includes(searchQuery.toLowerCase())) return false
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前选中的片段
|
||||
*/
|
||||
export function getSelectedClip(clips: ClipData[], selectedClipId: string | null): ClipData | null {
|
||||
return clips.find((c) => c.id === selectedClipId) || null
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
/** 格式化时间为 mm:ss */
|
||||
export const formatTime = (sec: number): string => {
|
||||
const m = Math.floor(sec / 60)
|
||||
const s = Math.floor(sec % 60)
|
||||
return `${m}:${s.toString().padStart(2, "0")}`
|
||||
}
|
||||
|
||||
/** 格式化裁剪时间(精确到 0.1 秒) */
|
||||
export const formatTrimTime = (sec: number): string => {
|
||||
return `${sec.toFixed(1)}s`
|
||||
}
|
||||
|
||||
/** 生成时间标尺刻度 */
|
||||
export const generateRulerMarks = (totalDuration: number, step: number): number[] => {
|
||||
const marks: number[] = []
|
||||
for (let t = 0; t <= totalDuration + step; t += step) {
|
||||
marks.push(t)
|
||||
}
|
||||
return marks
|
||||
}
|
||||
Regular → Executable
+56
-813
@@ -2,832 +2,87 @@
|
||||
* 成片库页面 — V21 设计系统
|
||||
* 卡片网格布局,支持视频播放/下载/分享、批量操作、筛选
|
||||
* 使用 useQuery 对接后端真实 API(api/products.ts)
|
||||
*
|
||||
* 代码结构(三阶段重构后):
|
||||
* - types.ts: 类型定义
|
||||
* - constants.ts: 常量配置
|
||||
* - utils/index.ts: 工具函数
|
||||
* - components/ProductCard.tsx: 产品卡片组件
|
||||
* - components/VideoPlayer.tsx: 视频播放器组件
|
||||
* - hooks/useProductList.ts: 列表查询与筛选
|
||||
* - hooks/useProductActions.ts: 单个/批量操作
|
||||
*/
|
||||
import React, { useMemo, useState, useRef, useEffect, useCallback } from "react"
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
import { useNavigate } from "react-router-dom"
|
||||
import { message, Popconfirm } from "antd"
|
||||
import React, { useState } from "react"
|
||||
import { Popconfirm, message } from "antd"
|
||||
import {
|
||||
SearchOutlined,
|
||||
VideoCameraOutlined,
|
||||
DownloadOutlined,
|
||||
ShareAltOutlined,
|
||||
DeleteOutlined,
|
||||
PlayCircleOutlined,
|
||||
PauseCircleOutlined,
|
||||
CloseOutlined,
|
||||
CheckOutlined,
|
||||
CloudUploadOutlined,
|
||||
EyeOutlined,
|
||||
} from "@ant-design/icons"
|
||||
import { Button, Input, Select } from "@/components/ui"
|
||||
import {
|
||||
getProducts,
|
||||
deleteProduct,
|
||||
getProductDownloadUrl,
|
||||
updateReviewStatus,
|
||||
batchDownload,
|
||||
getBatchDownloadStatus,
|
||||
type ProductItem as ApiProductItem,
|
||||
type ReviewStatus,
|
||||
} from "@/api/products"
|
||||
import type { ProductItem } from "./types"
|
||||
import { ProductCard } from "./components/ProductCard"
|
||||
import { VideoPlayer } from "./components/VideoPlayer"
|
||||
import { useProductList } from "./hooks/useProductList"
|
||||
import { useProductActions } from "./hooks/useProductActions"
|
||||
import "./products.css"
|
||||
|
||||
/* ============================================================
|
||||
* 类型 & 常量
|
||||
* ============================================================ */
|
||||
type ProductStatus = "completed" | "processing" | "review" | "failed"
|
||||
|
||||
interface ProductItem {
|
||||
id: string
|
||||
name: string
|
||||
status: ProductStatus
|
||||
duration: number // 秒
|
||||
date: string
|
||||
gradient: string
|
||||
duplicateRate: number // 查重率百分比
|
||||
isPublished: boolean
|
||||
resolution: string
|
||||
fileSize: number // MB
|
||||
videoUrl?: string
|
||||
thumbnailUrl?: string
|
||||
/** 复核状态 */
|
||||
reviewStatus?: ReviewStatus
|
||||
/** 所属项目 ID */
|
||||
projectId?: string
|
||||
/** 所属项目名称 */
|
||||
projectName?: string
|
||||
}
|
||||
|
||||
/** 将后端 status 映射为前端 ProductStatus */
|
||||
const normalizeStatus = (s: string): ProductStatus => {
|
||||
const map: Record<string, ProductStatus> = {
|
||||
completed: "completed",
|
||||
succeeded: "completed",
|
||||
processing: "processing",
|
||||
running: "processing",
|
||||
review: "review",
|
||||
failed: "failed",
|
||||
error: "failed",
|
||||
}
|
||||
return map[s] ?? "processing"
|
||||
}
|
||||
|
||||
/** 渐变色列表(按 id hash 选取) */
|
||||
const GRADIENTS = [
|
||||
"linear-gradient(135deg, #1e1b4b 0%, #4f46e5 50%, #818cf8 100%)",
|
||||
"linear-gradient(135deg, #831843 0%, #db2777 50%, #f472b6 100%)",
|
||||
"linear-gradient(135deg, #064e3b 0%, #059669 50%, #34d399 100%)",
|
||||
"linear-gradient(135deg, #78350f 0%, #d97706 50%, #fbbf24 100%)",
|
||||
"linear-gradient(135deg, #1e3a5f 0%, #2563eb 50%, #60a5fa 100%)",
|
||||
"linear-gradient(135deg, #581c87 0%, #9333ea 50%, #c084fc 100%)",
|
||||
"linear-gradient(135deg, #7f1d1d 0%, #dc2626 50%, #f87171 100%)",
|
||||
"linear-gradient(135deg, #134e4a 0%, #0d9488 50%, #5eead4 100%)",
|
||||
"linear-gradient(135deg, #1e1b4b 0%, #6366f1 50%, #a5b4fc 100%)",
|
||||
"linear-gradient(135deg, #3b0764 0%, #7c3aed 50%, #a78bfa 100%)",
|
||||
"linear-gradient(135deg, #052e16 0%, #16a34a 50%, #86efac 100%)",
|
||||
"linear-gradient(135deg, #450a0e 0%, #e11d48 50%, #fb7185 100%)",
|
||||
]
|
||||
|
||||
/** 根据 id 生成稳定的渐变色 */
|
||||
const gradientForId = (id: string): string => {
|
||||
let hash = 0
|
||||
for (let i = 0; i < id.length; i++) hash = (hash * 31 + id.charCodeAt(i)) | 0
|
||||
return GRADIENTS[Math.abs(hash) % GRADIENTS.length]
|
||||
}
|
||||
|
||||
/** 将后端 ProductItem 映射为前端 ProductItem */
|
||||
const mapApiProduct = (item: ApiProductItem): ProductItem => ({
|
||||
id: item.id,
|
||||
name: item.title,
|
||||
status: normalizeStatus(item.status),
|
||||
duration: item.duration_seconds ?? 0,
|
||||
date: item.created_at ? formatDateTime(item.created_at) : "—",
|
||||
gradient: gradientForId(item.id),
|
||||
duplicateRate: item.duplicate_rate ?? 0,
|
||||
isPublished: false, // TODO: 后端发布状态字段待补齐
|
||||
resolution: item.resolution ?? "1080×1920",
|
||||
fileSize: item.file_size ? +(item.file_size / (1024 * 1024)).toFixed(1) : 0,
|
||||
videoUrl: item.video_url,
|
||||
thumbnailUrl: item.thumbnail_url,
|
||||
reviewStatus: item.review_status,
|
||||
projectId: item.project_id,
|
||||
projectName: item.project_name,
|
||||
})
|
||||
|
||||
/* ============================================================
|
||||
* 工具函数
|
||||
* ============================================================ */
|
||||
const statusConfig: Record<ProductStatus, { text: string; className: string }> = {
|
||||
completed: { text: "已完成", className: "completed" },
|
||||
processing: { text: "处理中", className: "processing" },
|
||||
review: { text: "待复核", className: "review" },
|
||||
failed: { text: "失败", className: "failed" },
|
||||
}
|
||||
|
||||
/** 格式化时间 mm:ss */
|
||||
const formatTime = (seconds: number): string => {
|
||||
const mins = Math.floor(seconds / 60)
|
||||
const secs = Math.floor(seconds % 60)
|
||||
return `${mins.toString().padStart(2, "0")}:${secs.toString().padStart(2, "0")}`
|
||||
}
|
||||
|
||||
/** 格式化文件大小 */
|
||||
const formatSize = (mb: number): string => {
|
||||
if (mb <= 0) return "-"
|
||||
if (mb < 1) return `${(mb * 1024).toFixed(0)} KB`
|
||||
return `${mb.toFixed(1)} MB`
|
||||
}
|
||||
|
||||
/** 格式化日期时间(MM-DD HH:mm) */
|
||||
const formatDateTime = (isoString: string): string => {
|
||||
const d = new Date(isoString)
|
||||
const month = (d.getMonth() + 1).toString().padStart(2, "0")
|
||||
const day = d.getDate().toString().padStart(2, "0")
|
||||
const hour = d.getHours().toString().padStart(2, "0")
|
||||
const minute = d.getMinutes().toString().padStart(2, "0")
|
||||
return `${month}-${day} ${hour}:${minute}`
|
||||
}
|
||||
|
||||
/** 复核状态配置 */
|
||||
const reviewStatusConfig: Record<ReviewStatus, { text: string; className: string }> = {
|
||||
pending_review: { text: "待复核", className: "review-pending" },
|
||||
approved: { text: "已通过", className: "review-approved" },
|
||||
rejected: { text: "需修改", className: "review-rejected" },
|
||||
}
|
||||
|
||||
/** 复核状态循环顺序 */
|
||||
const REVIEW_STATUS_CYCLE: ReviewStatus[] = ["pending_review", "approved", "rejected"]
|
||||
|
||||
/** 获取下一个复核状态 */
|
||||
const getNextReviewStatus = (current?: ReviewStatus): ReviewStatus => {
|
||||
if (!current) return "approved"
|
||||
const idx = REVIEW_STATUS_CYCLE.indexOf(current)
|
||||
return REVIEW_STATUS_CYCLE[(idx + 1) % REVIEW_STATUS_CYCLE.length]
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* ProductCard 组件
|
||||
* ============================================================ */
|
||||
const ProductCard: React.FC<{
|
||||
product: ProductItem
|
||||
isSelected: boolean
|
||||
batchMode: boolean
|
||||
onToggleSelect: (id: string) => void
|
||||
onPlay: (product: ProductItem) => void
|
||||
onDownload: (product: ProductItem) => void
|
||||
onShare: (product: ProductItem) => void
|
||||
onDelete: (id: string) => void
|
||||
onPublish: (product: ProductItem) => void
|
||||
onReviewStatusChange: (id: string) => void
|
||||
}> = ({
|
||||
product,
|
||||
isSelected,
|
||||
batchMode,
|
||||
onToggleSelect,
|
||||
onPlay,
|
||||
onDownload,
|
||||
onShare,
|
||||
onDelete,
|
||||
onPublish,
|
||||
onReviewStatusChange,
|
||||
}) => {
|
||||
const st = statusConfig[product.status]
|
||||
|
||||
/** 查重率样式 */
|
||||
const dupClass =
|
||||
product.duplicateRate <= 5 ? "good" : product.duplicateRate <= 15 ? "warn" : "bad"
|
||||
|
||||
/** 点击卡片 */
|
||||
const handleCardClick = () => {
|
||||
if (batchMode) {
|
||||
onToggleSelect(product.id)
|
||||
} else {
|
||||
onPlay(product)
|
||||
}
|
||||
}
|
||||
|
||||
/** 点击复选框 */
|
||||
const handleCheckboxClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
onToggleSelect(product.id)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`xx-product-card${isSelected ? " selected" : ""}${product.isPublished ? " published" : ""}`}
|
||||
onClick={handleCardClick}
|
||||
>
|
||||
{/* 复选框(左上角) */}
|
||||
<div
|
||||
className={`xx-product-card-checkbox${isSelected ? " checked" : ""}`}
|
||||
onClick={handleCheckboxClick}
|
||||
title={isSelected ? "取消选择" : "选择"}
|
||||
>
|
||||
{isSelected && <CheckOutlined />}
|
||||
</div>
|
||||
|
||||
{/* 已发布徽章(右上角) */}
|
||||
{product.isPublished && <div className="xx-product-badge">✅ 已发布</div>}
|
||||
|
||||
{/* 复核状态标签(右上角) */}
|
||||
{product.reviewStatus && (
|
||||
<div
|
||||
className={`xx-product-review-tag ${reviewStatusConfig[product.reviewStatus].className}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onReviewStatusChange(product.id)
|
||||
}}
|
||||
title={`点击切换复核状态(当前:${reviewStatusConfig[product.reviewStatus].text})`}
|
||||
>
|
||||
{reviewStatusConfig[product.reviewStatus].text}
|
||||
</div>
|
||||
)}
|
||||
{/* 无复核状态时显示"待复核"入口 */}
|
||||
{!product.reviewStatus && (
|
||||
<div
|
||||
className="xx-product-review-tag review-pending"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onReviewStatusChange(product.id)
|
||||
}}
|
||||
title="点击设置复核状态"
|
||||
>
|
||||
待复核
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 缩略图 */}
|
||||
<div className="xx-product-thumb">
|
||||
{product.thumbnailUrl ? (
|
||||
<img
|
||||
className="xx-product-thumb-bg"
|
||||
src={product.thumbnailUrl}
|
||||
alt={product.name}
|
||||
style={{ width: "100%", height: "100%", objectFit: "cover" }}
|
||||
/>
|
||||
) : (
|
||||
<div className="xx-product-thumb-bg" style={{ background: product.gradient }} />
|
||||
)}
|
||||
<div className="xx-product-play">
|
||||
<PlayCircleOutlined />
|
||||
</div>
|
||||
{product.duration > 0 && (
|
||||
<span className="xx-product-duration">{formatTime(product.duration)}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 信息区 */}
|
||||
<div className="xx-product-info">
|
||||
<h4 className="xx-product-title" title={product.name}>
|
||||
{product.name}
|
||||
</h4>
|
||||
<div className="xx-product-meta">
|
||||
<span className={`xx-product-status ${st.className}`}>{st.text}</span>
|
||||
<span className="xx-product-date">{product.date}</span>
|
||||
</div>
|
||||
{product.duplicateRate > 0 && (
|
||||
<span className={`xx-product-dup-rate ${dupClass}`}>
|
||||
查重率:{product.duplicateRate.toFixed(1)}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className="xx-product-actions" onClick={(e) => e.stopPropagation()}>
|
||||
<button
|
||||
className="xx-product-action-btn"
|
||||
onClick={() => onDownload(product)}
|
||||
disabled={product.status !== "completed"}
|
||||
title="下载"
|
||||
>
|
||||
<DownloadOutlined /> 下载
|
||||
</button>
|
||||
<button
|
||||
className="xx-product-action-btn"
|
||||
onClick={() => onShare(product)}
|
||||
disabled={product.status !== "completed"}
|
||||
title="分享"
|
||||
>
|
||||
<ShareAltOutlined /> 分享
|
||||
</button>
|
||||
{product.isPublished ? (
|
||||
<span className="xx-product-action-btn success" style={{ cursor: "default" }}>
|
||||
✅ 已发布
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
className="xx-product-action-btn primary"
|
||||
onClick={() => onPublish(product)}
|
||||
disabled={product.status !== "completed"}
|
||||
title="发布"
|
||||
>
|
||||
<CloudUploadOutlined /> 发布
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 删除按钮(不在批量模式下显示) */}
|
||||
{!batchMode && (
|
||||
<div style={{ padding: "0 14px 14px" }} onClick={(e) => e.stopPropagation()}>
|
||||
<Popconfirm
|
||||
title={`确定删除"${product.name}"?`}
|
||||
onConfirm={() => onDelete(product.id)}
|
||||
okText="删除"
|
||||
cancelText="取消"
|
||||
>
|
||||
<button
|
||||
className="xx-product-action-btn"
|
||||
style={{ width: "100%", color: "#dc2626" }}
|
||||
title="删除"
|
||||
>
|
||||
<DeleteOutlined /> 删除
|
||||
</button>
|
||||
</Popconfirm>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* VideoPlayer 弹窗组件
|
||||
* ============================================================ */
|
||||
const VideoPlayer: React.FC<{
|
||||
product: ProductItem
|
||||
onClose: () => void
|
||||
onDownload: (product: ProductItem) => void
|
||||
onShare: (product: ProductItem) => void
|
||||
onViewDetail: (product: ProductItem) => void
|
||||
}> = ({ product, onClose, onDownload, onShare, onViewDetail }) => {
|
||||
const videoRef = useRef<HTMLVideoElement>(null)
|
||||
const progressRef = useRef<HTMLDivElement>(null)
|
||||
const [isPlaying, setIsPlaying] = useState(false)
|
||||
const [currentTime, setCurrentTime] = useState(0)
|
||||
const [duration, setDuration] = useState(product.duration)
|
||||
|
||||
const hasVideo = !!product.videoUrl
|
||||
|
||||
/** 播放/暂停 */
|
||||
const handlePlayPause = useCallback(() => {
|
||||
const video = videoRef.current
|
||||
if (!video) return
|
||||
if (isPlaying) {
|
||||
video.pause()
|
||||
} else {
|
||||
video.play().catch(() => {})
|
||||
}
|
||||
setIsPlaying(!isPlaying)
|
||||
}, [isPlaying])
|
||||
|
||||
/** 视频事件监听 */
|
||||
useEffect(() => {
|
||||
const video = videoRef.current
|
||||
if (!video) return
|
||||
const onTime = () => setCurrentTime(video.currentTime)
|
||||
const onDur = () => setDuration(video.duration || product.duration)
|
||||
const onEnd = () => setIsPlaying(false)
|
||||
video.addEventListener("timeupdate", onTime)
|
||||
video.addEventListener("loadedmetadata", onDur)
|
||||
video.addEventListener("ended", onEnd)
|
||||
return () => {
|
||||
video.removeEventListener("timeupdate", onTime)
|
||||
video.removeEventListener("loadedmetadata", onDur)
|
||||
video.removeEventListener("ended", onEnd)
|
||||
}
|
||||
}, [product.duration])
|
||||
|
||||
/** 进度条点击 */
|
||||
const handleProgressClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (!progressRef.current) return
|
||||
const rect = progressRef.current.getBoundingClientRect()
|
||||
const percent = (e.clientX - rect.left) / rect.width
|
||||
const newTime = percent * duration
|
||||
setCurrentTime(newTime)
|
||||
if (videoRef.current) videoRef.current.currentTime = newTime
|
||||
}
|
||||
|
||||
/** ESC 关闭 */
|
||||
useEffect(() => {
|
||||
const handleKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose()
|
||||
}
|
||||
window.addEventListener("keydown", handleKey)
|
||||
return () => window.removeEventListener("keydown", handleKey)
|
||||
}, [onClose])
|
||||
|
||||
const progress = duration > 0 ? (currentTime / duration) * 100 : 0
|
||||
|
||||
return (
|
||||
<div className="xx-player-overlay" onClick={onClose}>
|
||||
<div className="xx-player-container" onClick={(e) => e.stopPropagation()}>
|
||||
{/* 视频区域 */}
|
||||
<div className="xx-player-video-wrap">
|
||||
{hasVideo ? (
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={product.videoUrl}
|
||||
style={{ width: "100%", height: "100%", objectFit: "contain" }}
|
||||
/>
|
||||
) : (
|
||||
/* 无视频 URL 时用渐变占位 */
|
||||
<div
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
background: product.gradient,
|
||||
display: "grid",
|
||||
placeItems: "center",
|
||||
color: "rgba(255,255,255,0.3)",
|
||||
fontSize: "64px",
|
||||
}}
|
||||
>
|
||||
<VideoCameraOutlined />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 播放/暂停按钮 */}
|
||||
<button className="xx-player-play-btn" onClick={handlePlayPause}>
|
||||
{isPlaying ? <PauseCircleOutlined /> : <PlayCircleOutlined />}
|
||||
</button>
|
||||
|
||||
{/* 关闭按钮 */}
|
||||
<button className="xx-player-close" onClick={onClose}>
|
||||
<CloseOutlined />
|
||||
</button>
|
||||
|
||||
{/* 进度条 */}
|
||||
<div className="xx-player-progress-wrap">
|
||||
<div ref={progressRef} className="xx-player-progress" onClick={handleProgressClick}>
|
||||
<div className="xx-player-progress-bar" style={{ width: `${progress}%` }} />
|
||||
</div>
|
||||
<div className="xx-player-time">
|
||||
<span>{formatTime(currentTime)}</span>
|
||||
<span>{formatTime(duration)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 信息区 */}
|
||||
<div className="xx-player-info">
|
||||
<h3 className="xx-player-title">{product.name}</h3>
|
||||
<div className="xx-player-details">
|
||||
<div className="xx-player-detail-item">
|
||||
<span className="xx-player-detail-label">分辨率:</span>
|
||||
<span className="xx-player-detail-value">{product.resolution}</span>
|
||||
</div>
|
||||
<div className="xx-player-detail-item">
|
||||
<span className="xx-player-detail-label">时长:</span>
|
||||
<span className="xx-player-detail-value">{formatTime(product.duration)}</span>
|
||||
</div>
|
||||
<div className="xx-player-detail-item">
|
||||
<span className="xx-player-detail-label">文件大小:</span>
|
||||
<span className="xx-player-detail-value">{formatSize(product.fileSize)}</span>
|
||||
</div>
|
||||
<div className="xx-player-detail-item">
|
||||
<span className="xx-player-detail-label">查重率:</span>
|
||||
<span className="xx-player-detail-value">
|
||||
{product.duplicateRate > 0 ? `${product.duplicateRate.toFixed(1)}%` : "-"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 底部操作 */}
|
||||
<div className="xx-player-footer">
|
||||
<Button
|
||||
buttonType="ghost"
|
||||
buttonSize="sm"
|
||||
icon={<DownloadOutlined />}
|
||||
onClick={() => onDownload(product)}
|
||||
disabled={product.status !== "completed"}
|
||||
>
|
||||
下载视频
|
||||
</Button>
|
||||
<Button
|
||||
buttonType="ghost"
|
||||
buttonSize="sm"
|
||||
icon={<ShareAltOutlined />}
|
||||
onClick={() => onShare(product)}
|
||||
disabled={product.status !== "completed"}
|
||||
>
|
||||
分享
|
||||
</Button>
|
||||
<Button
|
||||
buttonType="ghost"
|
||||
buttonSize="sm"
|
||||
icon={<EyeOutlined />}
|
||||
onClick={() => onViewDetail(product)}
|
||||
>
|
||||
查看详情
|
||||
</Button>
|
||||
<div style={{ flex: 1 }} />
|
||||
<Button buttonType="primary" buttonSize="sm" onClick={onClose}>
|
||||
关闭
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* 主组件
|
||||
* ============================================================ */
|
||||
const ProductLibrary: React.FC = () => {
|
||||
const queryClient = useQueryClient()
|
||||
const navigate = useNavigate()
|
||||
|
||||
/* ── 获取成品列表 ── */
|
||||
const {
|
||||
data: apiProducts = [],
|
||||
products,
|
||||
filteredProducts,
|
||||
isLoading,
|
||||
isError,
|
||||
error,
|
||||
refetch,
|
||||
} = useQuery<ApiProductItem[], Error>({
|
||||
queryKey: ["products"],
|
||||
queryFn: () => getProducts(),
|
||||
staleTime: 30_000,
|
||||
})
|
||||
|
||||
// 映射为前端类型,按创建时间倒序排列,防御非数组返回
|
||||
const products = useMemo(() => {
|
||||
const list = (Array.isArray(apiProducts) ? apiProducts : []).map(mapApiProduct)
|
||||
// 按创建时间倒序(最新的在最前面),无时间的排最后
|
||||
return list.sort((a, b) => {
|
||||
if (!a.date || a.date === "—") return 1
|
||||
if (!b.date || b.date === "—") return -1
|
||||
return new Date(b.date).getTime() - new Date(a.date).getTime()
|
||||
})
|
||||
}, [apiProducts])
|
||||
|
||||
/* ── 删除 mutation ── */
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: deleteProduct,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["products"] })
|
||||
message.success("已删除")
|
||||
},
|
||||
onError: () => {
|
||||
message.error("删除失败")
|
||||
},
|
||||
})
|
||||
|
||||
/* ── 复核状态 mutation ── */
|
||||
const reviewMutation = useMutation({
|
||||
mutationFn: ({ id, status }: { id: string; status: ReviewStatus }) =>
|
||||
updateReviewStatus(id, status),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["products"] })
|
||||
message.success("复核状态已更新")
|
||||
},
|
||||
onError: () => {
|
||||
message.error("更新复核状态失败")
|
||||
},
|
||||
})
|
||||
|
||||
/* 筛选 */
|
||||
const [searchText, setSearchText] = useState("")
|
||||
const [filterStatus, setFilterStatus] = useState<string>("all")
|
||||
const [filterTime, setFilterTime] = useState<string>("all")
|
||||
const [filterDuration, setFilterDuration] = useState<string>("all")
|
||||
const [filterProject, setFilterProject] = useState<string>("all")
|
||||
const [filterReviewStatus, setFilterReviewStatus] = useState<string>("all")
|
||||
|
||||
/* 批量操作 */
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
|
||||
searchText,
|
||||
setSearchText,
|
||||
filterStatus,
|
||||
setFilterStatus,
|
||||
filterTime,
|
||||
setFilterTime,
|
||||
filterDuration,
|
||||
setFilterDuration,
|
||||
filterProject,
|
||||
setFilterProject,
|
||||
filterReviewStatus,
|
||||
setFilterReviewStatus,
|
||||
projectOptions,
|
||||
selectedIds,
|
||||
batchMode,
|
||||
allSelected,
|
||||
handleSelectAll,
|
||||
handleToggleSelect,
|
||||
clearSelection,
|
||||
} = useProductList()
|
||||
|
||||
/* 播放器 */
|
||||
const [playingProduct, setPlayingProduct] = useState<ProductItem | null>(null)
|
||||
|
||||
/* 派生数据 */
|
||||
const batchMode = selectedIds.size > 0
|
||||
|
||||
const filteredProducts = useMemo(() => {
|
||||
let list = products
|
||||
|
||||
/* 状态筛选 */
|
||||
if (filterStatus !== "all") {
|
||||
list = list.filter((p) => p.status === filterStatus)
|
||||
}
|
||||
|
||||
/* 时间筛选 */
|
||||
if (filterTime !== "all") {
|
||||
const now = new Date()
|
||||
list = list.filter((p) => {
|
||||
const d = new Date(p.date)
|
||||
const diff = (now.getTime() - d.getTime()) / (1000 * 60 * 60 * 24)
|
||||
switch (filterTime) {
|
||||
case "today":
|
||||
return diff < 1
|
||||
case "week":
|
||||
return diff <= 7
|
||||
case "month":
|
||||
return diff <= 30
|
||||
default:
|
||||
return true
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/* 时长筛选 */
|
||||
if (filterDuration !== "all") {
|
||||
list = list.filter((p) => {
|
||||
switch (filterDuration) {
|
||||
case "short":
|
||||
return p.duration > 0 && p.duration <= 60
|
||||
case "medium":
|
||||
return p.duration > 60 && p.duration <= 180
|
||||
case "long":
|
||||
return p.duration > 180
|
||||
default:
|
||||
return true
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/* 项目筛选 */
|
||||
if (filterProject !== "all") {
|
||||
list = list.filter((p) => p.projectId === filterProject)
|
||||
}
|
||||
|
||||
/* 复核状态筛选 */
|
||||
if (filterReviewStatus !== "all") {
|
||||
if (filterReviewStatus === "none") {
|
||||
list = list.filter((p) => !p.reviewStatus)
|
||||
} else {
|
||||
list = list.filter((p) => p.reviewStatus === filterReviewStatus)
|
||||
}
|
||||
}
|
||||
|
||||
/* 搜索 */
|
||||
if (searchText.trim()) {
|
||||
const q = searchText.trim().toLowerCase()
|
||||
list = list.filter((p) => p.name.toLowerCase().includes(q))
|
||||
}
|
||||
|
||||
return list
|
||||
}, [
|
||||
const {
|
||||
handleDownload,
|
||||
handleShare,
|
||||
handleViewDetail,
|
||||
handleDelete,
|
||||
handlePublish,
|
||||
handleReviewStatusChange,
|
||||
handleBatchDownload,
|
||||
handleBatchDelete,
|
||||
handleBatchPublish,
|
||||
batchDownloading,
|
||||
} = useProductActions({
|
||||
selectedIds,
|
||||
clearSelection,
|
||||
products,
|
||||
filterStatus,
|
||||
filterTime,
|
||||
filterDuration,
|
||||
filterProject,
|
||||
filterReviewStatus,
|
||||
searchText,
|
||||
])
|
||||
setPlayingProduct,
|
||||
})
|
||||
|
||||
/* 全选 */
|
||||
const allSelected =
|
||||
filteredProducts.length > 0 && filteredProducts.every((p) => selectedIds.has(p.id))
|
||||
|
||||
const handleSelectAll = () => {
|
||||
if (allSelected) {
|
||||
setSelectedIds(new Set())
|
||||
} else {
|
||||
setSelectedIds(new Set(filteredProducts.map((p) => p.id)))
|
||||
}
|
||||
}
|
||||
|
||||
/* 切换单个选择 */
|
||||
const handleToggleSelect = (id: string) => {
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(id)) {
|
||||
next.delete(id)
|
||||
} else {
|
||||
next.add(id)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
/* 下载 — 调用真实 API 获取下载链接 */
|
||||
const handleDownload = async (product: ProductItem) => {
|
||||
if (product.status !== "completed") return
|
||||
try {
|
||||
const { url } = await getProductDownloadUrl(product.id)
|
||||
// 打开下载链接
|
||||
const a = document.createElement("a")
|
||||
a.href = url
|
||||
a.download = ""
|
||||
a.click()
|
||||
message.success(`正在下载"${product.name}"`)
|
||||
} catch {
|
||||
message.error("获取下载链接失败")
|
||||
}
|
||||
}
|
||||
|
||||
/* 分享 */
|
||||
const handleShare = (product: ProductItem) => {
|
||||
if (product.status !== "completed") return
|
||||
const link = `${window.location.origin}/share/${product.id}`
|
||||
navigator.clipboard?.writeText(link).then(
|
||||
() => message.success(`分享链接已复制:${link}`),
|
||||
() => message.success(`分享链接:${link}(请手动复制)`),
|
||||
)
|
||||
}
|
||||
|
||||
/* 查看详情 — 跳转到产品详情页 */
|
||||
const handleViewDetail = (product: ProductItem) => {
|
||||
setPlayingProduct(null) // 关闭播放器
|
||||
navigate(`/app/products/${product.id}`)
|
||||
}
|
||||
|
||||
/* 删除 */
|
||||
const handleDelete = (id: string) => {
|
||||
deleteMutation.mutate(id)
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
next.delete(id)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
/* 发布 — TODO: 后端发布 API 待实现 */
|
||||
const handlePublish = (_product: ProductItem) => {
|
||||
// TODO: 对接后端发布 API(当前后端未提供发布接口)
|
||||
message.info("发布功能待后端 API 补齐")
|
||||
}
|
||||
|
||||
/* 切换复核状态 */
|
||||
const handleReviewStatusChange = (id: string) => {
|
||||
const current = products.find((p) => p.id === id)?.reviewStatus
|
||||
const nextStatus = getNextReviewStatus(current)
|
||||
reviewMutation.mutate({ id, status: nextStatus })
|
||||
}
|
||||
|
||||
/* 批量下载 — 使用 batch-download API + 轮询 */
|
||||
const [batchDownloading, setBatchDownloading] = useState(false)
|
||||
|
||||
const handleBatchDownload = async () => {
|
||||
const ids = Array.from(selectedIds)
|
||||
if (ids.length === 0) return
|
||||
setBatchDownloading(true)
|
||||
try {
|
||||
// 发起批量下载任务
|
||||
const { job_id } = await batchDownload(ids)
|
||||
message.info(`批量下载任务已创建,正在打包 ${ids.length} 个视频...`)
|
||||
|
||||
// 轮询下载状态(最多 60 次,每次 2 秒)
|
||||
let attempts = 0
|
||||
const maxAttempts = 60
|
||||
const poll = async (): Promise<void> => {
|
||||
if (attempts >= maxAttempts) {
|
||||
message.warning("打包超时,请稍后在消息中心查看")
|
||||
return
|
||||
}
|
||||
attempts++
|
||||
await new Promise((r) => setTimeout(r, 2000))
|
||||
const status = await getBatchDownloadStatus(job_id)
|
||||
if (status.status === "completed" && status.download_url) {
|
||||
const a = document.createElement("a")
|
||||
a.href = status.download_url
|
||||
a.download = ""
|
||||
a.click()
|
||||
message.success(`已打包下载 ${ids.length} 个视频`)
|
||||
setSelectedIds(new Set())
|
||||
} else if (status.status === "failed") {
|
||||
message.error("批量下载失败,请重试")
|
||||
} else {
|
||||
// 继续轮询
|
||||
await poll()
|
||||
}
|
||||
}
|
||||
await poll()
|
||||
} catch {
|
||||
message.error("发起批量下载失败")
|
||||
} finally {
|
||||
setBatchDownloading(false)
|
||||
}
|
||||
}
|
||||
|
||||
/* 批量删除 */
|
||||
const handleBatchDelete = async () => {
|
||||
const ids = Array.from(selectedIds)
|
||||
let successCount = 0
|
||||
for (const id of ids) {
|
||||
try {
|
||||
await deleteProduct(id)
|
||||
successCount++
|
||||
} catch {
|
||||
// 忽略单个失败
|
||||
}
|
||||
}
|
||||
queryClient.invalidateQueries({ queryKey: ["products"] })
|
||||
setSelectedIds(new Set())
|
||||
message.success(`已批量删除 ${successCount}/${ids.length} 个视频`)
|
||||
}
|
||||
|
||||
/* 批量发布 */
|
||||
const handleBatchPublish = () => {
|
||||
// TODO: 对接后端批量发布 API
|
||||
message.info("批量发布功能待后端 API 补齐")
|
||||
setSelectedIds(new Set())
|
||||
}
|
||||
|
||||
// ── Loading 状态 ──
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="xx-products-page">
|
||||
@@ -939,7 +194,7 @@ const ProductLibrary: React.FC = () => {
|
||||
批量删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
<Button buttonType="ghost" buttonSize="sm" onClick={() => setSelectedIds(new Set())}>
|
||||
<Button buttonType="ghost" buttonSize="sm" onClick={clearSelection}>
|
||||
取消选择
|
||||
</Button>
|
||||
</div>
|
||||
@@ -995,19 +250,7 @@ const ProductLibrary: React.FC = () => {
|
||||
value={filterProject}
|
||||
onChange={setFilterProject}
|
||||
style={{ width: 140 }}
|
||||
options={[
|
||||
{ value: "all", label: "全部项目" },
|
||||
...Array.from(
|
||||
new Map(
|
||||
products
|
||||
.filter((p) => p.projectId && p.projectName)
|
||||
.map((p) => [p.projectId!, p.projectName!] as const),
|
||||
),
|
||||
).map(([id, name]) => ({
|
||||
value: id as string,
|
||||
label: name as string,
|
||||
})),
|
||||
]}
|
||||
options={[{ value: "all", label: "全部项目" }, ...projectOptions]}
|
||||
/>
|
||||
<Select
|
||||
value={filterReviewStatus}
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
import React from "react"
|
||||
import { Popconfirm } from "antd"
|
||||
import {
|
||||
CheckOutlined,
|
||||
PlayCircleOutlined,
|
||||
DownloadOutlined,
|
||||
ShareAltOutlined,
|
||||
DeleteOutlined,
|
||||
CloudUploadOutlined,
|
||||
} from "@ant-design/icons"
|
||||
import type { ProductItem } from "../types"
|
||||
import { statusConfig, reviewStatusConfig } from "../constants"
|
||||
import { formatTime } from "../utils"
|
||||
|
||||
interface ProductCardProps {
|
||||
product: ProductItem
|
||||
isSelected: boolean
|
||||
batchMode: boolean
|
||||
onToggleSelect: (id: string) => void
|
||||
onPlay: (product: ProductItem) => void
|
||||
onDownload: (product: ProductItem) => void
|
||||
onShare: (product: ProductItem) => void
|
||||
onDelete: (id: string) => void
|
||||
onPublish: (product: ProductItem) => void
|
||||
onReviewStatusChange: (id: string) => void
|
||||
}
|
||||
|
||||
export const ProductCard: React.FC<ProductCardProps> = ({
|
||||
product,
|
||||
isSelected,
|
||||
batchMode,
|
||||
onToggleSelect,
|
||||
onPlay,
|
||||
onDownload,
|
||||
onShare,
|
||||
onDelete,
|
||||
onPublish,
|
||||
onReviewStatusChange,
|
||||
}) => {
|
||||
const st = statusConfig[product.status]
|
||||
|
||||
/** 查重率样式 */
|
||||
const dupClass =
|
||||
product.duplicateRate <= 5 ? "good" : product.duplicateRate <= 15 ? "warn" : "bad"
|
||||
|
||||
/** 点击卡片 */
|
||||
const handleCardClick = () => {
|
||||
if (batchMode) {
|
||||
onToggleSelect(product.id)
|
||||
} else {
|
||||
onPlay(product)
|
||||
}
|
||||
}
|
||||
|
||||
/** 点击复选框 */
|
||||
const handleCheckboxClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
onToggleSelect(product.id)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`xx-product-card${isSelected ? " selected" : ""}${product.isPublished ? " published" : ""}`}
|
||||
onClick={handleCardClick}
|
||||
>
|
||||
{/* 复选框(左上角) */}
|
||||
<div
|
||||
className={`xx-product-card-checkbox${isSelected ? " checked" : ""}`}
|
||||
onClick={handleCheckboxClick}
|
||||
title={isSelected ? "取消选择" : "选择"}
|
||||
>
|
||||
{isSelected && <CheckOutlined />}
|
||||
</div>
|
||||
|
||||
{/* 已发布徽章(右上角) */}
|
||||
{product.isPublished && <div className="xx-product-badge">✅ 已发布</div>}
|
||||
|
||||
{/* 复核状态标签(右上角) */}
|
||||
{product.reviewStatus && (
|
||||
<div
|
||||
className={`xx-product-review-tag ${reviewStatusConfig[product.reviewStatus].className}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onReviewStatusChange(product.id)
|
||||
}}
|
||||
title={`点击切换复核状态(当前:${reviewStatusConfig[product.reviewStatus].text})`}
|
||||
>
|
||||
{reviewStatusConfig[product.reviewStatus].text}
|
||||
</div>
|
||||
)}
|
||||
{/* 无复核状态时显示"待复核"入口 */}
|
||||
{!product.reviewStatus && (
|
||||
<div
|
||||
className="xx-product-review-tag review-pending"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onReviewStatusChange(product.id)
|
||||
}}
|
||||
title="点击设置复核状态"
|
||||
>
|
||||
待复核
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 缩略图 */}
|
||||
<div className="xx-product-thumb">
|
||||
{product.thumbnailUrl ? (
|
||||
<img
|
||||
className="xx-product-thumb-bg"
|
||||
src={product.thumbnailUrl}
|
||||
alt={product.name}
|
||||
style={{ width: "100%", height: "100%", objectFit: "cover" }}
|
||||
/>
|
||||
) : (
|
||||
<div className="xx-product-thumb-bg" style={{ background: product.gradient }} />
|
||||
)}
|
||||
<div className="xx-product-play">
|
||||
<PlayCircleOutlined />
|
||||
</div>
|
||||
{product.duration > 0 && (
|
||||
<span className="xx-product-duration">{formatTime(product.duration)}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 信息区 */}
|
||||
<div className="xx-product-info">
|
||||
<h4 className="xx-product-title" title={product.name}>
|
||||
{product.name}
|
||||
</h4>
|
||||
<div className="xx-product-meta">
|
||||
<span className={`xx-product-status ${st.className}`}>{st.text}</span>
|
||||
<span className="xx-product-date">{product.date}</span>
|
||||
</div>
|
||||
{product.duplicateRate > 0 && (
|
||||
<span className={`xx-product-dup-rate ${dupClass}`}>
|
||||
查重率:{product.duplicateRate.toFixed(1)}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className="xx-product-actions" onClick={(e) => e.stopPropagation()}>
|
||||
<button
|
||||
className="xx-product-action-btn"
|
||||
onClick={() => onDownload(product)}
|
||||
disabled={product.status !== "completed"}
|
||||
title="下载"
|
||||
>
|
||||
<DownloadOutlined /> 下载
|
||||
</button>
|
||||
<button
|
||||
className="xx-product-action-btn"
|
||||
onClick={() => onShare(product)}
|
||||
disabled={product.status !== "completed"}
|
||||
title="分享"
|
||||
>
|
||||
<ShareAltOutlined /> 分享
|
||||
</button>
|
||||
{product.isPublished ? (
|
||||
<span className="xx-product-action-btn success" style={{ cursor: "default" }}>
|
||||
✅ 已发布
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
className="xx-product-action-btn primary"
|
||||
onClick={() => onPublish(product)}
|
||||
disabled={product.status !== "completed"}
|
||||
title="发布"
|
||||
>
|
||||
<CloudUploadOutlined /> 发布
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 删除按钮(不在批量模式下显示) */}
|
||||
{!batchMode && (
|
||||
<div style={{ padding: "0 14px 14px" }} onClick={(e) => e.stopPropagation()}>
|
||||
<Popconfirm
|
||||
title={`确定删除"${product.name}"?`}
|
||||
onConfirm={() => onDelete(product.id)}
|
||||
okText="删除"
|
||||
cancelText="取消"
|
||||
>
|
||||
<button
|
||||
className="xx-product-action-btn"
|
||||
style={{ width: "100%", color: "#dc2626" }}
|
||||
title="删除"
|
||||
>
|
||||
<DeleteOutlined /> 删除
|
||||
</button>
|
||||
</Popconfirm>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
import React, { useRef, useState, useEffect, useCallback } from "react"
|
||||
import {
|
||||
VideoCameraOutlined,
|
||||
PlayCircleOutlined,
|
||||
PauseCircleOutlined,
|
||||
CloseOutlined,
|
||||
DownloadOutlined,
|
||||
ShareAltOutlined,
|
||||
EyeOutlined,
|
||||
} from "@ant-design/icons"
|
||||
import { Button } from "@/components/ui"
|
||||
import type { ProductItem } from "../types"
|
||||
import { formatTime, formatSize } from "../utils"
|
||||
|
||||
interface VideoPlayerProps {
|
||||
product: ProductItem
|
||||
onClose: () => void
|
||||
onDownload: (product: ProductItem) => void
|
||||
onShare: (product: ProductItem) => void
|
||||
onViewDetail: (product: ProductItem) => void
|
||||
}
|
||||
|
||||
export const VideoPlayer: React.FC<VideoPlayerProps> = ({
|
||||
product,
|
||||
onClose,
|
||||
onDownload,
|
||||
onShare,
|
||||
onViewDetail,
|
||||
}) => {
|
||||
const videoRef = useRef<HTMLVideoElement>(null)
|
||||
const progressRef = useRef<HTMLDivElement>(null)
|
||||
const [isPlaying, setIsPlaying] = useState(false)
|
||||
const [currentTime, setCurrentTime] = useState(0)
|
||||
const [duration, setDuration] = useState(product.duration)
|
||||
|
||||
const hasVideo = !!product.videoUrl
|
||||
|
||||
/** 播放/暂停 */
|
||||
const handlePlayPause = useCallback(() => {
|
||||
const video = videoRef.current
|
||||
if (!video) return
|
||||
if (isPlaying) {
|
||||
video.pause()
|
||||
} else {
|
||||
video.play().catch(() => {})
|
||||
}
|
||||
setIsPlaying(!isPlaying)
|
||||
}, [isPlaying])
|
||||
|
||||
/** 视频事件监听 */
|
||||
useEffect(() => {
|
||||
const video = videoRef.current
|
||||
if (!video) return
|
||||
const onTime = () => setCurrentTime(video.currentTime)
|
||||
const onDur = () => setDuration(video.duration || product.duration)
|
||||
const onEnd = () => setIsPlaying(false)
|
||||
video.addEventListener("timeupdate", onTime)
|
||||
video.addEventListener("loadedmetadata", onDur)
|
||||
video.addEventListener("ended", onEnd)
|
||||
return () => {
|
||||
video.removeEventListener("timeupdate", onTime)
|
||||
video.removeEventListener("loadedmetadata", onDur)
|
||||
video.removeEventListener("ended", onEnd)
|
||||
}
|
||||
}, [product.duration])
|
||||
|
||||
/** 进度条点击 */
|
||||
const handleProgressClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (!progressRef.current) return
|
||||
const rect = progressRef.current.getBoundingClientRect()
|
||||
const percent = (e.clientX - rect.left) / rect.width
|
||||
const newTime = percent * duration
|
||||
setCurrentTime(newTime)
|
||||
if (videoRef.current) videoRef.current.currentTime = newTime
|
||||
}
|
||||
|
||||
/** ESC 关闭 */
|
||||
useEffect(() => {
|
||||
const handleKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose()
|
||||
}
|
||||
window.addEventListener("keydown", handleKey)
|
||||
return () => window.removeEventListener("keydown", handleKey)
|
||||
}, [onClose])
|
||||
|
||||
const progress = duration > 0 ? (currentTime / duration) * 100 : 0
|
||||
|
||||
return (
|
||||
<div className="xx-player-overlay" onClick={onClose}>
|
||||
<div className="xx-player-container" onClick={(e) => e.stopPropagation()}>
|
||||
{/* 视频区域 */}
|
||||
<div className="xx-player-video-wrap">
|
||||
{hasVideo ? (
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={product.videoUrl}
|
||||
style={{ width: "100%", height: "100%", objectFit: "contain" }}
|
||||
/>
|
||||
) : (
|
||||
/* 无视频 URL 时用渐变占位 */
|
||||
<div
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
background: product.gradient,
|
||||
display: "grid",
|
||||
placeItems: "center",
|
||||
color: "rgba(255,255,255,0.3)",
|
||||
fontSize: "64px",
|
||||
}}
|
||||
>
|
||||
<VideoCameraOutlined />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 播放/暂停按钮 */}
|
||||
<button className="xx-player-play-btn" onClick={handlePlayPause}>
|
||||
{isPlaying ? <PauseCircleOutlined /> : <PlayCircleOutlined />}
|
||||
</button>
|
||||
|
||||
{/* 关闭按钮 */}
|
||||
<button className="xx-player-close" onClick={onClose}>
|
||||
<CloseOutlined />
|
||||
</button>
|
||||
|
||||
{/* 进度条 */}
|
||||
<div className="xx-player-progress-wrap">
|
||||
<div ref={progressRef} className="xx-player-progress" onClick={handleProgressClick}>
|
||||
<div className="xx-player-progress-bar" style={{ width: `${progress}%` }} />
|
||||
</div>
|
||||
<div className="xx-player-time">
|
||||
<span>{formatTime(currentTime)}</span>
|
||||
<span>{formatTime(duration)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 信息区 */}
|
||||
<div className="xx-player-info">
|
||||
<h3 className="xx-player-title">{product.name}</h3>
|
||||
<div className="xx-player-details">
|
||||
<div className="xx-player-detail-item">
|
||||
<span className="xx-player-detail-label">分辨率:</span>
|
||||
<span className="xx-player-detail-value">{product.resolution}</span>
|
||||
</div>
|
||||
<div className="xx-player-detail-item">
|
||||
<span className="xx-player-detail-label">时长:</span>
|
||||
<span className="xx-player-detail-value">{formatTime(product.duration)}</span>
|
||||
</div>
|
||||
<div className="xx-player-detail-item">
|
||||
<span className="xx-player-detail-label">文件大小:</span>
|
||||
<span className="xx-player-detail-value">{formatSize(product.fileSize)}</span>
|
||||
</div>
|
||||
<div className="xx-player-detail-item">
|
||||
<span className="xx-player-detail-label">查重率:</span>
|
||||
<span className="xx-player-detail-value">
|
||||
{product.duplicateRate > 0 ? `${product.duplicateRate.toFixed(1)}%` : "-"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 底部操作 */}
|
||||
<div className="xx-player-footer">
|
||||
<Button
|
||||
buttonType="ghost"
|
||||
buttonSize="sm"
|
||||
icon={<DownloadOutlined />}
|
||||
onClick={() => onDownload(product)}
|
||||
disabled={product.status !== "completed"}
|
||||
>
|
||||
下载视频
|
||||
</Button>
|
||||
<Button
|
||||
buttonType="ghost"
|
||||
buttonSize="sm"
|
||||
icon={<ShareAltOutlined />}
|
||||
onClick={() => onShare(product)}
|
||||
disabled={product.status !== "completed"}
|
||||
>
|
||||
分享
|
||||
</Button>
|
||||
<Button
|
||||
buttonType="ghost"
|
||||
buttonSize="sm"
|
||||
icon={<EyeOutlined />}
|
||||
onClick={() => onViewDetail(product)}
|
||||
>
|
||||
查看详情
|
||||
</Button>
|
||||
<div style={{ flex: 1 }} />
|
||||
<Button buttonType="primary" buttonSize="sm" onClick={onClose}>
|
||||
关闭
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { ReviewStatus } from "@/api/products"
|
||||
import type { ProductStatus } from "./types"
|
||||
|
||||
/** 渐变色列表(按 id hash 选取) */
|
||||
export const GRADIENTS = [
|
||||
"linear-gradient(135deg, #1e1b4b 0%, #4f46e5 50%, #818cf8 100%)",
|
||||
"linear-gradient(135deg, #831843 0%, #db2777 50%, #f472b6 100%)",
|
||||
"linear-gradient(135deg, #064e3b 0%, #059669 50%, #34d399 100%)",
|
||||
"linear-gradient(135deg, #78350f 0%, #d97706 50%, #fbbf24 100%)",
|
||||
"linear-gradient(135deg, #1e3a5f 0%, #2563eb 50%, #60a5fa 100%)",
|
||||
"linear-gradient(135deg, #581c87 0%, #9333ea 50%, #c084fc 100%)",
|
||||
"linear-gradient(135deg, #7f1d1d 0%, #dc2626 50%, #f87171 100%)",
|
||||
"linear-gradient(135deg, #134e4a 0%, #0d9488 50%, #5eead4 100%)",
|
||||
"linear-gradient(135deg, #1e1b4b 0%, #6366f1 50%, #a5b4fc 100%)",
|
||||
"linear-gradient(135deg, #3b0764 0%, #7c3aed 50%, #a78bfa 100%)",
|
||||
"linear-gradient(135deg, #052e16 0%, #16a34a 50%, #86efac 100%)",
|
||||
"linear-gradient(135deg, #450a0e 0%, #e11d48 50%, #fb7185 100%)",
|
||||
]
|
||||
|
||||
export const statusConfig: Record<ProductStatus, { text: string; className: string }> = {
|
||||
completed: { text: "已完成", className: "completed" },
|
||||
processing: { text: "处理中", className: "processing" },
|
||||
review: { text: "待复核", className: "review" },
|
||||
failed: { text: "失败", className: "failed" },
|
||||
}
|
||||
|
||||
export const reviewStatusConfig: Record<ReviewStatus, { text: string; className: string }> = {
|
||||
pending_review: { text: "待复核", className: "review-pending" },
|
||||
approved: { text: "已通过", className: "review-approved" },
|
||||
rejected: { text: "需修改", className: "review-rejected" },
|
||||
}
|
||||
|
||||
/** 复核状态循环顺序 */
|
||||
export const REVIEW_STATUS_CYCLE: ReviewStatus[] = ["pending_review", "approved", "rejected"]
|
||||
@@ -0,0 +1,187 @@
|
||||
import { useState } from "react"
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
import { useNavigate } from "react-router-dom"
|
||||
import { message } from "antd"
|
||||
import {
|
||||
deleteProduct,
|
||||
getProductDownloadUrl,
|
||||
updateReviewStatus,
|
||||
batchDownload,
|
||||
getBatchDownloadStatus,
|
||||
type ReviewStatus,
|
||||
} from "@/api/products"
|
||||
import type { ProductItem } from "../types"
|
||||
import { getNextReviewStatus } from "../utils"
|
||||
|
||||
interface UseProductActionsOptions {
|
||||
selectedIds: Set<string>
|
||||
clearSelection: () => void
|
||||
products: ProductItem[]
|
||||
setPlayingProduct: (product: ProductItem | null) => void
|
||||
}
|
||||
|
||||
export const useProductActions = ({
|
||||
selectedIds,
|
||||
clearSelection,
|
||||
products,
|
||||
setPlayingProduct,
|
||||
}: UseProductActionsOptions) => {
|
||||
const queryClient = useQueryClient()
|
||||
const navigate = useNavigate()
|
||||
|
||||
/* ── 删除 mutation ── */
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: deleteProduct,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["products"] })
|
||||
message.success("已删除")
|
||||
},
|
||||
onError: () => {
|
||||
message.error("删除失败")
|
||||
},
|
||||
})
|
||||
|
||||
/* ── 复核状态 mutation ── */
|
||||
const reviewMutation = useMutation({
|
||||
mutationFn: ({ id, status }: { id: string; status: ReviewStatus }) =>
|
||||
updateReviewStatus(id, status),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["products"] })
|
||||
message.success("复核状态已更新")
|
||||
},
|
||||
onError: () => {
|
||||
message.error("更新复核状态失败")
|
||||
},
|
||||
})
|
||||
|
||||
/* 下载 — 调用真实 API 获取下载链接 */
|
||||
const handleDownload = async (product: ProductItem) => {
|
||||
if (product.status !== "completed") return
|
||||
try {
|
||||
const { url } = await getProductDownloadUrl(product.id)
|
||||
const a = document.createElement("a")
|
||||
a.href = url
|
||||
a.download = ""
|
||||
a.click()
|
||||
message.success(`正在下载"${product.name}"`)
|
||||
} catch {
|
||||
message.error("获取下载链接失败")
|
||||
}
|
||||
}
|
||||
|
||||
/* 分享 */
|
||||
const handleShare = (product: ProductItem) => {
|
||||
if (product.status !== "completed") return
|
||||
const link = `${window.location.origin}/share/${product.id}`
|
||||
navigator.clipboard?.writeText(link).then(
|
||||
() => message.success(`分享链接已复制:${link}`),
|
||||
() => message.success(`分享链接:${link}(请手动复制)`),
|
||||
)
|
||||
}
|
||||
|
||||
/* 查看详情 — 跳转到产品详情页 */
|
||||
const handleViewDetail = (product: ProductItem) => {
|
||||
setPlayingProduct(null)
|
||||
navigate(`/app/products/${product.id}`)
|
||||
}
|
||||
|
||||
/* 删除 */
|
||||
const handleDelete = (id: string) => {
|
||||
deleteMutation.mutate(id)
|
||||
}
|
||||
|
||||
/* 发布 — TODO: 后端发布 API 待实现 */
|
||||
const handlePublish = (_product: ProductItem) => {
|
||||
message.info("发布功能待后端 API 补齐")
|
||||
}
|
||||
|
||||
/* 切换复核状态 */
|
||||
const handleReviewStatusChange = (id: string) => {
|
||||
const current = products.find((p) => p.id === id)?.reviewStatus
|
||||
const nextStatus = getNextReviewStatus(current)
|
||||
reviewMutation.mutate({ id, status: nextStatus })
|
||||
}
|
||||
|
||||
/* 批量下载 */
|
||||
const [batchDownloading, setBatchDownloading] = useState(false)
|
||||
|
||||
const handleBatchDownload = async () => {
|
||||
const ids = Array.from(selectedIds)
|
||||
if (ids.length === 0) return
|
||||
setBatchDownloading(true)
|
||||
try {
|
||||
const { job_id } = await batchDownload(ids)
|
||||
message.info(`批量下载任务已创建,正在打包 ${ids.length} 个视频...`)
|
||||
|
||||
let attempts = 0
|
||||
const maxAttempts = 60
|
||||
const poll = async (): Promise<void> => {
|
||||
if (attempts >= maxAttempts) {
|
||||
message.warning("打包超时,请稍后在消息中心查看")
|
||||
return
|
||||
}
|
||||
attempts++
|
||||
await new Promise((r) => setTimeout(r, 2000))
|
||||
const status = await getBatchDownloadStatus(job_id)
|
||||
if (status.status === "completed" && status.download_url) {
|
||||
const a = document.createElement("a")
|
||||
a.href = status.download_url
|
||||
a.download = ""
|
||||
a.click()
|
||||
message.success(`已打包下载 ${ids.length} 个视频`)
|
||||
clearSelection()
|
||||
} else if (status.status === "failed") {
|
||||
message.error("批量下载失败,请重试")
|
||||
} else {
|
||||
await poll()
|
||||
}
|
||||
}
|
||||
await poll()
|
||||
} catch {
|
||||
message.error("发起批量下载失败")
|
||||
} finally {
|
||||
setBatchDownloading(false)
|
||||
}
|
||||
}
|
||||
|
||||
/* 批量删除 */
|
||||
const handleBatchDelete = async () => {
|
||||
const ids = Array.from(selectedIds)
|
||||
let successCount = 0
|
||||
for (const id of ids) {
|
||||
try {
|
||||
await deleteProduct(id)
|
||||
successCount++
|
||||
} catch {
|
||||
// 忽略单个失败
|
||||
}
|
||||
}
|
||||
queryClient.invalidateQueries({ queryKey: ["products"] })
|
||||
clearSelection()
|
||||
message.success(`已批量删除 ${successCount}/${ids.length} 个视频`)
|
||||
}
|
||||
|
||||
/* 批量发布 */
|
||||
const handleBatchPublish = () => {
|
||||
message.info("批量发布功能待后端 API 补齐")
|
||||
clearSelection()
|
||||
}
|
||||
|
||||
return {
|
||||
// 单个操作
|
||||
handleDownload,
|
||||
handleShare,
|
||||
handleViewDetail,
|
||||
handleDelete,
|
||||
handlePublish,
|
||||
handleReviewStatusChange,
|
||||
// 批量操作
|
||||
handleBatchDownload,
|
||||
handleBatchDelete,
|
||||
handleBatchPublish,
|
||||
batchDownloading,
|
||||
// mutation 状态
|
||||
isDeleting: deleteMutation.isPending,
|
||||
isUpdatingReview: reviewMutation.isPending,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
import { useMemo, useState } from "react"
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { getProducts, type ProductItem as ApiProductItem } from "@/api/products"
|
||||
import type { ProductItem } from "../types"
|
||||
import { mapApiProduct } from "../utils"
|
||||
|
||||
/** 筛选选项类型 */
|
||||
export interface Filters {
|
||||
searchText: string
|
||||
filterStatus: string
|
||||
filterTime: string
|
||||
filterDuration: string
|
||||
filterProject: string
|
||||
filterReviewStatus: string
|
||||
}
|
||||
|
||||
/** 项目选项列表 */
|
||||
const getProjectOptions = (products: ProductItem[]) =>
|
||||
Array.from(
|
||||
new Map(
|
||||
products
|
||||
.filter((p) => p.projectId && p.projectName)
|
||||
.map((p) => [p.projectId!, p.projectName!] as const),
|
||||
),
|
||||
).map(([id, name]) => ({
|
||||
value: id as string,
|
||||
label: name as string,
|
||||
}))
|
||||
|
||||
export const useProductList = () => {
|
||||
/* ── 获取成品列表 ── */
|
||||
const {
|
||||
data: apiProducts = [],
|
||||
isLoading,
|
||||
isError,
|
||||
error,
|
||||
refetch,
|
||||
} = useQuery<ApiProductItem[], Error>({
|
||||
queryKey: ["products"],
|
||||
queryFn: () => getProducts(),
|
||||
staleTime: 30_000,
|
||||
})
|
||||
|
||||
// 映射为前端类型,按创建时间倒序排列,防御非数组返回
|
||||
const products = useMemo(() => {
|
||||
const list = (Array.isArray(apiProducts) ? apiProducts : []).map(mapApiProduct)
|
||||
// 按创建时间倒序(最新的在最前面),无时间的排最后
|
||||
return list.sort((a, b) => {
|
||||
if (!a.date || a.date === "—") return 1
|
||||
if (!b.date || b.date === "—") return -1
|
||||
return new Date(b.date).getTime() - new Date(a.date).getTime()
|
||||
})
|
||||
}, [apiProducts])
|
||||
|
||||
/* 筛选 */
|
||||
const [searchText, setSearchText] = useState("")
|
||||
const [filterStatus, setFilterStatus] = useState<string>("all")
|
||||
const [filterTime, setFilterTime] = useState<string>("all")
|
||||
const [filterDuration, setFilterDuration] = useState<string>("all")
|
||||
const [filterProject, setFilterProject] = useState<string>("all")
|
||||
const [filterReviewStatus, setFilterReviewStatus] = useState<string>("all")
|
||||
|
||||
/* 批量选择 */
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
|
||||
|
||||
/* 派生数据 */
|
||||
const batchMode = selectedIds.size > 0
|
||||
|
||||
const filteredProducts = useMemo(() => {
|
||||
let list = products
|
||||
|
||||
/* 状态筛选 */
|
||||
if (filterStatus !== "all") {
|
||||
list = list.filter((p) => p.status === filterStatus)
|
||||
}
|
||||
|
||||
/* 时间筛选 */
|
||||
if (filterTime !== "all") {
|
||||
const now = new Date()
|
||||
list = list.filter((p) => {
|
||||
const d = new Date(p.date)
|
||||
const diff = (now.getTime() - d.getTime()) / (1000 * 60 * 60 * 24)
|
||||
switch (filterTime) {
|
||||
case "today":
|
||||
return diff < 1
|
||||
case "week":
|
||||
return diff <= 7
|
||||
case "month":
|
||||
return diff <= 30
|
||||
default:
|
||||
return true
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/* 时长筛选 */
|
||||
if (filterDuration !== "all") {
|
||||
list = list.filter((p) => {
|
||||
switch (filterDuration) {
|
||||
case "short":
|
||||
return p.duration > 0 && p.duration <= 60
|
||||
case "medium":
|
||||
return p.duration > 60 && p.duration <= 180
|
||||
case "long":
|
||||
return p.duration > 180
|
||||
default:
|
||||
return true
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/* 项目筛选 */
|
||||
if (filterProject !== "all") {
|
||||
list = list.filter((p) => p.projectId === filterProject)
|
||||
}
|
||||
|
||||
/* 复核状态筛选 */
|
||||
if (filterReviewStatus !== "all") {
|
||||
if (filterReviewStatus === "none") {
|
||||
list = list.filter((p) => !p.reviewStatus)
|
||||
} else {
|
||||
list = list.filter((p) => p.reviewStatus === filterReviewStatus)
|
||||
}
|
||||
}
|
||||
|
||||
/* 搜索 */
|
||||
if (searchText.trim()) {
|
||||
const q = searchText.trim().toLowerCase()
|
||||
list = list.filter((p) => p.name.toLowerCase().includes(q))
|
||||
}
|
||||
|
||||
return list
|
||||
}, [
|
||||
products,
|
||||
filterStatus,
|
||||
filterTime,
|
||||
filterDuration,
|
||||
filterProject,
|
||||
filterReviewStatus,
|
||||
searchText,
|
||||
])
|
||||
|
||||
const projectOptions = useMemo(() => getProjectOptions(products), [products])
|
||||
|
||||
/* 全选 */
|
||||
const allSelected =
|
||||
filteredProducts.length > 0 && filteredProducts.every((p) => selectedIds.has(p.id))
|
||||
|
||||
const handleSelectAll = () => {
|
||||
if (allSelected) {
|
||||
setSelectedIds(new Set())
|
||||
} else {
|
||||
setSelectedIds(new Set(filteredProducts.map((p) => p.id)))
|
||||
}
|
||||
}
|
||||
|
||||
/* 切换单个选择 */
|
||||
const handleToggleSelect = (id: string) => {
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(id)) {
|
||||
next.delete(id)
|
||||
} else {
|
||||
next.add(id)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const clearSelection = () => setSelectedIds(new Set())
|
||||
|
||||
return {
|
||||
// 数据
|
||||
products,
|
||||
filteredProducts,
|
||||
isLoading,
|
||||
isError,
|
||||
error,
|
||||
refetch,
|
||||
// 筛选
|
||||
searchText,
|
||||
setSearchText,
|
||||
filterStatus,
|
||||
setFilterStatus,
|
||||
filterTime,
|
||||
setFilterTime,
|
||||
filterDuration,
|
||||
setFilterDuration,
|
||||
filterProject,
|
||||
setFilterProject,
|
||||
filterReviewStatus,
|
||||
setFilterReviewStatus,
|
||||
projectOptions,
|
||||
// 批量选择
|
||||
selectedIds,
|
||||
batchMode,
|
||||
allSelected,
|
||||
handleSelectAll,
|
||||
handleToggleSelect,
|
||||
clearSelection,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { ReviewStatus } from "@/api/products"
|
||||
|
||||
export type ProductStatus = "completed" | "processing" | "review" | "failed"
|
||||
|
||||
export interface ProductItem {
|
||||
id: string
|
||||
name: string
|
||||
status: ProductStatus
|
||||
duration: number // 秒
|
||||
date: string
|
||||
gradient: string
|
||||
duplicateRate: number // 查重率百分比
|
||||
isPublished: boolean
|
||||
resolution: string
|
||||
fileSize: number // MB
|
||||
videoUrl?: string
|
||||
thumbnailUrl?: string
|
||||
/** 复核状态 */
|
||||
reviewStatus?: ReviewStatus
|
||||
/** 所属项目 ID */
|
||||
projectId?: string
|
||||
/** 所属项目名称 */
|
||||
projectName?: string
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import type { ProductItem as ApiProductItem } from "@/api/products"
|
||||
import type { ProductItem, ProductStatus } from "../types"
|
||||
import { GRADIENTS, REVIEW_STATUS_CYCLE } from "../constants"
|
||||
import type { ReviewStatus } from "@/api/products"
|
||||
|
||||
/** 格式化时间 mm:ss */
|
||||
export const formatTime = (seconds: number): string => {
|
||||
const mins = Math.floor(seconds / 60)
|
||||
const secs = Math.floor(seconds % 60)
|
||||
return `${mins.toString().padStart(2, "0")}:${secs.toString().padStart(2, "0")}`
|
||||
}
|
||||
|
||||
/** 格式化文件大小 */
|
||||
export const formatSize = (mb: number): string => {
|
||||
if (mb <= 0) return "-"
|
||||
if (mb < 1) return `${(mb * 1024).toFixed(0)} KB`
|
||||
return `${mb.toFixed(1)} MB`
|
||||
}
|
||||
|
||||
/** 格式化日期时间(MM-DD HH:mm) */
|
||||
export const formatDateTime = (isoString: string): string => {
|
||||
const d = new Date(isoString)
|
||||
const month = (d.getMonth() + 1).toString().padStart(2, "0")
|
||||
const day = d.getDate().toString().padStart(2, "0")
|
||||
const hour = d.getHours().toString().padStart(2, "0")
|
||||
const minute = d.getMinutes().toString().padStart(2, "0")
|
||||
return `${month}-${day} ${hour}:${minute}`
|
||||
}
|
||||
|
||||
/** 将后端 status 映射为前端 ProductStatus */
|
||||
export const normalizeStatus = (s: string): ProductStatus => {
|
||||
const map: Record<string, ProductStatus> = {
|
||||
completed: "completed",
|
||||
succeeded: "completed",
|
||||
processing: "processing",
|
||||
running: "processing",
|
||||
review: "review",
|
||||
failed: "failed",
|
||||
error: "failed",
|
||||
}
|
||||
return map[s] ?? "processing"
|
||||
}
|
||||
|
||||
/** 根据 id 生成稳定的渐变色 */
|
||||
export const gradientForId = (id: string): string => {
|
||||
let hash = 0
|
||||
for (let i = 0; i < id.length; i++) hash = (hash * 31 + id.charCodeAt(i)) | 0
|
||||
return GRADIENTS[Math.abs(hash) % GRADIENTS.length]
|
||||
}
|
||||
|
||||
/** 将后端 ProductItem 映射为前端 ProductItem */
|
||||
export const mapApiProduct = (item: ApiProductItem): ProductItem => ({
|
||||
id: item.id,
|
||||
name: item.title,
|
||||
status: normalizeStatus(item.status),
|
||||
duration: item.duration_seconds ?? 0,
|
||||
date: item.created_at ? formatDateTime(item.created_at) : "—",
|
||||
gradient: gradientForId(item.id),
|
||||
duplicateRate: item.duplicate_rate ?? 0,
|
||||
isPublished: false, // TODO: 后端发布状态字段待补齐
|
||||
resolution: item.resolution ?? "1080×1920",
|
||||
fileSize: item.file_size ? +(item.file_size / (1024 * 1024)).toFixed(1) : 0,
|
||||
videoUrl: item.video_url,
|
||||
thumbnailUrl: item.thumbnail_url,
|
||||
reviewStatus: item.review_status,
|
||||
projectId: item.project_id,
|
||||
projectName: item.project_name,
|
||||
})
|
||||
|
||||
/** 获取下一个复核状态 */
|
||||
export const getNextReviewStatus = (current?: ReviewStatus): ReviewStatus => {
|
||||
if (!current) return "approved"
|
||||
const idx = REVIEW_STATUS_CYCLE.indexOf(current)
|
||||
return REVIEW_STATUS_CYCLE[(idx + 1) % REVIEW_STATUS_CYCLE.length]
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* EditingPlanner 模块 smoke test
|
||||
* 建立完整依赖链,确保 vitest related 模式能匹配到
|
||||
* editing-planner 目录下所有文件的改动(包括子组件、Hook 和工具函数)
|
||||
*/
|
||||
import { describe, it, expect } from "vitest"
|
||||
|
||||
// 主组件
|
||||
import "@/pages/editing-planner/EditingPlanner"
|
||||
|
||||
// 常量
|
||||
import "@/pages/editing-planner/constants"
|
||||
import "@/pages/editing-planner/constants/timeline"
|
||||
import "@/pages/editing-planner/constants/clipProperties"
|
||||
import "@/pages/editing-planner/constants/pipConfig"
|
||||
import "@/pages/editing-planner/constants/sticker"
|
||||
import "@/pages/editing-planner/constants/filter"
|
||||
import "@/pages/editing-planner/constants/introOutro"
|
||||
import "@/pages/editing-planner/constants/subtitleStyle"
|
||||
import "@/pages/editing-planner/constants/tts"
|
||||
import "@/pages/editing-planner/constants/watermark"
|
||||
|
||||
// 工具函数
|
||||
import "@/pages/editing-planner/utils/selectors"
|
||||
import "@/pages/editing-planner/utils/timeline"
|
||||
import "@/pages/editing-planner/utils/clipProperties"
|
||||
|
||||
// 子组件
|
||||
import "@/pages/editing-planner/components/BgmSelector"
|
||||
import "@/pages/editing-planner/components/ClipPropertiesPanel"
|
||||
import "@/pages/editing-planner/components/CoverSelector"
|
||||
import "@/pages/editing-planner/components/EditorClipList"
|
||||
import "@/pages/editing-planner/components/EditingDrawers"
|
||||
import "@/pages/editing-planner/components/FilterPanel"
|
||||
import "@/pages/editing-planner/components/GenerationHistoryModal"
|
||||
import "@/pages/editing-planner/components/GenerationProgressModal"
|
||||
import "@/pages/editing-planner/components/GreenScreenPanel"
|
||||
import "@/pages/editing-planner/components/IntroOutroPanel"
|
||||
import "@/pages/editing-planner/components/MediaPanel"
|
||||
import "@/pages/editing-planner/components/ModeBar"
|
||||
import "@/pages/editing-planner/components/PipConfigPanel"
|
||||
import "@/pages/editing-planner/components/PreviewPlayer"
|
||||
import "@/pages/editing-planner/components/RightPanel"
|
||||
import "@/pages/editing-planner/components/SaveModal"
|
||||
import "@/pages/editing-planner/components/SpeedPanel"
|
||||
import "@/pages/editing-planner/components/StatusBar"
|
||||
import "@/pages/editing-planner/components/StickerPanel"
|
||||
import "@/pages/editing-planner/components/SubtitleStylePanel"
|
||||
import "@/pages/editing-planner/components/TimelinePanel"
|
||||
import "@/pages/editing-planner/components/timeline/ClipCard"
|
||||
import "@/pages/editing-planner/components/timeline/TimeRuler"
|
||||
import "@/pages/editing-planner/components/timeline/AddClipPicker"
|
||||
import "@/pages/editing-planner/components/timeline/TrimPreview"
|
||||
import "@/pages/editing-planner/components/timeline/ContextMenu"
|
||||
import "@/pages/editing-planner/components/clip-properties/SubtitleSettingsSection"
|
||||
import "@/pages/editing-planner/components/clip-properties/BgmSettingsSection"
|
||||
import "@/pages/editing-planner/components/clip-properties/ClipDetailSection"
|
||||
import "@/pages/editing-planner/components/clip-properties/StatsSection"
|
||||
import "@/pages/editing-planner/components/pip-config/LayerList"
|
||||
import "@/pages/editing-planner/components/pip-config/LayerConfig"
|
||||
import "@/pages/editing-planner/components/sticker/StickerLibrary"
|
||||
import "@/pages/editing-planner/components/sticker/StickerList"
|
||||
import "@/pages/editing-planner/components/sticker/StickerPropsEditor"
|
||||
import "@/pages/editing-planner/components/filter/FilterPresetGrid"
|
||||
import "@/pages/editing-planner/components/filter/FilterManualAdjust"
|
||||
import "@/pages/editing-planner/components/intro-outro/IntroOutroBlock"
|
||||
import "@/pages/editing-planner/components/subtitle-style/SubtitlePreview"
|
||||
import "@/pages/editing-planner/components/tts/VoiceSelector"
|
||||
import "@/pages/editing-planner/components/tts/TtsSlider"
|
||||
import "@/pages/editing-planner/components/watermark/WatermarkTypeTabs"
|
||||
import "@/pages/editing-planner/components/watermark/ImageWatermarkSection"
|
||||
import "@/pages/editing-planner/components/watermark/TextWatermarkSection"
|
||||
import "@/pages/editing-planner/components/watermark/ScrollWatermarkSection"
|
||||
import "@/pages/editing-planner/components/watermark/WatermarkCommonSection"
|
||||
import "@/pages/editing-planner/components/TopBar"
|
||||
import "@/pages/editing-planner/components/TransitionSelector"
|
||||
import "@/pages/editing-planner/components/TtsPanel"
|
||||
import "@/pages/editing-planner/components/WatermarkPanel"
|
||||
|
||||
// 类型
|
||||
import "@/pages/editing-planner/types"
|
||||
import "@/pages/editing-planner/types/subtitle"
|
||||
import "@/pages/editing-planner/types/clipProperties"
|
||||
|
||||
// Hooks
|
||||
import "@/pages/editing-planner/hooks/useUndoRedo"
|
||||
import "@/pages/editing-planner/hooks/useEditPlanClips"
|
||||
import "@/pages/editing-planner/hooks/useEditorDrawers"
|
||||
import "@/pages/editing-planner/hooks/usePlaybackControl"
|
||||
import "@/pages/editing-planner/hooks/useClipOperations"
|
||||
import "@/pages/editing-planner/hooks/useTemplateManagement"
|
||||
import "@/pages/editing-planner/hooks/useVoicePreview"
|
||||
import "@/pages/editing-planner/hooks/usePipLayers"
|
||||
import "@/pages/editing-planner/hooks/useStickerItems"
|
||||
import "@/pages/editing-planner/hooks/useTtsPanel"
|
||||
import "@/pages/editing-planner/hooks/useWatermarkConfig"
|
||||
|
||||
describe("EditingPlanner module smoke test", () => {
|
||||
it("should load all editing-planner modules", () => {
|
||||
// 纯模块加载测试,确保所有组件/工具函数能正常 import
|
||||
expect(true).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* ProductLibrary 模块 smoke test
|
||||
* 建立完整依赖链,确保 vitest related 模式能匹配到
|
||||
* products 目录下所有文件的改动(包括子组件、Hook 和工具函数)
|
||||
*/
|
||||
import { describe, it, expect } from "vitest"
|
||||
|
||||
// 主组件
|
||||
import "@/pages/products/ProductLibrary"
|
||||
|
||||
// 类型与常量
|
||||
import "@/pages/products/types"
|
||||
import "@/pages/products/constants"
|
||||
|
||||
// 工具函数
|
||||
import "@/pages/products/utils/index"
|
||||
|
||||
// 子组件
|
||||
import "@/pages/products/components/ProductCard"
|
||||
import "@/pages/products/components/VideoPlayer"
|
||||
|
||||
// Hooks
|
||||
import "@/pages/products/hooks/useProductList"
|
||||
import "@/pages/products/hooks/useProductActions"
|
||||
|
||||
describe("ProductLibrary module smoke test", () => {
|
||||
it("should load all product modules", () => {
|
||||
// 纯模块加载测试,确保所有组件/工具函数能正常 import
|
||||
expect(true).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -52,6 +52,15 @@ from video_processing.trim_engine import TrimConfig, TrimEngine, extract_trim_fr
|
||||
from video_processing.tts_engine import TtsEngine
|
||||
from video_processing.watermark_engine import WatermarkConfig, WatermarkEngine
|
||||
|
||||
from packages.domain.render_layer_utils import (
|
||||
LAYER_Z_INDEX as _IMPORTED_LAYER_Z_INDEX,
|
||||
can_pass_through as _can_pass_through_pure,
|
||||
clip_adjusted_duration as _clip_adjusted_duration_pure,
|
||||
clip_effective_duration as _clip_effective_duration_pure,
|
||||
clip_playback_speed as _clip_playback_speed_pure,
|
||||
estimate_total_duration as _estimate_total_duration_pure,
|
||||
resolve_layer_role as _resolve_layer_role_pure,
|
||||
)
|
||||
from packages.domain.tts_config import TtsConfig
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -107,47 +116,16 @@ class RenderResult:
|
||||
|
||||
|
||||
def _resolve_layer_role(clip_type: str, config: dict[str, Any]) -> str:
|
||||
"""根据 clip_type 和 config.role 确定图层角色。
|
||||
"""根据 clip_type 和 config.role 确定图层角色(向后兼容别名)。
|
||||
|
||||
映射规则:
|
||||
intro / outro → "main"(按 order 排在首/尾)
|
||||
overlay → "overlay"(画中画叠加,z=1)
|
||||
corner_voice → "corner_voice"(右上角小窗,z=1)
|
||||
background → "background"(全屏底图,z=0)
|
||||
b_roll → "broll"(z=0)
|
||||
main + config.role=b_roll → "broll"
|
||||
main (default) → "main"
|
||||
实际实现移至 packages.domain.render_layer_utils.resolve_layer_role。
|
||||
"""
|
||||
role = config.get("role", "")
|
||||
|
||||
if clip_type in ("intro", "outro"):
|
||||
return "main"
|
||||
if clip_type == "overlay":
|
||||
return "overlay"
|
||||
if clip_type == "corner_voice":
|
||||
return "corner_voice"
|
||||
if clip_type == "background":
|
||||
return "background"
|
||||
if clip_type == "b_roll":
|
||||
return "broll"
|
||||
# main type
|
||||
if role == "b_roll":
|
||||
return "broll"
|
||||
if role == "audio":
|
||||
return "audio"
|
||||
return "main"
|
||||
return _resolve_layer_role_pure(clip_type, config)
|
||||
|
||||
|
||||
# ── 图层默认 z_index ─────────────────────────────────────────────────────────
|
||||
|
||||
_LAYER_Z_INDEX: dict[str, int] = {
|
||||
"background": -1,
|
||||
"broll": 0,
|
||||
"main": 0,
|
||||
"overlay": 1,
|
||||
"corner_voice": 1,
|
||||
"audio": 2,
|
||||
}
|
||||
_LAYER_Z_INDEX: dict[str, int] = _IMPORTED_LAYER_Z_INDEX
|
||||
|
||||
# 图层默认 PiP 位置(相对输出画布的偏移)
|
||||
_PIP_SCALE = 0.25 # PiP 占主画面的比例
|
||||
@@ -489,29 +467,9 @@ class UnifiedRenderService:
|
||||
def _estimate_total_duration(self, layers: list[RenderLayer]) -> float:
|
||||
"""估算视频总时长(用于字幕等需要)。
|
||||
|
||||
取主图层(main/broll/background)的总时长,转场重叠按 transition_duration 估算。
|
||||
实际实现移至 packages.domain.render_layer_utils.estimate_total_duration。
|
||||
"""
|
||||
# 找主图层(第一个有视频内容的图层)
|
||||
main_layer = None
|
||||
for role in ("main", "broll", "background"):
|
||||
for layer in layers:
|
||||
if layer.role == role:
|
||||
main_layer = layer
|
||||
break
|
||||
if main_layer:
|
||||
break
|
||||
|
||||
if not main_layer or not main_layer.clips:
|
||||
return 0.0
|
||||
|
||||
total = sum(UnifiedRenderService._clip_adjusted_duration(c) for c in main_layer.clips)
|
||||
|
||||
# 减去转场重叠时间(粗略估算)
|
||||
n_clips = len(main_layer.clips)
|
||||
if n_clips > 1:
|
||||
total -= (n_clips - 1) * self.transition_duration
|
||||
|
||||
return max(0.1, total)
|
||||
return _estimate_total_duration_pure(layers, self.transition_duration)
|
||||
|
||||
def _maybe_generate_ass(self, video_duration: float) -> Path | None:
|
||||
"""根据 plan.config 生成 ASS 字幕文件。
|
||||
@@ -1869,10 +1827,11 @@ class UnifiedRenderService:
|
||||
|
||||
@staticmethod
|
||||
def _clip_effective_duration(clip: ResolvedClip) -> float:
|
||||
"""计算 clip 的有效时长(原速 trim 后时长)."""
|
||||
if clip.duration > 0:
|
||||
return min(clip.duration, clip.actual_duration) if clip.actual_duration > 0 else clip.duration
|
||||
return clip.actual_duration if clip.actual_duration > 0 else 0.0
|
||||
"""计算 clip 的有效时长(原速 trim 后时长)。
|
||||
|
||||
实际实现移至 packages.domain.render_layer_utils.clip_effective_duration。
|
||||
"""
|
||||
return _clip_effective_duration_pure(clip.duration, clip.actual_duration)
|
||||
|
||||
# ── 画中画(PiP)相关方法 ──────────────────────────────────────────────────
|
||||
|
||||
@@ -1969,17 +1928,20 @@ class UnifiedRenderService:
|
||||
|
||||
@staticmethod
|
||||
def _clip_speed(clip: ResolvedClip) -> float:
|
||||
"""获取 clip 的播放速度,无效值回退到 1.0."""
|
||||
speed = getattr(clip, "playback_speed", 1.0)
|
||||
if not isinstance(speed, (int, float)) or speed <= 0:
|
||||
return 1.0
|
||||
return float(speed)
|
||||
"""获取 clip 的播放速度,无效值回退到 1.0。
|
||||
|
||||
实际实现移至 packages.domain.render_layer_utils.clip_playback_speed。
|
||||
"""
|
||||
return _clip_playback_speed_pure(getattr(clip, "playback_speed", 1.0))
|
||||
|
||||
@staticmethod
|
||||
def _clip_adjusted_duration(clip: ResolvedClip) -> float:
|
||||
"""计算调速后的 clip 实际时长(用于拼接计算)."""
|
||||
base = UnifiedRenderService._clip_effective_duration(clip)
|
||||
speed = UnifiedRenderService._clip_speed(clip)
|
||||
if abs(speed - 1.0) < 1e-6:
|
||||
return base
|
||||
return base / speed
|
||||
"""计算调速后的 clip 实际时长(用于拼接计算)。
|
||||
|
||||
实际实现移至 packages.domain.render_layer_utils.clip_adjusted_duration。
|
||||
"""
|
||||
return _clip_adjusted_duration_pure(
|
||||
clip.duration,
|
||||
clip.actual_duration,
|
||||
getattr(clip, "playback_speed", 1.0),
|
||||
)
|
||||
|
||||
@@ -19,74 +19,21 @@ from PIL import Image
|
||||
|
||||
from packages.domain.classification import AssetClassification
|
||||
|
||||
from .asset_quality_scoring import (
|
||||
AudioAnalysis,
|
||||
ClassificationResult,
|
||||
ColorAnalysis,
|
||||
MotionAnalysis,
|
||||
QualityScore,
|
||||
VideoInfo,
|
||||
calculate_category_scores,
|
||||
calculate_quality_score,
|
||||
classify_from_analysis,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class VideoInfo:
|
||||
"""视频基本信息"""
|
||||
|
||||
width: int = 0
|
||||
height: int = 0
|
||||
fps: float = 0.0
|
||||
duration: float = 0.0
|
||||
bitrate: int = 0
|
||||
codec: str = ""
|
||||
has_audio: bool = False
|
||||
file_size: int = 0
|
||||
|
||||
|
||||
@dataclass
|
||||
class ColorAnalysis:
|
||||
"""色彩分析结果"""
|
||||
|
||||
dominant_hue: float = 0.0 # 主色调 (0-360)
|
||||
green_ratio: float = 0.0 # 绿色占比
|
||||
warm_ratio: float = 0.0 # 暖色调占比
|
||||
cool_ratio: float = 0.0 # 冷色调占比
|
||||
avg_saturation: float = 0.0
|
||||
avg_brightness: float = 0.0
|
||||
|
||||
|
||||
@dataclass
|
||||
class MotionAnalysis:
|
||||
"""运动分析结果"""
|
||||
|
||||
motion_score: float = 0.0 # 运动幅度 (0-1)
|
||||
scene_changes: int = 0 # 场景切换次数
|
||||
|
||||
|
||||
@dataclass
|
||||
class AudioAnalysis:
|
||||
"""音频分析结果"""
|
||||
|
||||
has_audio: bool = False
|
||||
speech_ratio: float = 0.0 # 人声比例
|
||||
music_ratio: float = 0.0 # 音乐比例
|
||||
ambient_ratio: float = 0.0 # 环境音比例
|
||||
|
||||
|
||||
@dataclass
|
||||
class ClassificationResult:
|
||||
"""分类结果"""
|
||||
|
||||
category: AssetClassification
|
||||
confidence: float
|
||||
scores: dict[str, float] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass
|
||||
class QualityScore:
|
||||
"""质量评分结果"""
|
||||
|
||||
total: float
|
||||
resolution_score: float = 0.0
|
||||
fps_score: float = 0.0
|
||||
bitrate_score: float = 0.0
|
||||
clarity_score: float = 0.0
|
||||
stability_score: float = 0.0
|
||||
|
||||
|
||||
class AssetAnalyzer:
|
||||
"""
|
||||
轻量级视频素材分析器
|
||||
@@ -449,316 +396,37 @@ class AssetAnalyzer:
|
||||
"""
|
||||
综合分析得出分类结果
|
||||
|
||||
评分逻辑在 asset_quality_scoring.calculate_category_scores / classify_from_analysis
|
||||
纯函数中,此处只负责采集分析数据后委托计算。
|
||||
|
||||
Returns:
|
||||
ClassificationResult 对象
|
||||
"""
|
||||
# 提取分析数据
|
||||
frames = self.extract_frames()
|
||||
color = self.analyze_color_distribution(frames)
|
||||
motion = self.analyze_motion(frames)
|
||||
audio = self.analyze_audio()
|
||||
|
||||
# 计算各类别得分
|
||||
scores = self._calculate_category_scores(color, motion, audio)
|
||||
|
||||
# 找最高分
|
||||
if not scores:
|
||||
return ClassificationResult(
|
||||
category=AssetClassification.OTHER,
|
||||
confidence=0.3,
|
||||
scores={},
|
||||
)
|
||||
|
||||
best_category = max(scores.items(), key=lambda x: x[1])
|
||||
category = AssetClassification(best_category[0])
|
||||
confidence = min(0.95, max(0.3, best_category[1]))
|
||||
|
||||
return ClassificationResult(
|
||||
category=category,
|
||||
confidence=confidence,
|
||||
scores=scores,
|
||||
)
|
||||
|
||||
def _calculate_category_scores(
|
||||
self,
|
||||
color: ColorAnalysis,
|
||||
motion: MotionAnalysis,
|
||||
audio: AudioAnalysis,
|
||||
) -> dict[str, float]:
|
||||
"""
|
||||
计算各类别的置信度得分
|
||||
|
||||
Args:
|
||||
color: 色彩分析结果
|
||||
motion: 运动分析结果
|
||||
audio: 音频分析结果
|
||||
|
||||
Returns:
|
||||
各类别得分字典
|
||||
"""
|
||||
scores = {}
|
||||
|
||||
# 1. 风景 (scenic) - 绿色、户外、自然
|
||||
scenic_score = 0.0
|
||||
if color.green_ratio > 0.3:
|
||||
scenic_score += 0.4 * color.green_ratio
|
||||
if color.avg_saturation > 0.3:
|
||||
scenic_score += 0.2 * color.avg_saturation
|
||||
if color.avg_brightness > 0.4:
|
||||
scenic_score += 0.2
|
||||
if motion.motion_score > 0.1 and motion.motion_score < 0.5:
|
||||
scenic_score += 0.2 # 适度运动(如云朵、树叶)
|
||||
if not audio.has_audio or audio.ambient_ratio > 0.5:
|
||||
scenic_score += 0.2 # 自然环境音
|
||||
scores[AssetClassification.SCENIC.value] = min(1.0, scenic_score)
|
||||
|
||||
# 2. 产品 (product) - 中等亮度、均匀色彩、低运动
|
||||
product_score = 0.0
|
||||
if 0.3 < color.avg_brightness < 0.7:
|
||||
product_score += 0.3
|
||||
if color.avg_saturation < 0.5:
|
||||
product_score += 0.2
|
||||
if motion.motion_score < 0.15:
|
||||
product_score += 0.4 # 低运动 = 产品展示
|
||||
if color.cool_ratio > 0.3:
|
||||
product_score += 0.2 # 冷色调 = 科技感
|
||||
scores[AssetClassification.PRODUCT.value] = min(1.0, product_score)
|
||||
|
||||
# 3. 人物 (person) - 中等运动、有时有人声
|
||||
person_score = 0.0
|
||||
if 0.1 < motion.motion_score < 0.4:
|
||||
person_score += 0.3 # 适度运动
|
||||
if audio.has_audio and audio.speech_ratio > 0.3:
|
||||
person_score += 0.5 # 有人声
|
||||
if color.avg_brightness > 0.3:
|
||||
person_score += 0.2
|
||||
scores[AssetClassification.PERSON.value] = min(1.0, person_score)
|
||||
|
||||
# 4. 动物 (animal) - 高运动、有时自然音
|
||||
animal_score = 0.0
|
||||
if motion.motion_score > 0.3:
|
||||
animal_score += 0.4 # 高运动
|
||||
if motion.scene_changes > 2:
|
||||
animal_score += 0.2
|
||||
if audio.has_audio and (audio.ambient_ratio > 0.3 or audio.speech_ratio > 0.2):
|
||||
animal_score += 0.3
|
||||
scores[AssetClassification.ANIMAL.value] = min(1.0, animal_score)
|
||||
|
||||
# 5. 美食 (food) - 暖色调、高饱和度
|
||||
food_score = 0.0
|
||||
if color.warm_ratio > 0.4:
|
||||
food_score += 0.5
|
||||
if color.avg_saturation > 0.5:
|
||||
food_score += 0.3
|
||||
if 0.4 < color.avg_brightness < 0.8:
|
||||
food_score += 0.2
|
||||
scores[AssetClassification.FOOD.value] = min(1.0, food_score)
|
||||
|
||||
# 6. 科技 (tech) - 冷色调、低饱和度、低运动
|
||||
tech_score = 0.0
|
||||
if color.cool_ratio > 0.4:
|
||||
tech_score += 0.4
|
||||
if color.avg_saturation < 0.4:
|
||||
tech_score += 0.3
|
||||
if motion.motion_score < 0.2:
|
||||
tech_score += 0.3
|
||||
scores[AssetClassification.TECH.value] = min(1.0, tech_score)
|
||||
|
||||
# 7. 运动 (sport) - 高运动
|
||||
sport_score = 0.0
|
||||
if motion.motion_score > 0.4:
|
||||
sport_score += 0.6
|
||||
if motion.scene_changes > 3:
|
||||
sport_score += 0.2
|
||||
if color.avg_brightness > 0.4:
|
||||
sport_score += 0.2
|
||||
scores[AssetClassification.SPORT.value] = min(1.0, sport_score)
|
||||
|
||||
# 8. 音乐 (music) - 有节奏性音乐
|
||||
music_score = 0.0
|
||||
if audio.has_audio and audio.music_ratio > 0.4:
|
||||
music_score += 0.6
|
||||
# 纯视觉判断:色彩丰富但非自然
|
||||
if color.avg_saturation > 0.5 and color.green_ratio < 0.2:
|
||||
music_score += 0.3
|
||||
scores[AssetClassification.MUSIC.value] = min(1.0, music_score)
|
||||
|
||||
# 9. 其他 (other) - 默认最低分
|
||||
scores[AssetClassification.OTHER.value] = 0.1
|
||||
|
||||
return scores
|
||||
return classify_from_analysis(color, motion, audio)
|
||||
|
||||
def calculate_quality_score(self) -> QualityScore:
|
||||
"""
|
||||
计算视频质量综合评分 (0-100)
|
||||
|
||||
评分逻辑在 asset_quality_scoring.calculate_quality_score 纯函数中,
|
||||
此处只负责采集数据后委托计算。
|
||||
|
||||
评分维度:
|
||||
1. 分辨率得分 (25分)
|
||||
2. 帧率得分 (20分)
|
||||
3. 码率得分 (20分)
|
||||
4. 清晰度得分 (20分) - Laplacian 方差
|
||||
5. 稳定性得分 (15分) - 帧间位移方差
|
||||
4. 清晰度得分 (20分)
|
||||
5. 稳定性得分 (15分)
|
||||
"""
|
||||
info = self.get_video_info()
|
||||
frames = self.extract_frames()
|
||||
|
||||
# 1. 分辨率得分
|
||||
resolution_score = self._score_resolution(info.width, info.height)
|
||||
|
||||
# 2. 帧率得分
|
||||
fps_score = self._score_framerate(info.fps)
|
||||
|
||||
# 3. 码率得分
|
||||
bitrate_score = self._score_bitrate(info.bitrate)
|
||||
|
||||
# 4. 清晰度得分
|
||||
clarity_score = self._score_clarity(frames)
|
||||
|
||||
# 5. 稳定性得分
|
||||
stability_score = self._score_stability(frames)
|
||||
|
||||
total = resolution_score + fps_score + bitrate_score + clarity_score + stability_score
|
||||
|
||||
return QualityScore(
|
||||
total=round(min(100, max(0, total)), 1),
|
||||
resolution_score=resolution_score,
|
||||
fps_score=fps_score,
|
||||
bitrate_score=bitrate_score,
|
||||
clarity_score=clarity_score,
|
||||
stability_score=stability_score,
|
||||
)
|
||||
|
||||
def _score_resolution(self, width: int, height: int) -> float:
|
||||
"""分辨率评分 (满分 25)"""
|
||||
pixels = width * height
|
||||
|
||||
if pixels >= 3840 * 2160: # 4K
|
||||
return 25.0
|
||||
elif pixels >= 2560 * 1440: # 2K
|
||||
return 22.0
|
||||
elif pixels >= 1920 * 1080: # 1080p
|
||||
return 20.0
|
||||
elif pixels >= 1280 * 720: # 720p
|
||||
return 15.0
|
||||
elif pixels >= 854 * 480: # 480p
|
||||
return 8.0
|
||||
else:
|
||||
return 3.0
|
||||
|
||||
def _score_framerate(self, fps: float) -> float:
|
||||
"""帧率评分 (满分 20)"""
|
||||
if fps >= 60:
|
||||
return 20.0
|
||||
elif fps >= 30:
|
||||
return 15.0
|
||||
elif fps >= 24:
|
||||
return 10.0
|
||||
elif fps >= 15:
|
||||
return 7.0
|
||||
else:
|
||||
return 5.0
|
||||
|
||||
def _score_bitrate(self, bitrate: int) -> float:
|
||||
"""码率评分 (满分 20)"""
|
||||
bitrate_mbps = bitrate / 1_000_000
|
||||
|
||||
if bitrate_mbps > 10:
|
||||
return 20.0
|
||||
elif bitrate_mbps >= 5:
|
||||
return 15.0
|
||||
elif bitrate_mbps >= 2:
|
||||
return 10.0
|
||||
elif bitrate_mbps >= 0.5:
|
||||
return 5.0
|
||||
else:
|
||||
return 3.0
|
||||
|
||||
def _score_clarity(self, frames: list[np.ndarray]) -> float:
|
||||
"""
|
||||
清晰度评分 (满分 20)
|
||||
|
||||
使用 Laplacian 方差评估画面清晰度
|
||||
高方差 = 细节丰富 = 高分
|
||||
"""
|
||||
if not frames:
|
||||
return 10.0 # 默认中等分
|
||||
|
||||
try:
|
||||
variances = []
|
||||
|
||||
for frame in frames[:5]: # 只分析前 5 帧
|
||||
if len(frame.shape) == 3:
|
||||
# 转灰度
|
||||
gray = np.dot(frame[..., :3], [0.299, 0.587, 0.114]).astype(np.uint8)
|
||||
else:
|
||||
gray = frame
|
||||
|
||||
# Laplacian 算子
|
||||
laplacian = np.array([[0, 1, 0], [1, -4, 1], [0, 1, 0]], dtype=np.float32)
|
||||
|
||||
# 手动计算卷积
|
||||
from scipy import signal
|
||||
|
||||
laplacian_img = signal.convolve2d(gray.astype(float), laplacian, mode="same")
|
||||
variance = np.var(laplacian_img)
|
||||
variances.append(variance)
|
||||
|
||||
# 归一化方差到 0-20 分
|
||||
avg_variance = np.mean(variances)
|
||||
# 根据经验值调整
|
||||
score = min(20.0, avg_variance / 100)
|
||||
return float(score)
|
||||
|
||||
except ImportError:
|
||||
# 如果没有 scipy,使用简化方法
|
||||
return 10.0
|
||||
except Exception:
|
||||
return 10.0
|
||||
|
||||
def _score_stability(self, frames: list[np.ndarray]) -> float:
|
||||
"""
|
||||
稳定性评分 (满分 15)
|
||||
|
||||
分析帧间位移方差
|
||||
画面稳定 = 高分
|
||||
剧烈抖动 = 低分
|
||||
"""
|
||||
if len(frames) < 2:
|
||||
return 10.0 # 默认中等分
|
||||
|
||||
try:
|
||||
displacements = []
|
||||
|
||||
for i in range(len(frames) - 1):
|
||||
# 缩小帧以加速处理
|
||||
scale = 0.25
|
||||
new_h = int(frames[i].shape[0] * scale)
|
||||
new_w = int(frames[i].shape[1] * scale)
|
||||
frame1_small = np.array(Image.fromarray(frames[i]).resize((new_w, new_h)))
|
||||
new_h2 = int(frames[i + 1].shape[0] * scale)
|
||||
new_w2 = int(frames[i + 1].shape[1] * scale)
|
||||
frame2_small = np.array(Image.fromarray(frames[i + 1]).resize((new_w2, new_h2)))
|
||||
|
||||
# 简单位移检测:灰度差
|
||||
gray1 = np.mean(frame1_small, axis=2) if len(frame1_small.shape) == 3 else frame1_small
|
||||
gray2 = np.mean(frame2_small, axis=2) if len(frame2_small.shape) == 3 else frame2_small
|
||||
|
||||
diff = np.abs(gray2.astype(float) - gray1.astype(float))
|
||||
displacement = np.mean(diff) / 255.0
|
||||
displacements.append(displacement)
|
||||
|
||||
# 高位移方差 = 不稳定
|
||||
if displacements:
|
||||
displacement_variance = np.var(displacements)
|
||||
# 归一化
|
||||
instability = min(1.0, displacement_variance * 10)
|
||||
score = 15.0 * (1.0 - instability)
|
||||
return float(max(0.0, score))
|
||||
|
||||
return 10.0
|
||||
|
||||
except Exception:
|
||||
return 10.0
|
||||
return calculate_quality_score(info, frames)
|
||||
|
||||
|
||||
def classify_asset_real(video_path: str) -> tuple[str, float]:
|
||||
|
||||
+459
@@ -0,0 +1,459 @@
|
||||
"""素材质量与分类评分 — 纯逻辑模块.
|
||||
|
||||
从 asset_analyzer.py 提取的评分计算逻辑,纯函数,无副作用。
|
||||
输入分析结果对象,输出评分/分类结果。
|
||||
|
||||
拆分目的:
|
||||
1. 大文件瘦身(asset_analyzer.py 799行 → 拆出 200+ 行纯逻辑)
|
||||
2. 评分逻辑可独立单测,不依赖 FFmpeg/视频文件
|
||||
3. 评分策略调整时不需要触碰分析主流程
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional
|
||||
|
||||
import numpy as np
|
||||
|
||||
from packages.domain.classification import AssetClassification
|
||||
|
||||
# ── 数据类 ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@dataclass
|
||||
class VideoInfo:
|
||||
"""视频基本信息"""
|
||||
|
||||
width: int = 0
|
||||
height: int = 0
|
||||
fps: float = 0.0
|
||||
duration: float = 0.0
|
||||
bitrate: int = 0
|
||||
codec: str = ""
|
||||
has_audio: bool = False
|
||||
file_size: int = 0
|
||||
|
||||
|
||||
@dataclass
|
||||
class ColorAnalysis:
|
||||
"""色彩分析结果"""
|
||||
|
||||
dominant_hue: float = 0.0 # 主色调 (0-360)
|
||||
green_ratio: float = 0.0 # 绿色占比
|
||||
warm_ratio: float = 0.0 # 暖色调占比
|
||||
cool_ratio: float = 0.0 # 冷色调占比
|
||||
avg_saturation: float = 0.0
|
||||
avg_brightness: float = 0.0
|
||||
|
||||
|
||||
@dataclass
|
||||
class MotionAnalysis:
|
||||
"""运动分析结果"""
|
||||
|
||||
motion_score: float = 0.0 # 运动幅度 (0-1)
|
||||
scene_changes: int = 0 # 场景切换次数
|
||||
|
||||
|
||||
@dataclass
|
||||
class AudioAnalysis:
|
||||
"""音频分析结果"""
|
||||
|
||||
has_audio: bool = False
|
||||
speech_ratio: float = 0.0 # 人声比例
|
||||
music_ratio: float = 0.0 # 音乐比例
|
||||
ambient_ratio: float = 0.0 # 环境音比例
|
||||
|
||||
|
||||
@dataclass
|
||||
class ClassificationResult:
|
||||
"""分类结果"""
|
||||
|
||||
category: AssetClassification
|
||||
confidence: float
|
||||
scores: dict[str, float] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass
|
||||
class QualityScore:
|
||||
"""质量评分结果"""
|
||||
|
||||
total: float
|
||||
resolution_score: float = 0.0
|
||||
fps_score: float = 0.0
|
||||
bitrate_score: float = 0.0
|
||||
clarity_score: float = 0.0
|
||||
stability_score: float = 0.0
|
||||
|
||||
|
||||
# ── 质量评分纯函数 ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def score_resolution(width: int, height: int) -> float:
|
||||
"""分辨率评分 (满分 25).
|
||||
|
||||
按像素总数阶梯评分:4K > 2K > 1080p > 720p > 480p > 其他.
|
||||
|
||||
Args:
|
||||
width: 视频宽度(像素)
|
||||
height: 视频高度(像素)
|
||||
|
||||
Returns:
|
||||
float: 0-25 分
|
||||
"""
|
||||
pixels = width * height
|
||||
|
||||
if pixels >= 3840 * 2160: # 4K
|
||||
return 25.0
|
||||
elif pixels >= 2560 * 1440: # 2K
|
||||
return 22.0
|
||||
elif pixels >= 1920 * 1080: # 1080p
|
||||
return 20.0
|
||||
elif pixels >= 1280 * 720: # 720p
|
||||
return 15.0
|
||||
elif pixels >= 854 * 480: # 480p
|
||||
return 8.0
|
||||
else:
|
||||
return 3.0
|
||||
|
||||
|
||||
def score_framerate(fps: float) -> float:
|
||||
"""帧率评分 (满分 20).
|
||||
|
||||
60fps 满分,阶梯递减.
|
||||
|
||||
Args:
|
||||
fps: 帧率(帧/秒)
|
||||
|
||||
Returns:
|
||||
float: 0-20 分
|
||||
"""
|
||||
if fps >= 60:
|
||||
return 20.0
|
||||
elif fps >= 30:
|
||||
return 15.0
|
||||
elif fps >= 24:
|
||||
return 10.0
|
||||
elif fps >= 15:
|
||||
return 7.0
|
||||
else:
|
||||
return 5.0
|
||||
|
||||
|
||||
def score_bitrate(bitrate: int) -> float:
|
||||
"""码率评分 (满分 20).
|
||||
|
||||
按 Mbps 阶梯评分.
|
||||
|
||||
Args:
|
||||
bitrate: 码率(bps)
|
||||
|
||||
Returns:
|
||||
float: 0-20 分
|
||||
"""
|
||||
bitrate_mbps = bitrate / 1_000_000
|
||||
|
||||
if bitrate_mbps > 10:
|
||||
return 20.0
|
||||
elif bitrate_mbps >= 5:
|
||||
return 15.0
|
||||
elif bitrate_mbps >= 2:
|
||||
return 10.0
|
||||
elif bitrate_mbps >= 0.5:
|
||||
return 5.0
|
||||
else:
|
||||
return 3.0
|
||||
|
||||
|
||||
def score_clarity(frames: list[np.ndarray]) -> float:
|
||||
"""清晰度评分 (满分 20).
|
||||
|
||||
使用 Laplacian 方差评估画面清晰度。
|
||||
高方差 = 细节丰富 = 高分.
|
||||
|
||||
Args:
|
||||
frames: 视频帧列表(numpy 数组,RGB 或灰度)
|
||||
|
||||
Returns:
|
||||
float: 0-20 分
|
||||
"""
|
||||
if not frames:
|
||||
return 10.0 # 默认中等分
|
||||
|
||||
try:
|
||||
variances = []
|
||||
|
||||
for frame in frames[:5]: # 只分析前 5 帧
|
||||
if len(frame.shape) == 3:
|
||||
# 转灰度
|
||||
gray = np.dot(frame[..., :3], [0.299, 0.587, 0.114]).astype(np.uint8)
|
||||
else:
|
||||
gray = frame
|
||||
|
||||
# Laplacian 算子
|
||||
laplacian = np.array([[0, 1, 0], [1, -4, 1], [0, 1, 0]], dtype=np.float32)
|
||||
|
||||
# 手动计算卷积
|
||||
from scipy import signal
|
||||
|
||||
laplacian_img = signal.convolve2d(gray.astype(float), laplacian, mode="same")
|
||||
variance = np.var(laplacian_img)
|
||||
variances.append(variance)
|
||||
|
||||
# 归一化方差到 0-20 分
|
||||
avg_variance = np.mean(variances)
|
||||
score = min(20.0, avg_variance / 100)
|
||||
return float(score)
|
||||
|
||||
except ImportError:
|
||||
# 如果没有 scipy,使用简化方法
|
||||
return 10.0
|
||||
except Exception:
|
||||
return 10.0
|
||||
|
||||
|
||||
def score_stability(frames: list[np.ndarray]) -> float:
|
||||
"""稳定性评分 (满分 15).
|
||||
|
||||
分析帧间位移方差。
|
||||
画面稳定 = 高分;剧烈抖动 = 低分.
|
||||
|
||||
Args:
|
||||
frames: 视频帧列表(numpy 数组,RGB 或灰度)
|
||||
|
||||
Returns:
|
||||
float: 0-15 分
|
||||
"""
|
||||
if len(frames) < 2:
|
||||
return 10.0 # 默认中等分
|
||||
|
||||
try:
|
||||
from PIL import Image
|
||||
|
||||
displacements = []
|
||||
|
||||
for i in range(len(frames) - 1):
|
||||
# 缩小帧以加速处理
|
||||
scale = 0.25
|
||||
new_h = int(frames[i].shape[0] * scale)
|
||||
new_w = int(frames[i].shape[1] * scale)
|
||||
frame1_small = np.array(Image.fromarray(frames[i]).resize((new_w, new_h)))
|
||||
new_h2 = int(frames[i + 1].shape[0] * scale)
|
||||
new_w2 = int(frames[i + 1].shape[1] * scale)
|
||||
frame2_small = np.array(Image.fromarray(frames[i + 1]).resize((new_w2, new_h2)))
|
||||
|
||||
# 简单位移检测:灰度差
|
||||
gray1 = np.mean(frame1_small, axis=2) if len(frame1_small.shape) == 3 else frame1_small
|
||||
gray2 = np.mean(frame2_small, axis=2) if len(frame2_small.shape) == 3 else frame2_small
|
||||
|
||||
diff = np.abs(gray2.astype(float) - gray1.astype(float))
|
||||
displacement = np.mean(diff) / 255.0
|
||||
displacements.append(displacement)
|
||||
|
||||
# 高位移方差 = 不稳定
|
||||
if displacements:
|
||||
displacement_variance = np.var(displacements)
|
||||
# 归一化
|
||||
instability = min(1.0, displacement_variance * 10)
|
||||
score = 15.0 * (1.0 - instability)
|
||||
return float(max(0.0, score))
|
||||
|
||||
return 10.0
|
||||
|
||||
except Exception:
|
||||
return 10.0
|
||||
|
||||
|
||||
def calculate_quality_score(
|
||||
info: VideoInfo,
|
||||
frames: Optional[list[np.ndarray]] = None,
|
||||
) -> QualityScore:
|
||||
"""计算视频质量综合评分 (0-100).
|
||||
|
||||
评分维度:
|
||||
1. 分辨率得分 (25分)
|
||||
2. 帧率得分 (20分)
|
||||
3. 码率得分 (20分)
|
||||
4. 清晰度得分 (20分) - 无帧时默认10分
|
||||
5. 稳定性得分 (15分) - 帧不足时默认10分
|
||||
|
||||
Args:
|
||||
info: 视频基本信息
|
||||
frames: 采样帧列表(可选,无则清晰度/稳定性给默认分)
|
||||
|
||||
Returns:
|
||||
QualityScore: 各维度得分 + 总分
|
||||
"""
|
||||
# 1. 分辨率得分
|
||||
resolution_score = score_resolution(info.width, info.height)
|
||||
|
||||
# 2. 帧率得分
|
||||
fps_score = score_framerate(info.fps)
|
||||
|
||||
# 3. 码率得分
|
||||
bitrate_score = score_bitrate(info.bitrate)
|
||||
|
||||
# 4. 清晰度得分
|
||||
clarity_score = score_clarity(frames) if frames else 10.0
|
||||
|
||||
# 5. 稳定性得分
|
||||
stability_score = score_stability(frames) if frames and len(frames) >= 2 else 10.0
|
||||
|
||||
total = resolution_score + fps_score + bitrate_score + clarity_score + stability_score
|
||||
|
||||
return QualityScore(
|
||||
total=round(min(100.0, max(0.0, total)), 1),
|
||||
resolution_score=resolution_score,
|
||||
fps_score=fps_score,
|
||||
bitrate_score=bitrate_score,
|
||||
clarity_score=clarity_score,
|
||||
stability_score=stability_score,
|
||||
)
|
||||
|
||||
|
||||
# ── 分类评分纯函数 ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def calculate_category_scores(
|
||||
color: ColorAnalysis,
|
||||
motion: MotionAnalysis,
|
||||
audio: AudioAnalysis,
|
||||
) -> dict[str, float]:
|
||||
"""计算各类别的置信度得分.
|
||||
|
||||
9 个分类:风景、产品、人物、动物、美食、科技、运动、音乐、其他.
|
||||
|
||||
Args:
|
||||
color: 色彩分析结果
|
||||
motion: 运动分析结果
|
||||
audio: 音频分析结果
|
||||
|
||||
Returns:
|
||||
dict[str, float]: 各分类名称 -> 得分 (0-1)
|
||||
"""
|
||||
scores: dict[str, float] = {}
|
||||
|
||||
# 1. 风景 (scenic) - 绿色、户外、自然
|
||||
scenic_score = 0.0
|
||||
if color.green_ratio > 0.3:
|
||||
scenic_score += 0.4 * color.green_ratio
|
||||
if color.avg_saturation > 0.3:
|
||||
scenic_score += 0.2 * color.avg_saturation
|
||||
if color.avg_brightness > 0.4:
|
||||
scenic_score += 0.2
|
||||
if 0.1 < motion.motion_score < 0.5:
|
||||
scenic_score += 0.2 # 适度运动(如云朵、树叶)
|
||||
if not audio.has_audio or audio.ambient_ratio > 0.5:
|
||||
scenic_score += 0.2 # 自然环境音
|
||||
scores[AssetClassification.SCENIC.value] = min(1.0, scenic_score)
|
||||
|
||||
# 2. 产品 (product) - 中等亮度、均匀色彩、低运动
|
||||
product_score = 0.0
|
||||
if 0.3 < color.avg_brightness < 0.7:
|
||||
product_score += 0.3
|
||||
if color.avg_saturation < 0.5:
|
||||
product_score += 0.2
|
||||
if motion.motion_score < 0.15:
|
||||
product_score += 0.4 # 低运动 = 产品展示
|
||||
if color.cool_ratio > 0.3:
|
||||
product_score += 0.2 # 冷色调 = 科技感
|
||||
scores[AssetClassification.PRODUCT.value] = min(1.0, product_score)
|
||||
|
||||
# 3. 人物 (person) - 中等运动、有时有人声
|
||||
person_score = 0.0
|
||||
if 0.1 < motion.motion_score < 0.4:
|
||||
person_score += 0.3 # 适度运动
|
||||
if audio.has_audio and audio.speech_ratio > 0.3:
|
||||
person_score += 0.5 # 有人声
|
||||
if color.avg_brightness > 0.3:
|
||||
person_score += 0.2
|
||||
scores[AssetClassification.PERSON.value] = min(1.0, person_score)
|
||||
|
||||
# 4. 动物 (animal) - 高运动、有时自然音
|
||||
animal_score = 0.0
|
||||
if motion.motion_score > 0.3:
|
||||
animal_score += 0.4 # 高运动
|
||||
if motion.scene_changes > 2:
|
||||
animal_score += 0.2
|
||||
if audio.has_audio and (audio.ambient_ratio > 0.3 or audio.speech_ratio > 0.2):
|
||||
animal_score += 0.3
|
||||
scores[AssetClassification.ANIMAL.value] = min(1.0, animal_score)
|
||||
|
||||
# 5. 美食 (food) - 暖色调、高饱和度
|
||||
food_score = 0.0
|
||||
if color.warm_ratio > 0.4:
|
||||
food_score += 0.5
|
||||
if color.avg_saturation > 0.5:
|
||||
food_score += 0.3
|
||||
if 0.4 < color.avg_brightness < 0.8:
|
||||
food_score += 0.2
|
||||
scores[AssetClassification.FOOD.value] = min(1.0, food_score)
|
||||
|
||||
# 6. 科技 (tech) - 冷色调、低饱和度、低运动
|
||||
tech_score = 0.0
|
||||
if color.cool_ratio > 0.4:
|
||||
tech_score += 0.4
|
||||
if color.avg_saturation < 0.4:
|
||||
tech_score += 0.3
|
||||
if motion.motion_score < 0.2:
|
||||
tech_score += 0.3
|
||||
scores[AssetClassification.TECH.value] = min(1.0, tech_score)
|
||||
|
||||
# 7. 运动 (sport) - 高运动
|
||||
sport_score = 0.0
|
||||
if motion.motion_score > 0.4:
|
||||
sport_score += 0.6
|
||||
if motion.scene_changes > 3:
|
||||
sport_score += 0.2
|
||||
if color.avg_brightness > 0.4:
|
||||
sport_score += 0.2
|
||||
scores[AssetClassification.SPORT.value] = min(1.0, sport_score)
|
||||
|
||||
# 8. 音乐 (music) - 有节奏性音乐
|
||||
music_score = 0.0
|
||||
if audio.has_audio and audio.music_ratio > 0.4:
|
||||
music_score += 0.6
|
||||
# 纯视觉判断:色彩丰富但非自然
|
||||
if color.avg_saturation > 0.5 and color.green_ratio < 0.2:
|
||||
music_score += 0.3
|
||||
scores[AssetClassification.MUSIC.value] = min(1.0, music_score)
|
||||
|
||||
# 9. 其他 (other) - 默认最低分
|
||||
scores[AssetClassification.OTHER.value] = 0.1
|
||||
|
||||
return scores
|
||||
|
||||
|
||||
def classify_from_analysis(
|
||||
color: ColorAnalysis,
|
||||
motion: MotionAnalysis,
|
||||
audio: AudioAnalysis,
|
||||
) -> ClassificationResult:
|
||||
"""综合分析得出分类结果.
|
||||
|
||||
Args:
|
||||
color: 色彩分析结果
|
||||
motion: 运动分析结果
|
||||
audio: 音频分析结果
|
||||
|
||||
Returns:
|
||||
ClassificationResult: 分类结果(最高分类别 + 置信度 + 全部分数)
|
||||
"""
|
||||
scores = calculate_category_scores(color, motion, audio)
|
||||
|
||||
if not scores:
|
||||
return ClassificationResult(
|
||||
category=AssetClassification.OTHER,
|
||||
confidence=0.3,
|
||||
scores={},
|
||||
)
|
||||
|
||||
best_category = max(scores.items(), key=lambda x: x[1])
|
||||
category = AssetClassification(best_category[0])
|
||||
confidence = min(0.95, max(0.3, best_category[1]))
|
||||
|
||||
return ClassificationResult(
|
||||
category=category,
|
||||
confidence=confidence,
|
||||
scores=scores,
|
||||
)
|
||||
@@ -17,7 +17,6 @@ import logging
|
||||
import os
|
||||
import tempfile
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
@@ -25,6 +24,15 @@ from worker_app.celery_app import celery_app
|
||||
from worker_app.db import SessionLocal
|
||||
|
||||
from packages.domain.bgm_utils import merge_bgm_config
|
||||
from video_processing.ffmpeg_utils import probe_duration
|
||||
from worker_app.tasks.generation_plan_builder import (
|
||||
VirtualPlan as _VirtualPlan,
|
||||
VirtualClip as _VirtualClip,
|
||||
build_error_info as _build_error_info,
|
||||
extract_intro_outro_from_clip_configs as _extract_intro_outro_from_clip_configs,
|
||||
apply_template_clip_effects as _apply_template_clip_effects,
|
||||
build_clips_by_mode,
|
||||
)
|
||||
|
||||
OUTPUT_WIDTH = 1280
|
||||
OUTPUT_HEIGHT = 720
|
||||
@@ -88,36 +96,6 @@ def _update_task_status(task_id: str, status_action: str, **kwargs) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _build_error_info(error: Exception, stage: str = "render") -> dict:
|
||||
"""构建结构化错误信息。
|
||||
|
||||
Args:
|
||||
error: 异常对象
|
||||
stage: 发生错误的阶段(download/render/merge/upload等)
|
||||
|
||||
Returns:
|
||||
包含 error_type, message, stack_trace, stage, failed_at 的字典
|
||||
"""
|
||||
import traceback
|
||||
from datetime import datetime, timezone
|
||||
|
||||
tb_str = traceback.format_exc()
|
||||
# 截取堆栈前20行,避免字段过大
|
||||
tb_lines = tb_str.strip().splitlines()
|
||||
if len(tb_lines) > 20:
|
||||
tb_summary = "\n".join(tb_lines[:20]) + f"\n... (truncated, total {len(tb_lines)} lines)"
|
||||
else:
|
||||
tb_summary = tb_str
|
||||
|
||||
return {
|
||||
"error_type": type(error).__name__,
|
||||
"message": str(error),
|
||||
"stack_trace": tb_summary,
|
||||
"stage": stage,
|
||||
"failed_at": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
|
||||
|
||||
# ── 日志持久化辅助 ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -148,36 +126,6 @@ from video_processing.oss_helpers import (
|
||||
upload_to_oss,
|
||||
)
|
||||
|
||||
# ── 虚拟 Plan / Clip(内存中构建,不写数据库) ────────────────────────────────
|
||||
|
||||
|
||||
@dataclass
|
||||
class _VirtualPlan:
|
||||
"""内存中的虚拟剪辑计划,供 UnifiedRenderService 使用。"""
|
||||
|
||||
id: str
|
||||
name: str = ""
|
||||
config: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass
|
||||
class _VirtualClip:
|
||||
"""内存中的虚拟剪辑片段,供 UnifiedRenderService 使用。"""
|
||||
|
||||
id: str
|
||||
plan_id: str = ""
|
||||
clip_type: str = "main"
|
||||
order: int = 0
|
||||
asset_id: str = ""
|
||||
text_content: str = ""
|
||||
start_time: float = 0.0
|
||||
duration: float = 0.0
|
||||
transition_effect: str = "cut"
|
||||
transition_duration: float = 0.0 # 0 表示使用全局默认值
|
||||
playback_speed: float = 1.0
|
||||
status: str = "ready"
|
||||
config: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
def _load_template_clip_configs(template_id: str) -> list:
|
||||
"""从数据库读取模板的片段配置列表。
|
||||
@@ -206,124 +154,6 @@ def _load_template_clip_configs(template_id: str) -> list:
|
||||
return []
|
||||
|
||||
|
||||
def _extract_intro_outro_from_clip_configs(clip_configs: list) -> dict[str, Any]:
|
||||
"""从模板的 intro/outro 类型 clip_config 中提取 plan 级 intro_outro 配置。
|
||||
|
||||
UnifiedRenderService 已支持 plan.config.intro_outro 路径,
|
||||
这里把 intro/outro 片段配置转为统一格式注入。
|
||||
"""
|
||||
intro_configs = [
|
||||
c for c in clip_configs if (c.clip_type.value if hasattr(c.clip_type, "value") else c.clip_type) == "intro"
|
||||
]
|
||||
outro_configs = [
|
||||
c for c in clip_configs if (c.clip_type.value if hasattr(c.clip_type, "value") else c.clip_type) == "outro"
|
||||
]
|
||||
|
||||
result: dict[str, Any] = {}
|
||||
|
||||
if intro_configs:
|
||||
intro = intro_configs[0]
|
||||
intro_cfg = intro.config or {}
|
||||
result["has_intro"] = True
|
||||
result["intro_type"] = intro_cfg.get("intro_type", "text")
|
||||
result["intro_duration"] = intro.default_duration or 3.0
|
||||
if intro.text_template:
|
||||
result["intro_text"] = intro.text_template
|
||||
# 透传额外配置
|
||||
for key in ("intro_text_color", "intro_bg_color", "intro_font_size", "intro_video_url", "intro_video_path"):
|
||||
if key in intro_cfg:
|
||||
result[key] = intro_cfg[key]
|
||||
|
||||
if outro_configs:
|
||||
outro = outro_configs[0]
|
||||
outro_cfg = outro.config or {}
|
||||
result["has_outro"] = True
|
||||
result["outro_type"] = outro_cfg.get("outro_type", "text")
|
||||
result["outro_duration"] = outro.default_duration or 3.0
|
||||
if outro.text_template:
|
||||
result["outro_text"] = outro.text_template
|
||||
for key in ("outro_text_color", "outro_bg_color", "outro_font_size", "outro_follow_text"):
|
||||
if key in outro_cfg:
|
||||
result[key] = outro_cfg[key]
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def _apply_template_clip_effects(
|
||||
clips: list[_VirtualClip],
|
||||
clip_configs: list,
|
||||
mode: str,
|
||||
) -> None:
|
||||
"""将模板的 clip 级效果层映射到素材 clips 上(就地修改)。
|
||||
|
||||
映射规则:
|
||||
- 只对素材主体 clips 做映射(ONE_TAKE: main, PIP: main+overlay, VOICE_OVER: main, VOICE_PIP: background+b_roll)
|
||||
- 从模板中筛选 main 类型的 clip_config 作为效果模板
|
||||
- 素材 clips 按顺序循环匹配模板 clip_config(素材多的话重复使用最后一个模板配置)
|
||||
- 映射字段:transition_effect, config.color_grade, config.speed
|
||||
"""
|
||||
if not clip_configs or not clips:
|
||||
return
|
||||
|
||||
# 筛选 main 类型的模板配置(作为效果模板池)
|
||||
main_configs = [
|
||||
c
|
||||
for c in clip_configs
|
||||
if (c.clip_type.value if hasattr(c.clip_type, "value") else c.clip_type) in ("main", "showcase", "b_roll")
|
||||
]
|
||||
if not main_configs:
|
||||
return
|
||||
|
||||
# 确定需要映射的素材 clips(排除 corner_voice 等特殊层)
|
||||
target_clips = [c for c in clips if c.clip_type not in ("corner_voice",)]
|
||||
|
||||
for i, clip in enumerate(target_clips):
|
||||
# 循环匹配:素材多了用最后一个模板配置
|
||||
cfg_idx = min(i, len(main_configs) - 1)
|
||||
template_cfg = main_configs[cfg_idx]
|
||||
|
||||
# 1. 转场效果 + 时长
|
||||
transition = (
|
||||
template_cfg.transition_effect.value
|
||||
if hasattr(template_cfg.transition_effect, "value")
|
||||
else template_cfg.transition_effect
|
||||
)
|
||||
if transition and transition != "cut":
|
||||
clip.transition_effect = transition
|
||||
# 同步转场时长(模板 clip_config 里的 transition_duration)
|
||||
tpl_cfg = template_cfg.config or {}
|
||||
tpl_duration = tpl_cfg.get("transition_duration")
|
||||
if tpl_duration:
|
||||
try:
|
||||
dur_val = float(tpl_duration)
|
||||
if dur_val > 0:
|
||||
clip.transition_duration = dur_val
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
# 2. clip 级效果配置(滤镜、调速等)
|
||||
template_clip_config = template_cfg.config or {}
|
||||
if template_clip_config:
|
||||
# 合并到 clip.config(保留已有配置如 role 等)
|
||||
existing_config = clip.config or {}
|
||||
# 需要从模板复制的效果层 key
|
||||
effect_keys = ("color_grade", "speed", "playback_speed", "reverse", "chroma_key", "filter")
|
||||
for key in effect_keys:
|
||||
if key in template_clip_config:
|
||||
existing_config[key] = template_clip_config[key]
|
||||
clip.config = existing_config
|
||||
|
||||
# 3. 调速:同步到 clip.playback_speed 顶级字段(渲染引擎读此字段)
|
||||
template_speed = template_clip_config.get("playback_speed") or template_clip_config.get("speed")
|
||||
if template_speed:
|
||||
try:
|
||||
speed_val = float(template_speed)
|
||||
if speed_val > 0:
|
||||
clip.playback_speed = speed_val
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
|
||||
def _build_plan_and_clips_from_task(
|
||||
task_id: str,
|
||||
downloaded_paths: list[Path],
|
||||
|
||||
+327
@@ -0,0 +1,327 @@
|
||||
"""Generation task pure logic utilities — template mapping + plan/clip building.
|
||||
|
||||
从 generation.py 抽出来的纯逻辑模块:
|
||||
- VirtualPlan / VirtualClip: 内存中的虚拟计划/片段数据类
|
||||
- extract_intro_outro_from_clip_configs: 从模板 clip_config 提取片头片尾配置
|
||||
- apply_template_clip_effects: 将模板效果层映射到素材 clips
|
||||
- build_clips_by_mode: 根据模式和素材列表构建虚拟 clips
|
||||
- build_error_info: 构建结构化错误信息
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import traceback
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
# ── 数据类 ───────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@dataclass
|
||||
class VirtualPlan:
|
||||
"""内存中的虚拟剪辑计划,供 UnifiedRenderService 使用。"""
|
||||
|
||||
id: str
|
||||
name: str = ""
|
||||
config: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass
|
||||
class VirtualClip:
|
||||
"""内存中的虚拟剪辑片段,供 UnifiedRenderService 使用。"""
|
||||
|
||||
id: str
|
||||
plan_id: str = ""
|
||||
clip_type: str = "main"
|
||||
order: int = 0
|
||||
asset_id: str = ""
|
||||
text_content: str = ""
|
||||
start_time: float = 0.0
|
||||
duration: float = 0.0
|
||||
transition_effect: str = "cut"
|
||||
transition_duration: float = 0.0 # 0 表示使用全局默认值
|
||||
playback_speed: float = 1.0
|
||||
status: str = "ready"
|
||||
config: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
# ── 模板片头片尾提取 ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _clip_type_value(c: Any) -> str:
|
||||
"""获取 clip_config 的 clip_type 字符串值(兼容 Enum 和 str)。"""
|
||||
if hasattr(c, "value"):
|
||||
return str(c.value)
|
||||
return str(c)
|
||||
|
||||
|
||||
def _transition_value(t: Any) -> str:
|
||||
"""获取 transition_effect 字符串值(兼容 Enum 和 str)。"""
|
||||
if hasattr(t, "value"):
|
||||
return str(t.value)
|
||||
return str(t) if t else ""
|
||||
|
||||
|
||||
def extract_intro_outro_from_clip_configs(clip_configs: list) -> dict[str, Any]:
|
||||
"""从模板的 intro/outro 类型 clip_config 中提取 plan 级 intro_outro 配置。
|
||||
|
||||
UnifiedRenderService 已支持 plan.config.intro_outro 路径,
|
||||
这里把 intro/outro 片段配置转为统一格式注入。
|
||||
"""
|
||||
intro_configs = [c for c in clip_configs if _clip_type_value(c.clip_type) == "intro"]
|
||||
outro_configs = [c for c in clip_configs if _clip_type_value(c.clip_type) == "outro"]
|
||||
|
||||
result: dict[str, Any] = {}
|
||||
|
||||
if intro_configs:
|
||||
intro = intro_configs[0]
|
||||
intro_cfg = intro.config or {}
|
||||
result["has_intro"] = True
|
||||
result["intro_type"] = intro_cfg.get("intro_type", "text")
|
||||
result["intro_duration"] = getattr(intro, "default_duration", 3.0) or 3.0
|
||||
intro_text = getattr(intro, "text_template", "")
|
||||
if intro_text:
|
||||
result["intro_text"] = intro_text
|
||||
# 透传额外配置
|
||||
for key in (
|
||||
"intro_text_color",
|
||||
"intro_bg_color",
|
||||
"intro_font_size",
|
||||
"intro_video_url",
|
||||
"intro_video_path",
|
||||
):
|
||||
if key in intro_cfg:
|
||||
result[key] = intro_cfg[key]
|
||||
|
||||
if outro_configs:
|
||||
outro = outro_configs[0]
|
||||
outro_cfg = outro.config or {}
|
||||
result["has_outro"] = True
|
||||
result["outro_type"] = outro_cfg.get("outro_type", "text")
|
||||
result["outro_duration"] = getattr(outro, "default_duration", 3.0) or 3.0
|
||||
outro_text = getattr(outro, "text_template", "")
|
||||
if outro_text:
|
||||
result["outro_text"] = outro_text
|
||||
for key in (
|
||||
"outro_text_color",
|
||||
"outro_bg_color",
|
||||
"outro_font_size",
|
||||
"outro_follow_text",
|
||||
):
|
||||
if key in outro_cfg:
|
||||
result[key] = outro_cfg[key]
|
||||
|
||||
return result
|
||||
|
||||
|
||||
# ── 模板效果层映射 ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
# 需要从模板复制的效果层 key
|
||||
_TEMPLATE_EFFECT_KEYS = (
|
||||
"color_grade",
|
||||
"speed",
|
||||
"playback_speed",
|
||||
"reverse",
|
||||
"chroma_key",
|
||||
"filter",
|
||||
)
|
||||
|
||||
# 各模式下需要应用效果的 clip_type
|
||||
_EFFECT_TARGET_TYPES = {
|
||||
"one_take": {"main"},
|
||||
"pip": {"main", "overlay"},
|
||||
"voice_over": {"main"},
|
||||
"voice_pip": {"background", "b_roll"},
|
||||
}
|
||||
|
||||
# 作为效果模板池的 clip_type
|
||||
_TEMPLATE_SOURCE_TYPES = {"main", "showcase", "b_roll"}
|
||||
|
||||
# 不应用效果的 clip_type
|
||||
_SKIP_TYPES = {"corner_voice"}
|
||||
|
||||
|
||||
def apply_template_clip_effects(
|
||||
clips: list[VirtualClip],
|
||||
clip_configs: list,
|
||||
mode: str,
|
||||
) -> None:
|
||||
"""将模板的 clip 级效果层映射到素材 clips 上(就地修改)。
|
||||
|
||||
映射规则:
|
||||
- 只对素材主体 clips 做映射
|
||||
- 从模板中筛选 main/showcase/b_roll 类型的 clip_config 作为效果模板
|
||||
- 素材 clips 按顺序循环匹配模板 clip_config(素材多的话重复使用最后一个模板配置)
|
||||
- 映射字段:transition_effect, transition_duration, config 中的效果层
|
||||
"""
|
||||
if not clip_configs or not clips:
|
||||
return
|
||||
|
||||
# 筛选 main 类型的模板配置(作为效果模板池)
|
||||
main_configs = [c for c in clip_configs if _clip_type_value(c.clip_type) in _TEMPLATE_SOURCE_TYPES]
|
||||
if not main_configs:
|
||||
return
|
||||
|
||||
# 确定需要映射的素材 clips(排除特殊层)
|
||||
target_clips = [c for c in clips if c.clip_type not in _SKIP_TYPES]
|
||||
|
||||
for i, clip in enumerate(target_clips):
|
||||
# 循环匹配:素材多了用最后一个模板配置
|
||||
cfg_idx = min(i, len(main_configs) - 1)
|
||||
template_cfg = main_configs[cfg_idx]
|
||||
|
||||
# 1. 转场效果 + 时长
|
||||
transition = _transition_value(template_cfg.transition_effect)
|
||||
if transition and transition != "cut":
|
||||
clip.transition_effect = transition
|
||||
# 同步转场时长
|
||||
tpl_cfg = template_cfg.config or {}
|
||||
tpl_duration = tpl_cfg.get("transition_duration")
|
||||
if tpl_duration:
|
||||
try:
|
||||
dur_val = float(tpl_duration)
|
||||
if dur_val > 0:
|
||||
clip.transition_duration = dur_val
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
# 2. clip 级效果配置(滤镜、调速等)
|
||||
template_clip_config = template_cfg.config or {}
|
||||
if template_clip_config:
|
||||
existing_config = clip.config or {}
|
||||
for key in _TEMPLATE_EFFECT_KEYS:
|
||||
if key in template_clip_config:
|
||||
existing_config[key] = template_clip_config[key]
|
||||
clip.config = existing_config
|
||||
|
||||
# 3. 调速:同步到 clip.playback_speed 顶级字段
|
||||
template_speed = template_clip_config.get("playback_speed") or template_clip_config.get("speed")
|
||||
if template_speed:
|
||||
try:
|
||||
speed_val = float(template_speed)
|
||||
if speed_val > 0:
|
||||
clip.playback_speed = speed_val
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
|
||||
# ── 按模式构建 clips ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def build_clips_by_mode(
|
||||
plan_id: str,
|
||||
asset_infos: list[dict[str, Any]],
|
||||
mode: str,
|
||||
) -> list[VirtualClip]:
|
||||
"""根据生成模式和素材信息,构建 VirtualClip 列表。
|
||||
|
||||
纯逻辑版本:不依赖 ffmpeg probe 或 DB,完全由输入数据驱动。
|
||||
|
||||
Args:
|
||||
plan_id: 计划 ID
|
||||
asset_infos: 素材信息列表,每项包含 asset_id / duration / path 等
|
||||
mode: 生成模式 (one_take / pip / voice_over / voice_pip)
|
||||
|
||||
Returns:
|
||||
VirtualClip 列表,按 order 排序
|
||||
|
||||
模式 → clip_type 映射:
|
||||
one_take: N 个 main clips
|
||||
pip: 1 main + N-1 overlay
|
||||
voice_over: N 个 main (config.role=b_roll)
|
||||
voice_pip: 1 background + 1 corner_voice + N-2 b_roll
|
||||
"""
|
||||
clips: list[VirtualClip] = []
|
||||
|
||||
for i, info in enumerate(asset_infos):
|
||||
asset_id = info.get("asset_id", f"asset_{i:03d}")
|
||||
duration = float(info.get("duration", 0.0))
|
||||
|
||||
if mode == "pip":
|
||||
clip_type = "main" if i == 0 else "overlay"
|
||||
clips.append(
|
||||
VirtualClip(
|
||||
id=f"vc_{i:03d}",
|
||||
plan_id=plan_id,
|
||||
clip_type=clip_type,
|
||||
order=i,
|
||||
asset_id=asset_id,
|
||||
duration=duration,
|
||||
)
|
||||
)
|
||||
elif mode == "voice_over":
|
||||
clips.append(
|
||||
VirtualClip(
|
||||
id=f"vc_{i:03d}",
|
||||
plan_id=plan_id,
|
||||
clip_type="main",
|
||||
order=i,
|
||||
asset_id=asset_id,
|
||||
duration=duration,
|
||||
config={"role": "b_roll"},
|
||||
)
|
||||
)
|
||||
elif mode == "voice_pip":
|
||||
if i == 0:
|
||||
clip_type = "background"
|
||||
elif i == 1:
|
||||
clip_type = "corner_voice"
|
||||
else:
|
||||
clip_type = "b_roll"
|
||||
clips.append(
|
||||
VirtualClip(
|
||||
id=f"vc_{i:03d}",
|
||||
plan_id=plan_id,
|
||||
clip_type=clip_type,
|
||||
order=i,
|
||||
asset_id=asset_id,
|
||||
duration=duration,
|
||||
)
|
||||
)
|
||||
else:
|
||||
# one_take (default): N 个 main clips
|
||||
clips.append(
|
||||
VirtualClip(
|
||||
id=f"vc_{i:03d}",
|
||||
plan_id=plan_id,
|
||||
clip_type="main",
|
||||
order=i,
|
||||
asset_id=asset_id,
|
||||
duration=duration,
|
||||
)
|
||||
)
|
||||
|
||||
return clips
|
||||
|
||||
|
||||
# ── 错误信息构建 ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def build_error_info(error: Exception, stage: str = "render") -> dict[str, Any]:
|
||||
"""构建结构化错误信息。
|
||||
|
||||
Args:
|
||||
error: 异常对象
|
||||
stage: 发生错误的阶段
|
||||
|
||||
Returns:
|
||||
包含 error_type, message, stack_trace, stage, failed_at 的字典
|
||||
"""
|
||||
tb_str = traceback.format_exc()
|
||||
# 截取堆栈前20行,避免字段过大
|
||||
tb_lines = tb_str.strip().splitlines()
|
||||
if len(tb_lines) > 20:
|
||||
tb_summary = "\n".join(tb_lines[:20]) + f"\n... (truncated, total {len(tb_lines)} lines)"
|
||||
else:
|
||||
tb_summary = tb_str
|
||||
|
||||
return {
|
||||
"error_type": type(error).__name__,
|
||||
"message": str(error),
|
||||
"stack_trace": tb_summary,
|
||||
"stage": stage,
|
||||
"failed_at": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
Executable
+372
@@ -0,0 +1,372 @@
|
||||
"""Asset scoring pure logic — multi-dimensional scoring + diverse selection.
|
||||
|
||||
从 smart_asset_selector.py 抽出来的纯逻辑模块:
|
||||
- 评分维度:质量分、分辨率、时长、码率(加权求和,总分 0-1)
|
||||
- 多样性选择:按时长分桶(短/中/长)保证分布均匀
|
||||
- 数据类:AssetScoreDetail, SmartSelectResult
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
# ── 评分权重(总和 = 1.0) ────────────────────────────────────────────────────
|
||||
|
||||
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 AssetScoreDetail:
|
||||
"""单个素材的评分详情."""
|
||||
|
||||
asset_id: str
|
||||
total_score: float
|
||||
quality_score: float
|
||||
resolution_score: float
|
||||
duration_score: float
|
||||
bitrate_score: float
|
||||
duration: float | None
|
||||
|
||||
|
||||
@dataclass
|
||||
class SmartSelectResult:
|
||||
"""智能选择结果."""
|
||||
|
||||
selected_ids: list[str]
|
||||
total_candidates: int
|
||||
filtered_out: int # 被质量门槛过滤的数量
|
||||
avg_score: float
|
||||
details: list[AssetScoreDetail] = field(default_factory=list)
|
||||
|
||||
|
||||
# ── 评分函数 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def score_resolution(
|
||||
width: int | None,
|
||||
height: int | None,
|
||||
target_width: int = TARGET_WIDTH,
|
||||
target_height: int = TARGET_HEIGHT,
|
||||
) -> float:
|
||||
"""分辨率评分:越接近目标分辨率得分越高,低于480p扣分严重.
|
||||
|
||||
Args:
|
||||
width: 素材宽度(像素)
|
||||
height: 素材高度(像素)
|
||||
target_width: 目标宽度
|
||||
target_height: 目标高度
|
||||
|
||||
Returns:
|
||||
0.0 - 1.0 之间的评分
|
||||
"""
|
||||
if width is None or height is None or width <= 0 or height <= 0:
|
||||
return 0.5 # 未知分辨率给中评分
|
||||
|
||||
target_pixels = target_width * target_height
|
||||
actual_pixels = width * height
|
||||
|
||||
# 计算像素数比例
|
||||
ratio = actual_pixels / target_pixels
|
||||
|
||||
if ratio >= 1.0:
|
||||
# 高于或等于目标分辨率:满分,略高不扣分(4K也给满分)
|
||||
return 1.0
|
||||
else:
|
||||
# 低于目标分辨率:线性衰减,但最低不低于 0.1
|
||||
score = 0.3 + 0.7 * ratio
|
||||
return max(0.1, min(1.0, score))
|
||||
|
||||
|
||||
def score_duration(duration: float | None) -> float:
|
||||
"""时长评分:3-30秒最佳,过短或过长都扣分.
|
||||
|
||||
Args:
|
||||
duration: 时长(秒)
|
||||
|
||||
Returns:
|
||||
0.0 - 1.0 之间的评分
|
||||
"""
|
||||
if duration is None or duration <= 0:
|
||||
return 0.5 # 未知时长给中评分
|
||||
|
||||
if OPTIMAL_DURATION_MIN <= duration <= OPTIMAL_DURATION_MAX:
|
||||
# 最佳区间:满分
|
||||
return 1.0
|
||||
|
||||
if duration < OPTIMAL_DURATION_MIN:
|
||||
# 太短:线性衰减,趋近于 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(file_size: int, duration: float | None) -> float:
|
||||
"""码率评分:根据文件大小和时长估算码率,适中得分高.
|
||||
|
||||
Args:
|
||||
file_size: 文件大小(字节)
|
||||
duration: 时长(秒)
|
||||
|
||||
Returns:
|
||||
0.0 - 1.0 之间的评分
|
||||
"""
|
||||
if not file_size or not duration or duration <= 0:
|
||||
return 0.5 # 未知给中评分
|
||||
|
||||
# 估算码率(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
|
||||
|
||||
# 码率太高(文件太大):适度扣分,最低 0.5
|
||||
excess = bitrate / optimal_high - 1.0
|
||||
penalty = min(0.5, excess * 0.2)
|
||||
return max(0.5, 1.0 - penalty)
|
||||
|
||||
|
||||
def calculate_total_score(
|
||||
quality_score: float,
|
||||
resolution_score: float,
|
||||
duration_score: float,
|
||||
bitrate_score: float,
|
||||
) -> float:
|
||||
"""计算加权总分.
|
||||
|
||||
Args:
|
||||
quality_score: 质量分(0-1)
|
||||
resolution_score: 分辨率分(0-1)
|
||||
duration_score: 时长分(0-1)
|
||||
bitrate_score: 码率分(0-1)
|
||||
|
||||
Returns:
|
||||
加权总分(0-1)
|
||||
"""
|
||||
total = (
|
||||
WEIGHT_QUALITY * quality_score
|
||||
+ WEIGHT_RESOLUTION * resolution_score
|
||||
+ WEIGHT_DURATION * duration_score
|
||||
+ WEIGHT_BITRATE * bitrate_score
|
||||
)
|
||||
return round(total, 4)
|
||||
|
||||
|
||||
def score_asset_detail(
|
||||
asset_id: str,
|
||||
quality: float | None,
|
||||
width: int | None,
|
||||
height: int | None,
|
||||
duration: float | None,
|
||||
file_size: int,
|
||||
target_width: int = TARGET_WIDTH,
|
||||
target_height: int = TARGET_HEIGHT,
|
||||
) -> AssetScoreDetail:
|
||||
"""对单个素材进行多维度评分,返回详细评分结果.
|
||||
|
||||
Args:
|
||||
asset_id: 素材ID
|
||||
quality: 质量分(0-100,None表示未知)
|
||||
width: 宽度
|
||||
height: 高度
|
||||
duration: 时长
|
||||
file_size: 文件大小
|
||||
target_width: 目标宽度
|
||||
target_height: 目标高度
|
||||
|
||||
Returns:
|
||||
AssetScoreDetail 评分详情
|
||||
"""
|
||||
# 质量分归一化到 0-1
|
||||
quality_score = (quality / 100.0) if quality is not None else 0.5
|
||||
|
||||
resolution_score = score_resolution(width, height, target_width, target_height)
|
||||
duration_score = score_duration(duration)
|
||||
bitrate_score = score_bitrate(file_size, duration)
|
||||
|
||||
total_score = calculate_total_score(
|
||||
quality_score,
|
||||
resolution_score,
|
||||
duration_score,
|
||||
bitrate_score,
|
||||
)
|
||||
|
||||
return AssetScoreDetail(
|
||||
asset_id=asset_id,
|
||||
total_score=total_score,
|
||||
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 _bucket_by_duration(item: AssetScoreDetail) -> str:
|
||||
"""根据时长判断所属桶.
|
||||
|
||||
Returns:
|
||||
'short' / 'medium' / 'long' / 'unknown'
|
||||
"""
|
||||
if item.duration is None:
|
||||
return "unknown"
|
||||
if item.duration < SHORT_BUCKET_MAX:
|
||||
return "short"
|
||||
if item.duration < MEDIUM_BUCKET_MAX:
|
||||
return "medium"
|
||||
return "long"
|
||||
|
||||
|
||||
def diverse_selection(
|
||||
scored: list[AssetScoreDetail],
|
||||
count: int,
|
||||
) -> list[AssetScoreDetail]:
|
||||
"""多样性选择:按时长分桶,保证每个桶都有素材.
|
||||
|
||||
策略:
|
||||
1. 按时长分为三桶:短(<5s)、中(5-15s)、长(>=15s)
|
||||
2. 每个桶配额 = max(1, count // 3)
|
||||
3. 先从每桶按配额取最高分的
|
||||
4. 剩余名额从全局最高分中取(不重复)
|
||||
5. 如果还不够,加上未知时长的
|
||||
|
||||
Args:
|
||||
scored: 已按总分降序排列的评分列表
|
||||
count: 需要选取的数量
|
||||
|
||||
Returns:
|
||||
选中的评分列表(不超过 count 个)
|
||||
"""
|
||||
if count <= 0 or not scored:
|
||||
return []
|
||||
|
||||
# 分桶
|
||||
short_bucket = [d for d in scored if _bucket_by_duration(d) == "short"]
|
||||
medium_bucket = [d for d in scored if _bucket_by_duration(d) == "medium"]
|
||||
long_bucket = [d for d in scored if _bucket_by_duration(d) == "long"]
|
||||
unknown_bucket = [d for d in scored if _bucket_by_duration(d) == "unknown"]
|
||||
|
||||
buckets = [short_bucket, medium_bucket, long_bucket]
|
||||
|
||||
# 每个桶基础配额(至少1个,如果桶非空且需要的话)
|
||||
base_quota = max(1, count // 3)
|
||||
|
||||
selected: list[AssetScoreDetail] = []
|
||||
selected_ids: set[str] = set()
|
||||
|
||||
# 先按配额从每个桶取
|
||||
for bucket in buckets:
|
||||
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]
|
||||
|
||||
|
||||
# ── 候选过滤 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def filter_candidates(
|
||||
assets: list[Any],
|
||||
min_quality_score: float = MIN_QUALITY_SCORE,
|
||||
) -> tuple[list[Any], int]:
|
||||
"""从素材列表中筛选出合格的候选素材.
|
||||
|
||||
筛选条件:
|
||||
- status == 'ready'
|
||||
- mime_type 以 'video' 开头
|
||||
- quality_score >= min_quality_score(如果quality不为None)
|
||||
|
||||
Args:
|
||||
assets: 素材列表
|
||||
min_quality_score: 最低质量分门槛
|
||||
|
||||
Returns:
|
||||
(合格素材列表, 被质量门槛过滤的数量)
|
||||
"""
|
||||
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 < min_quality_score:
|
||||
filtered_out += 1
|
||||
continue
|
||||
|
||||
candidates.append(asset)
|
||||
|
||||
return candidates, filtered_out
|
||||
Executable
+347
@@ -0,0 +1,347 @@
|
||||
"""剪辑计划生成 — 纯逻辑工具函数.
|
||||
|
||||
从 PlanGeneratorService 提取的纯业务逻辑:
|
||||
- 素材分配策略(4 种 editing_mode)
|
||||
- 默认 clip 结构生成
|
||||
- clip_type 按模式映射
|
||||
- 从 TemplateClipConfig 创建 EditPlanClip
|
||||
|
||||
纯函数,无副作用,不依赖 DB/外部服务。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import List
|
||||
|
||||
from packages.domain.edit_plan_clip import EditPlanClip
|
||||
from packages.domain.editing_mode import EditingMode
|
||||
from packages.domain.template_clip_config import ClipType, TemplateClipConfig
|
||||
|
||||
# ── 默认片段时长(秒) ────────────────────────────────────────────────────────
|
||||
DEFAULT_CLIP_DURATION = 5.0
|
||||
DEFAULT_INTRO_DURATION = 3.0
|
||||
DEFAULT_OUTRO_DURATION = 3.0
|
||||
|
||||
|
||||
# ── 素材分配 ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def distribute_assets(
|
||||
clips: List[EditPlanClip],
|
||||
asset_ids: List[str],
|
||||
editing_mode: str,
|
||||
) -> None:
|
||||
"""按 editing_mode 将素材分配到 clips(就地修改).
|
||||
|
||||
分配策略:
|
||||
- ONE_TAKE: 素材按顺序依次分配给 main 类型 clips
|
||||
- PIP: 第1个素材→main(全屏背景),其余→overlay clips
|
||||
- VOICE_OVER: 素材→main clips (B-roll)
|
||||
- VOICE_PIP: 第1个→background, 第2个→corner_voice, 其余→b_roll
|
||||
|
||||
Args:
|
||||
clips: 剪辑片段列表(就地修改 asset_id)
|
||||
asset_ids: 素材 ID 列表
|
||||
editing_mode: 剪辑模式字符串
|
||||
"""
|
||||
if not asset_ids or not clips:
|
||||
return
|
||||
|
||||
if editing_mode == EditingMode.ONE_TAKE.value:
|
||||
_distribute_one_take(clips, asset_ids)
|
||||
elif editing_mode == EditingMode.PIP.value:
|
||||
_distribute_pip(clips, asset_ids)
|
||||
elif editing_mode == EditingMode.VOICE_OVER.value:
|
||||
_distribute_voice_over(clips, asset_ids)
|
||||
elif editing_mode == EditingMode.VOICE_PIP.value:
|
||||
_distribute_voice_pip(clips, asset_ids)
|
||||
else:
|
||||
# 未知模式,退化为 one_take
|
||||
_distribute_one_take(clips, asset_ids)
|
||||
|
||||
|
||||
def _distribute_one_take(
|
||||
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(
|
||||
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(
|
||||
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(
|
||||
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"]
|
||||
voice_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"]
|
||||
|
||||
idx = 0
|
||||
|
||||
# 第1个 → background
|
||||
if idx < len(asset_ids) and bg_clips:
|
||||
bg_clips[0].assign_asset(asset_ids[idx])
|
||||
idx += 1
|
||||
|
||||
# 第2个 → corner_voice
|
||||
if idx < len(asset_ids) and voice_clips:
|
||||
voice_clips[0].assign_asset(asset_ids[idx])
|
||||
idx += 1
|
||||
|
||||
# 剩余 → b_roll clips
|
||||
remaining = asset_ids[idx:]
|
||||
for i, clip in enumerate(broll_clips):
|
||||
if i < len(remaining):
|
||||
clip.assign_asset(remaining[i])
|
||||
|
||||
|
||||
# ── clip_type 映射 ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def map_clip_types_for_mode(
|
||||
clips: List[EditPlanClip],
|
||||
editing_mode: str,
|
||||
) -> None:
|
||||
"""将 MAIN 类型片段按 editing_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 不变
|
||||
|
||||
Args:
|
||||
clips: 剪辑片段列表(就地修改 clip_type)
|
||||
editing_mode: 剪辑模式字符串
|
||||
"""
|
||||
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 不变,无需处理
|
||||
|
||||
|
||||
# ── 默认 clip 生成 ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def generate_default_clips(
|
||||
plan_id: str,
|
||||
editing_mode: str,
|
||||
asset_count: int,
|
||||
) -> List[EditPlanClip]:
|
||||
"""无 clip_configs 时,根据 editing_mode 生成默认 clip 结构.
|
||||
|
||||
- ONE_TAKE: N 个 main clips(N = asset_count,至少1个)
|
||||
- PIP: 1 个 main + (N-1) 个 overlay(N = asset_count)
|
||||
- VOICE_OVER: N 个 main clips + 标记需要配音
|
||||
- VOICE_PIP: 1 个 background + 1 个 corner_voice + (N-2) 个 b_roll
|
||||
|
||||
Args:
|
||||
plan_id: 剪辑计划 ID
|
||||
editing_mode: 剪辑模式字符串
|
||||
asset_count: 素材数量
|
||||
|
||||
Returns:
|
||||
List[EditPlanClip]: 生成的默认剪辑片段列表
|
||||
"""
|
||||
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 clips(B-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(至少有1个素材就有)
|
||||
if n >= 2:
|
||||
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
|
||||
|
||||
|
||||
# ── 从配置创建 clips ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def create_clips_from_configs(
|
||||
plan_id: str,
|
||||
clip_configs: List[TemplateClipConfig],
|
||||
) -> List[EditPlanClip]:
|
||||
"""从 TemplateClipConfig 列表创建 EditPlanClip 列表.
|
||||
|
||||
Args:
|
||||
plan_id: 剪辑计划 ID
|
||||
clip_configs: 模板片段配置列表
|
||||
|
||||
Returns:
|
||||
List[EditPlanClip]: 创建的剪辑片段列表(按 order 排序)
|
||||
"""
|
||||
clips: List[EditPlanClip] = []
|
||||
# 按 order 排序
|
||||
sorted_configs = sorted(clip_configs, key=lambda c: c.order)
|
||||
|
||||
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
|
||||
Executable
+241
@@ -0,0 +1,241 @@
|
||||
"""渲染图层工具函数 — 纯函数集合.
|
||||
|
||||
从 unified_render_service.py 抽离的纯逻辑,负责:
|
||||
- clip 时长计算(有效时长、调速后时长)
|
||||
- clip_type → layer_role 映射
|
||||
- 总时长估算
|
||||
- 图层默认属性(z_index 等)
|
||||
|
||||
所有函数均为纯函数,不依赖 FFmpeg、数据库或外部 IO。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
# ── 图层角色定义 ─────────────────────────────────────────────────────────────
|
||||
|
||||
# 图层默认 z_index 映射
|
||||
LAYER_Z_INDEX: dict[str, int] = {
|
||||
"background": -1,
|
||||
"broll": 0,
|
||||
"main": 0,
|
||||
"overlay": 1,
|
||||
"corner_voice": 1,
|
||||
"audio": 2,
|
||||
}
|
||||
|
||||
# 图层默认 PiP 缩放比例(相对于主画面)
|
||||
PIP_DEFAULT_SCALE = 0.25
|
||||
|
||||
# 主视频图层角色(用于总时长计算、直通判断等)
|
||||
MAIN_LAYER_ROLES = frozenset({"main", "broll", "background"})
|
||||
|
||||
|
||||
# ── clip_type → layer_role 映射 ──────────────────────────────────────────────
|
||||
|
||||
|
||||
def resolve_layer_role(clip_type: str, config: dict[str, Any] | None = None) -> str:
|
||||
"""根据 clip_type 和 config.role 确定图层角色。
|
||||
|
||||
映射规则:
|
||||
intro / outro → "main"(按 order 排在首/尾)
|
||||
overlay → "overlay"(画中画叠加,z=1)
|
||||
corner_voice → "corner_voice"(右上角小窗,z=1)
|
||||
background → "background"(全屏底图,z=0)
|
||||
b_roll → "broll"(z=0)
|
||||
main + config.role=b_roll → "broll"
|
||||
main + config.role=audio → "audio"
|
||||
main (default) → "main"
|
||||
|
||||
Args:
|
||||
clip_type: 片段类型字符串
|
||||
config: 片段配置字典(可选)
|
||||
|
||||
Returns:
|
||||
图层角色字符串
|
||||
"""
|
||||
role = (config or {}).get("role", "") if config else ""
|
||||
|
||||
if clip_type in ("intro", "outro"):
|
||||
return "main"
|
||||
if clip_type == "overlay":
|
||||
return "overlay"
|
||||
if clip_type == "corner_voice":
|
||||
return "corner_voice"
|
||||
if clip_type == "background":
|
||||
return "background"
|
||||
if clip_type == "b_roll":
|
||||
return "broll"
|
||||
# main type
|
||||
if role == "b_roll":
|
||||
return "broll"
|
||||
if role == "audio":
|
||||
return "audio"
|
||||
return "main"
|
||||
|
||||
|
||||
def get_layer_z_index(role: str) -> int:
|
||||
"""获取图层角色的默认 z_index。
|
||||
|
||||
Args:
|
||||
role: 图层角色
|
||||
|
||||
Returns:
|
||||
z_index 值,未知角色返回 0
|
||||
"""
|
||||
return LAYER_Z_INDEX.get(role, 0)
|
||||
|
||||
|
||||
# ── clip 时长计算 ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def clip_effective_duration(
|
||||
duration: float,
|
||||
actual_duration: float = 0.0,
|
||||
) -> float:
|
||||
"""计算 clip 的有效时长(原速 trim 后时长)。
|
||||
|
||||
规则:
|
||||
- duration > 0: min(duration, actual_duration),actual=0 时用 duration
|
||||
- duration <= 0: actual_duration,actual=0 时返回 0
|
||||
|
||||
Args:
|
||||
duration: 配置的时长(0 表示使用完整素材)
|
||||
actual_duration: 素材实际时长(probe 后的结果)
|
||||
|
||||
Returns:
|
||||
有效时长(秒)
|
||||
"""
|
||||
if duration > 0:
|
||||
return min(duration, actual_duration) if actual_duration > 0 else duration
|
||||
return actual_duration if actual_duration > 0 else 0.0
|
||||
|
||||
|
||||
def clip_playback_speed(playback_speed: Any) -> float:
|
||||
"""获取 clip 的播放速度,无效值回退到 1.0。
|
||||
|
||||
Args:
|
||||
playback_speed: 播放速度(可为任意类型
|
||||
|
||||
Returns:
|
||||
有效的播放速度(正数)
|
||||
"""
|
||||
if not isinstance(playback_speed, (int, float)):
|
||||
return 1.0
|
||||
if playback_speed <= 0:
|
||||
return 1.0
|
||||
return float(playback_speed)
|
||||
|
||||
|
||||
def clip_adjusted_duration(
|
||||
duration: float,
|
||||
actual_duration: float = 0.0,
|
||||
playback_speed: Any = 1.0,
|
||||
) -> float:
|
||||
"""计算调速后的 clip 实际时长(用于拼接计算)。
|
||||
|
||||
Args:
|
||||
duration: 配置的时长
|
||||
actual_duration: 素材实际时长
|
||||
playback_speed: 播放速度
|
||||
|
||||
Returns:
|
||||
调速后的时长
|
||||
"""
|
||||
base = clip_effective_duration(duration, actual_duration)
|
||||
speed = clip_playback_speed(playback_speed)
|
||||
if abs(speed - 1.0) < 1e-6:
|
||||
return base
|
||||
return base / speed
|
||||
|
||||
|
||||
# ── 总时长估算 ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def estimate_total_duration(
|
||||
layers: list[Any],
|
||||
transition_duration: float = 0.0,
|
||||
) -> float:
|
||||
"""估算视频总时长。
|
||||
|
||||
取主图层(main/broll/background)的总调整后时长,减去转场重叠时间。
|
||||
|
||||
Args:
|
||||
layers: 图层列表(每个元素需有 role 和 clips 属性,
|
||||
clips 中元素需有 duration/actual_duration/playback_speed 属性)
|
||||
transition_duration: 转场时长(秒),用于估算重叠时间
|
||||
|
||||
Returns:
|
||||
估算的总时长(秒),最小 0.1
|
||||
"""
|
||||
# 找主图层(第一个有视频内容的图层)
|
||||
main_layer = None
|
||||
for role in ("main", "broll", "background"):
|
||||
for layer in layers:
|
||||
if getattr(layer, "role", None) == role and getattr(layer, "clips", None):
|
||||
main_layer = layer
|
||||
break
|
||||
if main_layer:
|
||||
break
|
||||
|
||||
if not main_layer or not getattr(main_layer, "clips", None):
|
||||
return 0.0
|
||||
|
||||
clips = getattr(main_layer, "clips", [])
|
||||
total = sum(
|
||||
clip_adjusted_duration(
|
||||
duration=getattr(c, "duration", 0),
|
||||
actual_duration=getattr(c, "actual_duration", 0.0),
|
||||
playback_speed=getattr(c, "playback_speed", 1.0),
|
||||
)
|
||||
for c in clips
|
||||
)
|
||||
|
||||
# 减去转场重叠时间(粗略估算)
|
||||
n_clips = len(clips)
|
||||
if n_clips > 1 and transition_duration > 0:
|
||||
total -= (n_clips - 1) * transition_duration
|
||||
|
||||
return max(0.1, total)
|
||||
|
||||
|
||||
# ── 直通 / Stream Copy 判断辅助 ──────────────────────────────────────────────
|
||||
|
||||
|
||||
def can_pass_through(
|
||||
layers: list[Any],
|
||||
has_stickers: bool = False,
|
||||
has_watermark: bool = False,
|
||||
) -> bool:
|
||||
"""判断是否可以走直通优化路径(单 clip 简单场景)。
|
||||
|
||||
条件:
|
||||
1. 只有 1 个图层
|
||||
2. 该图层是视频图层(main/broll/background)
|
||||
3. 该图层只有 1 个 clip(无转场需求)
|
||||
4. 没有贴纸
|
||||
5. 没有水印
|
||||
|
||||
Args:
|
||||
layers: 图层列表
|
||||
has_stickers: 是否有贴纸
|
||||
has_watermark: 是否有水印
|
||||
|
||||
Returns:
|
||||
是否可以走直通
|
||||
"""
|
||||
if len(layers) != 1:
|
||||
return False
|
||||
layer = layers[0]
|
||||
role = getattr(layer, "role", "")
|
||||
if role not in MAIN_LAYER_ROLES:
|
||||
return False
|
||||
clips = getattr(layer, "clips", [])
|
||||
if len(clips) != 1:
|
||||
return False
|
||||
if has_stickers:
|
||||
return False
|
||||
if has_watermark:
|
||||
return False
|
||||
return True
|
||||
@@ -6,6 +6,7 @@ API 层和 Worker 层都从此模块导入,避免 API 直接依赖 Worker 代
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import json
|
||||
import logging
|
||||
import random
|
||||
@@ -87,7 +88,7 @@ def _fallback_recommend_clips(
|
||||
order += 1
|
||||
|
||||
# 生成推荐 config
|
||||
config = DEFAULT_EDIT_PLAN_CONFIG.copy()
|
||||
config = copy.deepcopy(DEFAULT_EDIT_PLAN_CONFIG)
|
||||
config["title"]["text"] = f"精选视频 — {len(asset_ids)} 个片段"
|
||||
config["title"]["ai_auto"] = True
|
||||
|
||||
@@ -167,7 +168,7 @@ def _parse_recommend_response(
|
||||
for i, clip in enumerate(clips):
|
||||
clip["order"] = i
|
||||
|
||||
config = DEFAULT_EDIT_PLAN_CONFIG.copy()
|
||||
config = copy.deepcopy(DEFAULT_EDIT_PLAN_CONFIG)
|
||||
title = data.get("title", "")
|
||||
if title:
|
||||
config["title"]["text"] = str(title)
|
||||
|
||||
@@ -45,6 +45,9 @@ for i in 1 2 3; do
|
||||
done
|
||||
pytest --version
|
||||
|
||||
# --- 安装 ffmpeg(视频处理相关测试依赖)---
|
||||
bash scripts/ci/step_install_ffmpeg.sh
|
||||
|
||||
# 双保险:确保numpy已安装
|
||||
python3 -m pip install -q numpy==1.26.4 || true
|
||||
|
||||
|
||||
@@ -56,95 +56,18 @@ for fpath, items in data.get('results', {}).items():
|
||||
fi
|
||||
echo "✅ Secret scan passed"
|
||||
|
||||
# --- 增量/全量模式判断 ---
|
||||
# --- 代码质量检查(全量,PR 和 push 统一标准)---
|
||||
# 历史:PR 侧用增量检查以加速,但会导致 push 侧全量检查失败时 PR 侧感知不到
|
||||
# 现在统一全量检查,确保 CI 真正保护主分支(black/isort/ruff 全量仅多几十秒)
|
||||
echo ""
|
||||
echo "=== [2/6] Code quality checks ==="
|
||||
echo "=== [2/6] Code quality checks (full scan) ==="
|
||||
SCAN_MODE="full"
|
||||
CHANGED_PY_FILES=""
|
||||
echo "Full scan mode"
|
||||
python3 -m compileall -q alembic apps packages tests scripts
|
||||
python3 -m black --check --fast alembic apps packages tests scripts
|
||||
python3 -m isort --check-only alembic apps packages tests scripts
|
||||
python3 -m ruff check apps packages tests --statistics
|
||||
|
||||
if [ "${GITHUB_EVENT_NAME:-}" = "pull_request" ] && [ -n "${GITHUB_REF_NAME:-}" ] && [ -n "${GITHUB_TOKEN:-}" ]; then
|
||||
PR_NUMBER=$(echo "$GITHUB_REF" | sed 's|refs/pull/||; s|/.*||')
|
||||
API_URL="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/files?limit=100"
|
||||
set +e
|
||||
RESPONSE=$(curl -s -w "\n%{http_code}" -H "Authorization: token ${GITHUB_TOKEN}" "$API_URL")
|
||||
HTTP_CODE=$(echo "$RESPONSE" | tail -n1)
|
||||
BODY=$(echo "$RESPONSE" | sed '$d')
|
||||
set -e
|
||||
if [ "$HTTP_CODE" = "200" ]; then
|
||||
CHANGED_PY_FILES=$(echo "$BODY" | python3 -c "
|
||||
import json, sys
|
||||
try:
|
||||
files = json.load(sys.stdin)
|
||||
py_files = [f['filename'] for f in files if f['filename'].endswith('.py') and f['status'] != 'removed']
|
||||
print(' '.join(py_files))
|
||||
except Exception:
|
||||
print('')
|
||||
")
|
||||
# 新增文件(added)强制全量检查,防止增量漏检
|
||||
ADDED_PY_FILES=$(echo "$BODY" | python3 -c "
|
||||
import json, sys
|
||||
try:
|
||||
files = json.load(sys.stdin)
|
||||
added = [f['filename'] for f in files if f['filename'].endswith('.py') and f['status'] == 'added']
|
||||
print(' '.join(added))
|
||||
except Exception:
|
||||
print('')
|
||||
")
|
||||
MODIFIED_PY_FILES=$(echo "$BODY" | python3 -c "
|
||||
import json, sys
|
||||
try:
|
||||
files = json.load(sys.stdin)
|
||||
modified = [f['filename'] for f in files if f['filename'].endswith('.py') and f['status'] not in ('removed', 'added')]
|
||||
print(' '.join(modified))
|
||||
except Exception:
|
||||
print('')
|
||||
")
|
||||
if [ -n "$CHANGED_PY_FILES" ]; then
|
||||
SCAN_MODE="incremental"
|
||||
echo "Incremental mode: $(echo "$CHANGED_PY_FILES" | wc -w) Python files changed"
|
||||
else
|
||||
SCAN_MODE="skip_py"
|
||||
echo "No Python files changed in this PR"
|
||||
fi
|
||||
else
|
||||
echo "WARN: API returned HTTP $HTTP_CODE, falling back to full scan"
|
||||
fi
|
||||
else
|
||||
echo "Full scan mode (not a PR event)"
|
||||
fi
|
||||
|
||||
if [ "$SCAN_MODE" = "incremental" ]; then
|
||||
# 防御性过滤
|
||||
EXISTING_PY_FILES=""
|
||||
for f in $CHANGED_PY_FILES; do
|
||||
if [ -f "$f" ]; then
|
||||
if [ -z "$EXISTING_PY_FILES" ]; then
|
||||
EXISTING_PY_FILES="$f"
|
||||
else
|
||||
EXISTING_PY_FILES="$EXISTING_PY_FILES $f"
|
||||
fi
|
||||
fi
|
||||
done
|
||||
CHANGED_PY_FILES="$EXISTING_PY_FILES"
|
||||
|
||||
python3 -m compileall -q $CHANGED_PY_FILES
|
||||
python3 -m black --check --fast $CHANGED_PY_FILES
|
||||
python3 -m isort --check-only $CHANGED_PY_FILES
|
||||
RUFF_FILES=$(echo "$CHANGED_PY_FILES" | tr ' ' '\n' | grep -v '^scripts/' | grep -v '^$' | xargs)
|
||||
if [ -n "$RUFF_FILES" ]; then
|
||||
python3 -m ruff check $RUFF_FILES --statistics
|
||||
else
|
||||
echo "No ruff-checkable files changed, skipping"
|
||||
fi
|
||||
elif [ "$SCAN_MODE" = "skip_py" ]; then
|
||||
echo "No Python files changed - skipping Python lint checks"
|
||||
else
|
||||
echo "Full scan mode"
|
||||
python3 -m compileall -q alembic apps packages tests scripts
|
||||
python3 -m black --check --fast alembic apps packages tests scripts
|
||||
python3 -m isort --check-only alembic apps packages tests scripts
|
||||
python3 -m ruff check apps packages tests --statistics
|
||||
fi
|
||||
echo "✅ Code quality checks passed"
|
||||
|
||||
# --- Bandit 安全扫描(仅告警) ---
|
||||
|
||||
Executable
+667
@@ -0,0 +1,667 @@
|
||||
"""asset_quality_scoring 纯逻辑单测 — 第89波.
|
||||
|
||||
测试评分纯函数,不依赖 FFmpeg/视频文件。
|
||||
覆盖:分辨率评分、帧率评分、码率评分、清晰度评分、稳定性评分、
|
||||
质量总评分、9分类评分、分类结果计算。
|
||||
"""
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from apps.worker.worker_app.tasks.asset_quality_scoring import (
|
||||
AudioAnalysis,
|
||||
ClassificationResult,
|
||||
ColorAnalysis,
|
||||
MotionAnalysis,
|
||||
QualityScore,
|
||||
VideoInfo,
|
||||
calculate_category_scores,
|
||||
calculate_quality_score,
|
||||
classify_from_analysis,
|
||||
score_bitrate,
|
||||
score_clarity,
|
||||
score_framerate,
|
||||
score_resolution,
|
||||
score_stability,
|
||||
)
|
||||
from packages.domain.classification import AssetClassification
|
||||
|
||||
# ── 分辨率评分 ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestScoreResolution:
|
||||
"""分辨率评分边界测试."""
|
||||
|
||||
def test_4k_full_score(self):
|
||||
"""4K 及以上满分 25."""
|
||||
assert score_resolution(3840, 2160) == 25.0
|
||||
assert score_resolution(4096, 2160) == 25.0
|
||||
assert score_resolution(7680, 4320) == 25.0 # 8K
|
||||
|
||||
def test_2k_score(self):
|
||||
"""2K 档 22 分."""
|
||||
assert score_resolution(2560, 1440) == 22.0
|
||||
assert score_resolution(3000, 1600) == 22.0
|
||||
# 刚好低于 4K
|
||||
assert score_resolution(3839, 2159) == 22.0
|
||||
|
||||
def test_1080p_score(self):
|
||||
"""1080p 档 20 分."""
|
||||
assert score_resolution(1920, 1080) == 20.0
|
||||
assert score_resolution(2000, 1080) == 20.0
|
||||
# 刚好低于 2K
|
||||
assert score_resolution(2559, 1439) == 20.0
|
||||
|
||||
def test_720p_score(self):
|
||||
"""720p 档 15 分."""
|
||||
assert score_resolution(1280, 720) == 15.0
|
||||
assert score_resolution(1280, 720) == 15.0
|
||||
# 刚好低于 1080p
|
||||
assert score_resolution(1919, 1079) == 15.0
|
||||
# 1080x720 像素数 < 1280x720,掉到 480p 档
|
||||
assert score_resolution(1080, 720) == 8.0
|
||||
|
||||
def test_480p_score(self):
|
||||
"""480p 档 8 分."""
|
||||
assert score_resolution(854, 480) == 8.0
|
||||
assert score_resolution(854, 480) == 8.0
|
||||
# 刚好低于 720p
|
||||
assert score_resolution(1279, 719) == 8.0
|
||||
# 720x480 像素数 < 854x480,掉到最低档
|
||||
assert score_resolution(720, 480) == 3.0
|
||||
|
||||
def test_low_resolution_score(self):
|
||||
"""低于 480p 给 3 分."""
|
||||
assert score_resolution(640, 360) == 3.0
|
||||
assert score_resolution(320, 240) == 3.0
|
||||
assert score_resolution(0, 0) == 3.0
|
||||
|
||||
def test_non_standard_aspect_ratio(self):
|
||||
"""非标准宽高比按像素总数计算."""
|
||||
# 竖屏 1080x1920 像素数 = 1080p
|
||||
assert score_resolution(1080, 1920) == 20.0
|
||||
# 超宽屏
|
||||
assert score_resolution(2560, 1080) == 20.0 # 像素≈2.7M < 2K(3.6M)
|
||||
# 1x1 极低分辨率
|
||||
assert score_resolution(1, 1) == 3.0
|
||||
|
||||
def test_negative_values(self):
|
||||
"""负尺寸:负负得正按像素数算,一正一负 = 负数像素 = 最低档."""
|
||||
# 一正一负 → 负像素总数 → < 480p → 3分
|
||||
assert score_resolution(1920, -1080) == 3.0
|
||||
assert score_resolution(-1920, 1080) == 3.0
|
||||
# 都是 0 → 3分
|
||||
assert score_resolution(0, 0) == 3.0
|
||||
|
||||
|
||||
# ── 帧率评分 ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestScoreFramerate:
|
||||
"""帧率评分边界测试."""
|
||||
|
||||
def test_60fps_full_score(self):
|
||||
"""60fps 及以上满分 20."""
|
||||
assert score_framerate(60) == 20.0
|
||||
assert score_framerate(120) == 20.0
|
||||
assert score_framerate(240) == 20.0
|
||||
|
||||
def test_30fps_score(self):
|
||||
"""30-59fps 给 15 分."""
|
||||
assert score_framerate(30) == 15.0
|
||||
assert score_framerate(59.9) == 15.0
|
||||
assert score_framerate(59) == 15.0
|
||||
|
||||
def test_24fps_score(self):
|
||||
"""24-29fps 给 10 分."""
|
||||
assert score_framerate(24) == 10.0
|
||||
assert score_framerate(29.97) == 10.0
|
||||
assert score_framerate(25) == 10.0
|
||||
|
||||
def test_15fps_score(self):
|
||||
"""15-23fps 给 7 分."""
|
||||
assert score_framerate(15) == 7.0
|
||||
assert score_framerate(23.9) == 7.0
|
||||
assert score_framerate(20) == 7.0
|
||||
|
||||
def test_low_fps_score(self):
|
||||
"""低于 15fps 给 5 分."""
|
||||
assert score_framerate(10) == 5.0
|
||||
assert score_framerate(1) == 5.0
|
||||
assert score_framerate(0) == 5.0
|
||||
|
||||
def test_negative_fps(self):
|
||||
"""负帧率按最低档."""
|
||||
assert score_framerate(-30) == 5.0
|
||||
|
||||
def test_float_fps(self):
|
||||
"""浮点帧率正确判断边界."""
|
||||
# 29.97 (NTSC) < 30 → 24fps 档
|
||||
assert score_framerate(29.97) == 10.0
|
||||
assert score_framerate(23.976) == 7.0
|
||||
assert score_framerate(59.94) == 15.0 # 59.94 < 60 → 30fps 档
|
||||
assert score_framerate(30.0) == 15.0
|
||||
|
||||
|
||||
# ── 码率评分 ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestScoreBitrate:
|
||||
"""码率评分边界测试."""
|
||||
|
||||
def test_high_bitrate_full_score(self):
|
||||
"""10Mbps 以上满分 20."""
|
||||
assert score_bitrate(10_000_001) == 20.0
|
||||
assert score_bitrate(50_000_000) == 20.0
|
||||
assert score_bitrate(100_000_000) == 20.0
|
||||
|
||||
def test_5mbps_score(self):
|
||||
"""5-10Mbps 给 15 分."""
|
||||
assert score_bitrate(5_000_000) == 15.0
|
||||
assert score_bitrate(8_000_000) == 15.0
|
||||
assert score_bitrate(10_000_000) == 15.0 # 刚好 10Mbps = 不 > 10
|
||||
|
||||
def test_2mbps_score(self):
|
||||
"""2-5Mbps 给 10 分."""
|
||||
assert score_bitrate(2_000_000) == 10.0
|
||||
assert score_bitrate(3_000_000) == 10.0
|
||||
assert score_bitrate(4_999_999) == 10.0
|
||||
|
||||
def test_05mbps_score(self):
|
||||
"""0.5-2Mbps 给 5 分."""
|
||||
assert score_bitrate(500_000) == 5.0
|
||||
assert score_bitrate(1_000_000) == 5.0
|
||||
assert score_bitrate(1_999_999) == 5.0
|
||||
|
||||
def test_low_bitrate_score(self):
|
||||
"""低于 0.5Mbps 给 3 分."""
|
||||
assert score_bitrate(499_999) == 3.0
|
||||
assert score_bitrate(100_000) == 3.0
|
||||
assert score_bitrate(0) == 3.0
|
||||
|
||||
def test_negative_bitrate(self):
|
||||
"""负码率按最低档."""
|
||||
assert score_bitrate(-5_000_000) == 3.0
|
||||
|
||||
def test_zero_bitrate(self):
|
||||
"""0 码率 = 最低档."""
|
||||
assert score_bitrate(0) == 3.0
|
||||
|
||||
|
||||
# ── 清晰度评分 ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestScoreClarity:
|
||||
"""清晰度评分测试."""
|
||||
|
||||
def test_empty_frames_default_score(self):
|
||||
"""空帧列表给默认 10 分."""
|
||||
assert score_clarity([]) == 10.0
|
||||
|
||||
def test_constant_image_low_clarity(self):
|
||||
"""纯色图像比高细节图像清晰度低很多."""
|
||||
# 纯灰色图像
|
||||
gray_frame = np.full((100, 100), 128, dtype=np.uint8)
|
||||
score_constant = score_clarity([gray_frame])
|
||||
|
||||
# 高细节随机图像
|
||||
detail_frame = np.random.randint(0, 256, (100, 100), dtype=np.uint8)
|
||||
score_detail = score_clarity([detail_frame])
|
||||
|
||||
# 纯色图应该显著低于高细节图
|
||||
assert score_constant < score_detail
|
||||
assert 0.0 <= score_constant <= 20.0
|
||||
|
||||
def test_edge_rich_image_high_clarity(self):
|
||||
"""高频边缘图像有较高清晰度得分."""
|
||||
# 棋盘格图案,边缘丰富
|
||||
frame = np.zeros((100, 100), dtype=np.uint8)
|
||||
for i in range(0, 100, 10):
|
||||
for j in range(0, 100, 10):
|
||||
if (i // 10 + j // 10) % 2 == 0:
|
||||
frame[i : i + 10, j : j + 10] = 255
|
||||
score = score_clarity([frame])
|
||||
assert score > 1.0 # 应有一定清晰度
|
||||
assert 0.0 <= score <= 20.0
|
||||
|
||||
def test_rgb_frame_converts_to_gray(self):
|
||||
"""RGB 帧会被转灰度后计算."""
|
||||
rgb_frame = np.random.randint(0, 256, (50, 50, 3), dtype=np.uint8)
|
||||
score_rgb = score_clarity([rgb_frame])
|
||||
# 对应灰度图
|
||||
gray = np.dot(rgb_frame[..., :3], [0.299, 0.587, 0.114]).astype(np.uint8)
|
||||
score_gray = score_clarity([gray])
|
||||
# 两者应近似相等
|
||||
assert abs(score_rgb - score_gray) < 0.01
|
||||
|
||||
def test_only_first_five_frames_analyzed(self):
|
||||
"""只分析前 5 帧."""
|
||||
# 10 帧:前 5 帧纯色,后 5 帧高对比度
|
||||
frames = []
|
||||
for _ in range(5):
|
||||
frames.append(np.full((50, 50), 128, dtype=np.uint8))
|
||||
for _ in range(5):
|
||||
high_freq = np.random.randint(0, 256, (50, 50), dtype=np.uint8)
|
||||
frames.append(high_freq)
|
||||
score_10 = score_clarity(frames)
|
||||
score_5 = score_clarity(frames[:5])
|
||||
# 前 5 帧相同,得分应相同
|
||||
assert abs(score_10 - score_5) < 0.01
|
||||
|
||||
def test_score_within_bounds(self):
|
||||
"""得分始终在 0-20 范围内."""
|
||||
for _ in range(10):
|
||||
frame = np.random.randint(0, 256, (30, 30, 3), dtype=np.uint8)
|
||||
score = score_clarity([frame])
|
||||
assert 0.0 <= score <= 20.0
|
||||
|
||||
def test_multiple_frames_averaged(self):
|
||||
"""多帧取平均方差."""
|
||||
# 第 1 帧低细节,第 2 帧高细节
|
||||
low_detail = np.full((50, 50), 100, dtype=np.uint8)
|
||||
high_detail = np.random.randint(0, 256, (50, 50), dtype=np.uint8)
|
||||
|
||||
score_low = score_clarity([low_detail])
|
||||
score_high = score_clarity([high_detail])
|
||||
score_both = score_clarity([low_detail, high_detail])
|
||||
|
||||
# 混合得分应在两者之间
|
||||
assert score_low <= score_both <= score_high
|
||||
|
||||
|
||||
# ── 稳定性评分 ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestScoreStability:
|
||||
"""稳定性评分测试."""
|
||||
|
||||
def test_single_frame_default_score(self):
|
||||
"""不足 2 帧给默认 10 分."""
|
||||
assert score_stability([]) == 10.0
|
||||
assert score_stability([np.zeros((10, 10, 3), dtype=np.uint8)]) == 10.0
|
||||
|
||||
def test_identical_frames_max_stability(self):
|
||||
"""完全相同的帧 = 高稳定性."""
|
||||
frame = np.random.randint(50, 200, (100, 100, 3), dtype=np.uint8)
|
||||
score = score_stability([frame, frame.copy()])
|
||||
assert score > 10.0 # 应该接近满分 15
|
||||
|
||||
def test_very_different_frames_low_stability(self):
|
||||
"""位移方差大的多帧序列 = 低稳定性."""
|
||||
# 构造 4 帧:帧间位移差异大(有的帧相似、有的帧完全不同)
|
||||
# 位移方差大 → 不稳定 → 低分
|
||||
base = np.random.randint(100, 150, (100, 100, 3), dtype=np.uint8)
|
||||
frames = [
|
||||
base, # 帧0
|
||||
base, # 帧1 (完全相同 → 位移0)
|
||||
np.full_like(base, 255), # 帧2 (纯白 → 位移大)
|
||||
base, # 帧3 (回到基准 → 位移又大)
|
||||
]
|
||||
score = score_stability(frames)
|
||||
# 位移差异大 → 方差大 → 稳定性低
|
||||
assert score < 10.0
|
||||
assert 0.0 <= score <= 15.0
|
||||
|
||||
def test_score_within_bounds(self):
|
||||
"""得分始终在 0-15 范围内."""
|
||||
for _ in range(10):
|
||||
f1 = np.random.randint(0, 256, (40, 40, 3), dtype=np.uint8)
|
||||
f2 = np.random.randint(0, 256, (40, 40, 3), dtype=np.uint8)
|
||||
score = score_stability([f1, f2])
|
||||
assert 0.0 <= score <= 15.0
|
||||
|
||||
def test_gray_frames_also_work(self):
|
||||
"""灰度帧也能计算."""
|
||||
f1 = np.random.randint(0, 256, (50, 50), dtype=np.uint8)
|
||||
f2 = np.random.randint(0, 256, (50, 50), dtype=np.uint8)
|
||||
score = score_stability([f1, f2])
|
||||
assert 0.0 <= score <= 15.0
|
||||
|
||||
def test_multiple_frame_pairs(self):
|
||||
"""多对帧取方差."""
|
||||
base = np.random.randint(100, 150, (60, 60, 3), dtype=np.uint8)
|
||||
# 5 帧相似的
|
||||
frames = []
|
||||
for i in range(5):
|
||||
f = base.copy()
|
||||
# 轻微变化
|
||||
f = np.clip(f.astype(int) + np.random.randint(-5, 6, f.shape), 0, 255).astype(np.uint8)
|
||||
frames.append(f)
|
||||
score = score_stability(frames)
|
||||
assert score > 5.0 # 相似帧应该有一定稳定性
|
||||
|
||||
|
||||
# ── 质量总评分 ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestCalculateQualityScore:
|
||||
"""质量综合评分测试."""
|
||||
|
||||
def test_perfect_video_near_100(self):
|
||||
"""完美参数的视频接近 100 分."""
|
||||
info = VideoInfo(
|
||||
width=3840,
|
||||
height=2160,
|
||||
fps=60,
|
||||
bitrate=20_000_000,
|
||||
)
|
||||
# 用高细节帧提升清晰度分
|
||||
frame = np.random.randint(0, 256, (100, 100, 3), dtype=np.uint8)
|
||||
result = calculate_quality_score(info, [frame])
|
||||
assert isinstance(result, QualityScore)
|
||||
assert result.resolution_score == 25.0
|
||||
assert result.fps_score == 20.0
|
||||
assert result.bitrate_score == 20.0
|
||||
assert 50.0 <= result.total <= 100.0
|
||||
|
||||
def test_low_quality_video(self):
|
||||
"""低质量视频得分低."""
|
||||
info = VideoInfo(
|
||||
width=320,
|
||||
height=240,
|
||||
fps=10,
|
||||
bitrate=100_000,
|
||||
)
|
||||
result = calculate_quality_score(info, [])
|
||||
assert isinstance(result, QualityScore)
|
||||
assert result.resolution_score == 3.0
|
||||
assert result.fps_score == 5.0
|
||||
assert result.bitrate_score == 3.0
|
||||
# 无帧时清晰度和稳定性各给 10 分默认
|
||||
assert result.clarity_score == 10.0
|
||||
assert result.stability_score == 10.0
|
||||
assert result.total == 31.0 # 3+5+3+10+10
|
||||
|
||||
def test_no_frames_uses_defaults(self):
|
||||
"""不传 frames 时清晰度/稳定性给默认分."""
|
||||
info = VideoInfo(width=1920, height=1080, fps=30, bitrate=5_000_000)
|
||||
result = calculate_quality_score(info)
|
||||
assert result.clarity_score == 10.0
|
||||
assert result.stability_score == 10.0
|
||||
assert result.resolution_score == 20.0
|
||||
assert result.fps_score == 15.0
|
||||
assert result.bitrate_score == 15.0
|
||||
assert result.total == 70.0
|
||||
|
||||
def test_total_capped_at_100(self):
|
||||
"""总分不超过 100."""
|
||||
info = VideoInfo(
|
||||
width=7680,
|
||||
height=4320,
|
||||
fps=240,
|
||||
bitrate=100_000_000,
|
||||
)
|
||||
# 即使所有维度都满,总分不超 100
|
||||
result = calculate_quality_score(info, [])
|
||||
assert result.total <= 100.0
|
||||
|
||||
def test_total_minimum_zero(self):
|
||||
"""总分不低于 0."""
|
||||
info = VideoInfo(width=0, height=0, fps=0, bitrate=0)
|
||||
result = calculate_quality_score(info, [])
|
||||
assert result.total >= 0.0
|
||||
|
||||
def test_total_is_rounded(self):
|
||||
"""总分保留 1 位小数."""
|
||||
info = VideoInfo(width=1920, height=1080, fps=30, bitrate=5_000_000)
|
||||
result = calculate_quality_score(info, [])
|
||||
# 检查是 1 位小数
|
||||
assert round(result.total, 1) == result.total
|
||||
|
||||
|
||||
# ── 分类评分 ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestCalculateCategoryScores:
|
||||
"""分类评分计算测试."""
|
||||
|
||||
def test_scenic_high_green_and_motion(self):
|
||||
"""绿色+适度运动+自然音 → 风景高分."""
|
||||
color = ColorAnalysis(
|
||||
green_ratio=0.5,
|
||||
avg_saturation=0.5,
|
||||
avg_brightness=0.6,
|
||||
warm_ratio=0.2,
|
||||
cool_ratio=0.3,
|
||||
)
|
||||
motion = MotionAnalysis(motion_score=0.3, scene_changes=1)
|
||||
audio = AudioAnalysis(has_audio=True, ambient_ratio=0.7)
|
||||
scores = calculate_category_scores(color, motion, audio)
|
||||
assert scores[AssetClassification.SCENIC.value] > 0.5
|
||||
assert scores[AssetClassification.SCENIC.value] <= 1.0
|
||||
|
||||
def test_product_low_motion_cool_tone(self):
|
||||
"""低运动+冷色调 → 产品高分."""
|
||||
color = ColorAnalysis(
|
||||
avg_brightness=0.5,
|
||||
avg_saturation=0.3,
|
||||
cool_ratio=0.5,
|
||||
green_ratio=0.1,
|
||||
warm_ratio=0.2,
|
||||
)
|
||||
motion = MotionAnalysis(motion_score=0.1, scene_changes=0)
|
||||
audio = AudioAnalysis(has_audio=False)
|
||||
scores = calculate_category_scores(color, motion, audio)
|
||||
assert scores[AssetClassification.PRODUCT.value] > 0.5
|
||||
|
||||
def test_person_with_speech(self):
|
||||
"""有人声+适度运动 → 人物高分."""
|
||||
color = ColorAnalysis(avg_brightness=0.5)
|
||||
motion = MotionAnalysis(motion_score=0.25, scene_changes=1)
|
||||
audio = AudioAnalysis(has_audio=True, speech_ratio=0.6)
|
||||
scores = calculate_category_scores(color, motion, audio)
|
||||
assert scores[AssetClassification.PERSON.value] > 0.5
|
||||
|
||||
def test_animal_high_motion(self):
|
||||
"""高运动+多场景切换 → 动物高分."""
|
||||
color = ColorAnalysis()
|
||||
motion = MotionAnalysis(motion_score=0.6, scene_changes=5)
|
||||
audio = AudioAnalysis(has_audio=True, ambient_ratio=0.5)
|
||||
scores = calculate_category_scores(color, motion, audio)
|
||||
assert scores[AssetClassification.ANIMAL.value] > 0.5
|
||||
|
||||
def test_food_warm_saturated(self):
|
||||
"""暖色调+高饱和 → 美食高分."""
|
||||
color = ColorAnalysis(
|
||||
warm_ratio=0.6,
|
||||
avg_saturation=0.7,
|
||||
avg_brightness=0.6,
|
||||
green_ratio=0.1,
|
||||
cool_ratio=0.2,
|
||||
)
|
||||
motion = MotionAnalysis(motion_score=0.1, scene_changes=0)
|
||||
audio = AudioAnalysis(has_audio=False)
|
||||
scores = calculate_category_scores(color, motion, audio)
|
||||
assert scores[AssetClassification.FOOD.value] > 0.5
|
||||
|
||||
def test_tech_cool_low_saturation(self):
|
||||
"""冷色调+低饱和+低运动 → 科技高分."""
|
||||
color = ColorAnalysis(
|
||||
cool_ratio=0.6,
|
||||
avg_saturation=0.3,
|
||||
avg_brightness=0.5,
|
||||
green_ratio=0.1,
|
||||
warm_ratio=0.2,
|
||||
)
|
||||
motion = MotionAnalysis(motion_score=0.1, scene_changes=0)
|
||||
audio = AudioAnalysis(has_audio=False)
|
||||
scores = calculate_category_scores(color, motion, audio)
|
||||
assert scores[AssetClassification.TECH.value] > 0.5
|
||||
|
||||
def test_sport_high_motion(self):
|
||||
"""高运动+多场景 → 运动高分."""
|
||||
color = ColorAnalysis(avg_brightness=0.6)
|
||||
motion = MotionAnalysis(motion_score=0.7, scene_changes=5)
|
||||
audio = AudioAnalysis(has_audio=False)
|
||||
scores = calculate_category_scores(color, motion, audio)
|
||||
assert scores[AssetClassification.SPORT.value] > 0.5
|
||||
|
||||
def test_music_high_music_ratio(self):
|
||||
"""高音乐比例 → 音乐高分."""
|
||||
color = ColorAnalysis(avg_saturation=0.6, green_ratio=0.1)
|
||||
motion = MotionAnalysis(motion_score=0.2)
|
||||
audio = AudioAnalysis(has_audio=True, music_ratio=0.7)
|
||||
scores = calculate_category_scores(color, motion, audio)
|
||||
assert scores[AssetClassification.MUSIC.value] > 0.5
|
||||
|
||||
def test_other_has_base_score(self):
|
||||
"""其他分类有基础分 0.1."""
|
||||
color = ColorAnalysis()
|
||||
motion = MotionAnalysis()
|
||||
audio = AudioAnalysis()
|
||||
scores = calculate_category_scores(color, motion, audio)
|
||||
assert scores[AssetClassification.OTHER.value] == 0.1
|
||||
|
||||
def test_all_scores_within_bounds(self):
|
||||
"""所有分类得分都在 0-1 范围内."""
|
||||
color = ColorAnalysis(
|
||||
green_ratio=0.9,
|
||||
warm_ratio=0.9,
|
||||
cool_ratio=0.9,
|
||||
avg_saturation=0.99,
|
||||
avg_brightness=0.99,
|
||||
)
|
||||
motion = MotionAnalysis(motion_score=0.9, scene_changes=100)
|
||||
audio = AudioAnalysis(
|
||||
has_audio=True,
|
||||
speech_ratio=0.99,
|
||||
music_ratio=0.99,
|
||||
ambient_ratio=0.99,
|
||||
)
|
||||
scores = calculate_category_scores(color, motion, audio)
|
||||
for cat, score in scores.items():
|
||||
assert 0.0 <= score <= 1.0, f"{cat} score {score} out of bounds"
|
||||
|
||||
def test_all_nine_categories_present(self):
|
||||
"""返回 9 个分类的得分."""
|
||||
color = ColorAnalysis()
|
||||
motion = MotionAnalysis()
|
||||
audio = AudioAnalysis()
|
||||
scores = calculate_category_scores(color, motion, audio)
|
||||
assert len(scores) == 9
|
||||
|
||||
|
||||
# ── 分类结果计算 ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestClassifyFromAnalysis:
|
||||
"""分类结果计算测试."""
|
||||
|
||||
def test_returns_classification_result(self):
|
||||
"""返回 ClassificationResult 对象."""
|
||||
color = ColorAnalysis()
|
||||
motion = MotionAnalysis()
|
||||
audio = AudioAnalysis()
|
||||
result = classify_from_analysis(color, motion, audio)
|
||||
assert isinstance(result, ClassificationResult)
|
||||
assert isinstance(result.category, AssetClassification)
|
||||
assert isinstance(result.confidence, float)
|
||||
assert isinstance(result.scores, dict)
|
||||
|
||||
def test_highest_score_wins(self):
|
||||
"""得分最高的分类胜出."""
|
||||
# 构造明显偏向风景的特征
|
||||
color = ColorAnalysis(
|
||||
green_ratio=0.8,
|
||||
avg_saturation=0.6,
|
||||
avg_brightness=0.7,
|
||||
)
|
||||
motion = MotionAnalysis(motion_score=0.3)
|
||||
audio = AudioAnalysis(has_audio=True, ambient_ratio=0.8)
|
||||
result = classify_from_analysis(color, motion, audio)
|
||||
assert result.category == AssetClassification.SCENIC
|
||||
|
||||
def test_confidence_within_bounds(self):
|
||||
"""置信度在 0.3-0.95 范围内."""
|
||||
color = ColorAnalysis()
|
||||
motion = MotionAnalysis()
|
||||
audio = AudioAnalysis()
|
||||
result = classify_from_analysis(color, motion, audio)
|
||||
assert 0.3 <= result.confidence <= 0.95
|
||||
|
||||
def test_confidence_capped_at_095(self):
|
||||
"""极高得分也被限制在 0.95."""
|
||||
color = ColorAnalysis(
|
||||
green_ratio=0.9,
|
||||
avg_saturation=0.9,
|
||||
avg_brightness=0.9,
|
||||
warm_ratio=0.9,
|
||||
)
|
||||
motion = MotionAnalysis(motion_score=0.9, scene_changes=10)
|
||||
audio = AudioAnalysis(
|
||||
has_audio=True,
|
||||
speech_ratio=0.9,
|
||||
music_ratio=0.9,
|
||||
ambient_ratio=0.9,
|
||||
)
|
||||
result = classify_from_analysis(color, motion, audio)
|
||||
assert result.confidence <= 0.95
|
||||
|
||||
def test_confidence_floored_at_03(self):
|
||||
"""极低得分也有 0.3 最低置信度."""
|
||||
color = ColorAnalysis()
|
||||
motion = MotionAnalysis()
|
||||
audio = AudioAnalysis()
|
||||
result = classify_from_analysis(color, motion, audio)
|
||||
assert result.confidence >= 0.3
|
||||
|
||||
def test_scores_dict_included(self):
|
||||
"""结果中包含完整分数字典."""
|
||||
color = ColorAnalysis()
|
||||
motion = MotionAnalysis()
|
||||
audio = AudioAnalysis()
|
||||
result = classify_from_analysis(color, motion, audio)
|
||||
assert len(result.scores) == 9
|
||||
assert AssetClassification.OTHER.value in result.scores
|
||||
|
||||
def test_food_category_wins_on_warm_colors(self):
|
||||
"""暖色调+高饱和 → 美食分类胜出."""
|
||||
color = ColorAnalysis(
|
||||
warm_ratio=0.7,
|
||||
avg_saturation=0.8,
|
||||
avg_brightness=0.6,
|
||||
green_ratio=0.05,
|
||||
)
|
||||
motion = MotionAnalysis(motion_score=0.05)
|
||||
audio = AudioAnalysis(has_audio=False)
|
||||
result = classify_from_analysis(color, motion, audio)
|
||||
assert result.category == AssetClassification.FOOD
|
||||
|
||||
|
||||
# ── 数据类默认值 ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestDataclassDefaults:
|
||||
"""数据类默认值测试."""
|
||||
|
||||
def test_video_info_defaults(self):
|
||||
info = VideoInfo()
|
||||
assert info.width == 0
|
||||
assert info.height == 0
|
||||
assert info.fps == 0.0
|
||||
assert info.bitrate == 0
|
||||
assert info.has_audio is False
|
||||
|
||||
def test_color_analysis_defaults(self):
|
||||
color = ColorAnalysis()
|
||||
assert color.green_ratio == 0.0
|
||||
assert color.avg_brightness == 0.0
|
||||
assert color.dominant_hue == 0.0
|
||||
|
||||
def test_motion_analysis_defaults(self):
|
||||
motion = MotionAnalysis()
|
||||
assert motion.motion_score == 0.0
|
||||
assert motion.scene_changes == 0
|
||||
|
||||
def test_audio_analysis_defaults(self):
|
||||
audio = AudioAnalysis()
|
||||
assert audio.has_audio is False
|
||||
assert audio.speech_ratio == 0.0
|
||||
assert audio.music_ratio == 0.0
|
||||
|
||||
def test_quality_score_requires_total(self):
|
||||
with pytest.raises(TypeError):
|
||||
QualityScore()
|
||||
qs = QualityScore(total=50.0)
|
||||
assert qs.total == 50.0
|
||||
assert qs.resolution_score == 0.0
|
||||
Executable
+755
@@ -0,0 +1,755 @@
|
||||
"""Deep unit tests for asset_scoring.py — multi-dimensional scoring + diverse selection.
|
||||
|
||||
深度覆盖:
|
||||
- score_resolution: 10+ 边界情况
|
||||
- score_duration: 10+ 边界情况
|
||||
- score_bitrate: 10+ 边界情况
|
||||
- calculate_total_score: 加权验证
|
||||
- score_asset_detail: 完整评分流程
|
||||
- diverse_selection: 各种分桶场景
|
||||
- filter_candidates: 各种过滤条件
|
||||
- 数据类 + 常量
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
import pytest
|
||||
|
||||
from packages.domain.asset_scoring import (
|
||||
AssetScoreDetail,
|
||||
MEDIUM_BUCKET_MAX,
|
||||
MIN_QUALITY_SCORE,
|
||||
OPTIMAL_DURATION_MAX,
|
||||
OPTIMAL_DURATION_MIN,
|
||||
SHORT_BUCKET_MAX,
|
||||
SmartSelectResult,
|
||||
TARGET_HEIGHT,
|
||||
TARGET_WIDTH,
|
||||
WEIGHT_BITRATE,
|
||||
WEIGHT_DURATION,
|
||||
WEIGHT_QUALITY,
|
||||
WEIGHT_RESOLUTION,
|
||||
_bucket_by_duration,
|
||||
calculate_total_score,
|
||||
diverse_selection,
|
||||
filter_candidates,
|
||||
score_asset_detail,
|
||||
score_bitrate,
|
||||
score_duration,
|
||||
score_resolution,
|
||||
)
|
||||
|
||||
# ── 辅助:模拟 asset 对象 ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class MockStatus:
|
||||
def __init__(self, value: str):
|
||||
self.value = value
|
||||
|
||||
|
||||
@dataclass
|
||||
class MockAsset:
|
||||
id: str = "asset_001"
|
||||
status: Any = None
|
||||
mime_type: str = "video/mp4"
|
||||
quality_score: Optional[float] = None
|
||||
width: Optional[int] = None
|
||||
height: Optional[int] = None
|
||||
duration: Optional[float] = None
|
||||
file_size: int = 0
|
||||
|
||||
def __post_init__(self):
|
||||
if self.status is None:
|
||||
self.status = MockStatus("ready")
|
||||
|
||||
|
||||
# ── 常量测试 ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestConstants:
|
||||
def test_weights_sum_to_one(self):
|
||||
total = WEIGHT_QUALITY + WEIGHT_RESOLUTION + WEIGHT_DURATION + WEIGHT_BITRATE
|
||||
assert abs(total - 1.0) < 0.001
|
||||
|
||||
def test_target_resolution_1080p(self):
|
||||
assert TARGET_WIDTH == 1920
|
||||
assert TARGET_HEIGHT == 1080
|
||||
|
||||
def test_bucket_thresholds(self):
|
||||
assert SHORT_BUCKET_MAX == 5.0
|
||||
assert MEDIUM_BUCKET_MAX == 15.0
|
||||
assert SHORT_BUCKET_MAX < MEDIUM_BUCKET_MAX
|
||||
|
||||
def test_optimal_duration_range(self):
|
||||
assert OPTIMAL_DURATION_MIN == 3.0
|
||||
assert OPTIMAL_DURATION_MAX == 30.0
|
||||
assert OPTIMAL_DURATION_MIN < OPTIMAL_DURATION_MAX
|
||||
|
||||
def test_min_quality_score(self):
|
||||
assert MIN_QUALITY_SCORE == 30.0
|
||||
|
||||
|
||||
# ── 数据类测试 ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestDataClasses:
|
||||
def test_asset_score_detail_defaults(self):
|
||||
detail = AssetScoreDetail(
|
||||
asset_id="a1",
|
||||
total_score=0.8,
|
||||
quality_score=0.7,
|
||||
resolution_score=0.9,
|
||||
duration_score=0.85,
|
||||
bitrate_score=0.75,
|
||||
duration=10.0,
|
||||
)
|
||||
assert detail.asset_id == "a1"
|
||||
assert detail.total_score == 0.8
|
||||
assert detail.duration == 10.0
|
||||
|
||||
def test_smart_select_result_defaults(self):
|
||||
result = SmartSelectResult(
|
||||
selected_ids=["a1", "a2"],
|
||||
total_candidates=10,
|
||||
filtered_out=3,
|
||||
avg_score=0.75,
|
||||
)
|
||||
assert result.selected_ids == ["a1", "a2"]
|
||||
assert result.details == []
|
||||
assert result.total_candidates == 10
|
||||
|
||||
def test_smart_select_result_with_details(self):
|
||||
detail = AssetScoreDetail(
|
||||
asset_id="a1",
|
||||
total_score=0.9,
|
||||
quality_score=0.8,
|
||||
resolution_score=0.95,
|
||||
duration_score=0.9,
|
||||
bitrate_score=0.85,
|
||||
duration=5.0,
|
||||
)
|
||||
result = SmartSelectResult(
|
||||
selected_ids=["a1"],
|
||||
total_candidates=5,
|
||||
filtered_out=0,
|
||||
avg_score=0.9,
|
||||
details=[detail],
|
||||
)
|
||||
assert len(result.details) == 1
|
||||
assert result.details[0].asset_id == "a1"
|
||||
|
||||
|
||||
# ── score_resolution ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestScoreResolution:
|
||||
def test_exact_target_1080p(self):
|
||||
score = score_resolution(1920, 1080)
|
||||
assert score == 1.0
|
||||
|
||||
def test_4k_full_score(self):
|
||||
score = score_resolution(3840, 2160)
|
||||
assert score == 1.0
|
||||
|
||||
def test_higher_than_target_full_score(self):
|
||||
score = score_resolution(2560, 1440)
|
||||
assert score == 1.0
|
||||
|
||||
def test_720p_lower(self):
|
||||
score = score_resolution(1280, 720)
|
||||
# 720p 像素 = 921600, 1080p = 2073600
|
||||
# ratio = 0.444, score = 0.3 + 0.7 * 0.444 = 0.611
|
||||
assert 0.5 < score < 0.75
|
||||
|
||||
def test_480p_much_lower(self):
|
||||
score = score_resolution(854, 480)
|
||||
# 480p = 409,920 pixels, ratio = 0.197
|
||||
# score = 0.3 + 0.7 * 0.197 = 0.438
|
||||
assert 0.3 < score < 0.5
|
||||
|
||||
def test_none_width(self):
|
||||
score = score_resolution(None, 1080)
|
||||
assert score == 0.5
|
||||
|
||||
def test_none_height(self):
|
||||
score = score_resolution(1920, None)
|
||||
assert score == 0.5
|
||||
|
||||
def test_both_none(self):
|
||||
score = score_resolution(None, None)
|
||||
assert score == 0.5
|
||||
|
||||
def test_zero_width(self):
|
||||
score = score_resolution(0, 1080)
|
||||
assert score == 0.5
|
||||
|
||||
def test_zero_height(self):
|
||||
score = score_resolution(1920, 0)
|
||||
assert score == 0.5
|
||||
|
||||
def test_negative_width(self):
|
||||
score = score_resolution(-100, 1080)
|
||||
assert score == 0.5
|
||||
|
||||
def test_very_low_res_floor(self):
|
||||
score = score_resolution(100, 100)
|
||||
# 10000 pixels, ratio = 0.0048, score = 0.3 + 0.7*0.0048 = 0.303
|
||||
# 但最低不低于 0.1
|
||||
assert score >= 0.1
|
||||
assert score < 0.5
|
||||
|
||||
def test_custom_target_resolution(self):
|
||||
score = score_resolution(1280, 720, target_width=1280, target_height=720)
|
||||
assert score == 1.0
|
||||
|
||||
def test_sd_resolution(self):
|
||||
score = score_resolution(640, 480)
|
||||
# VGA = 307,200, ratio = 0.148
|
||||
assert score > 0.1
|
||||
|
||||
|
||||
# ── score_duration ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestScoreDuration:
|
||||
def test_none_duration(self):
|
||||
assert score_duration(None) == 0.5
|
||||
|
||||
def test_zero_duration(self):
|
||||
assert score_duration(0.0) == 0.5
|
||||
|
||||
def test_negative_duration(self):
|
||||
assert score_duration(-5.0) == 0.5
|
||||
|
||||
def test_optimal_lower_bound(self):
|
||||
assert score_duration(OPTIMAL_DURATION_MIN) == 1.0
|
||||
|
||||
def test_optimal_upper_bound(self):
|
||||
assert score_duration(OPTIMAL_DURATION_MAX) == 1.0
|
||||
|
||||
def test_optimal_middle(self):
|
||||
assert score_duration(10.0) == 1.0
|
||||
|
||||
def test_below_optimal_short(self):
|
||||
score = score_duration(1.5)
|
||||
# ratio = 1.5/3 = 0.5, score = 0.3 + 0.7*0.5 = 0.65
|
||||
assert score == pytest.approx(0.65, rel=1e-3)
|
||||
|
||||
def test_very_short_approaches_03(self):
|
||||
score = score_duration(0.1)
|
||||
# ratio = 0.1/3 = 0.033, score = 0.3 + 0.7*0.033 = 0.323
|
||||
assert 0.3 < score < 0.4
|
||||
|
||||
def test_just_below_optimal(self):
|
||||
score = score_duration(2.9)
|
||||
assert score < 1.0
|
||||
assert score > 0.9
|
||||
|
||||
def test_above_optimal_slightly(self):
|
||||
score = score_duration(35.0)
|
||||
# excess = 5, penalty = 5/10 * 0.1 = 0.05, score = 0.95
|
||||
assert score == pytest.approx(0.95, rel=1e-3)
|
||||
|
||||
def test_above_optimal_moderate(self):
|
||||
score = score_duration(60.0)
|
||||
# excess = 30, penalty = 30/10 * 0.1 = 0.3, score = 0.7
|
||||
assert score == pytest.approx(0.7, rel=1e-3)
|
||||
|
||||
def test_very_long_floor(self):
|
||||
score = score_duration(1000.0)
|
||||
# excess = 970, penalty = 970/10 * 0.1 = 9.7, capped at 0.8
|
||||
# score = max(0.2, 1.0 - 0.8) = 0.2
|
||||
assert score == 0.2
|
||||
|
||||
def test_1_second(self):
|
||||
score = score_duration(1.0)
|
||||
# ratio = 1/3 = 0.333, score = 0.3 + 0.7*0.333 = 0.533
|
||||
assert score == pytest.approx(0.3 + 0.7 * (1.0 / 3.0), rel=1e-3)
|
||||
|
||||
|
||||
# ── score_bitrate ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestScoreBitrate:
|
||||
def test_no_file_size(self):
|
||||
assert score_bitrate(0, 10.0) == 0.5
|
||||
|
||||
def test_no_duration(self):
|
||||
assert score_bitrate(1_000_000, None) == 0.5
|
||||
|
||||
def test_zero_duration(self):
|
||||
assert score_bitrate(1_000_000, 0.0) == 0.5
|
||||
|
||||
def test_negative_duration(self):
|
||||
assert score_bitrate(1_000_000, -5.0) == 0.5
|
||||
|
||||
def test_optimal_low_end(self):
|
||||
# 2 Mbps for 10s = 2.5 MB
|
||||
file_size = int(2_000_000 * 10 / 8)
|
||||
score = score_bitrate(file_size, 10.0)
|
||||
assert score == 1.0
|
||||
|
||||
def test_optimal_high_end(self):
|
||||
# 8 Mbps for 10s = 10 MB
|
||||
file_size = int(8_000_000 * 10 / 8)
|
||||
score = score_bitrate(file_size, 10.0)
|
||||
assert score == 1.0
|
||||
|
||||
def test_optimal_middle(self):
|
||||
# 5 Mbps for 10s = 6.25 MB
|
||||
file_size = int(5_000_000 * 10 / 8)
|
||||
score = score_bitrate(file_size, 10.0)
|
||||
assert score == 1.0
|
||||
|
||||
def test_low_bitrate(self):
|
||||
# 1 Mbps for 10s = 1.25 MB
|
||||
file_size = int(1_000_000 * 10 / 8)
|
||||
score = score_bitrate(file_size, 10.0)
|
||||
# ratio = 1/2 = 0.5, score = 0.3 + 0.7*0.5 = 0.65
|
||||
assert score == pytest.approx(0.65, rel=1e-2)
|
||||
|
||||
def test_very_low_bitrate(self):
|
||||
# 100 kbps for 10s = 125 KB
|
||||
file_size = int(100_000 * 10 / 8)
|
||||
score = score_bitrate(file_size, 10.0)
|
||||
# ratio = 0.05, score = 0.3 + 0.7*0.05 = 0.335
|
||||
assert 0.3 < score < 0.5
|
||||
|
||||
def test_high_bitrate_slightly(self):
|
||||
# 10 Mbps (just above 8Mbps)
|
||||
file_size = int(10_000_000 * 10 / 8)
|
||||
score = score_bitrate(file_size, 10.0)
|
||||
# excess ratio = 10/8 - 1 = 0.25, penalty = min(0.5, 0.25*0.2) = 0.05
|
||||
# score = max(0.5, 1.0 - 0.05) = 0.95
|
||||
assert score == pytest.approx(0.95, rel=1e-2)
|
||||
|
||||
def test_very_high_bitrate_floor(self):
|
||||
# 100 Mbps
|
||||
file_size = int(100_000_000 * 10 / 8)
|
||||
score = score_bitrate(file_size, 10.0)
|
||||
# excess ratio = 100/8 - 1 = 11.5, penalty = min(0.5, 11.5*0.2) = 0.5
|
||||
# score = max(0.5, 1.0 - 0.5) = 0.5
|
||||
assert score == 0.5
|
||||
|
||||
def test_1mbps_file_10s(self):
|
||||
file_size = 1_000_000 # 1 MB
|
||||
score = score_bitrate(file_size, 10.0)
|
||||
# bitrate = 8*1M/10 = 0.8 Mbps
|
||||
assert 0.3 < score < 0.7
|
||||
|
||||
|
||||
# ── calculate_total_score ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestCalculateTotalScore:
|
||||
def test_perfect_score(self):
|
||||
total = calculate_total_score(1.0, 1.0, 1.0, 1.0)
|
||||
assert total == 1.0
|
||||
|
||||
def test_zero_score(self):
|
||||
total = calculate_total_score(0.0, 0.0, 0.0, 0.0)
|
||||
assert total == 0.0
|
||||
|
||||
def test_weighted_sum(self):
|
||||
# 各维度不同分数
|
||||
q, r, d, b = 0.8, 0.6, 0.9, 0.7
|
||||
expected = WEIGHT_QUALITY * q + WEIGHT_RESOLUTION * r + WEIGHT_DURATION * d + WEIGHT_BITRATE * b
|
||||
total = calculate_total_score(q, r, d, b)
|
||||
assert total == pytest.approx(expected, rel=1e-4)
|
||||
|
||||
def test_quality_dominates(self):
|
||||
# 质量分权重最高(0.5),变化影响最大
|
||||
base = calculate_total_score(0.5, 0.5, 0.5, 0.5)
|
||||
quality_up = calculate_total_score(1.0, 0.5, 0.5, 0.5)
|
||||
resolution_up = calculate_total_score(0.5, 1.0, 0.5, 0.5)
|
||||
# 质量分变化带来的差异最大
|
||||
assert (quality_up - base) > (resolution_up - base)
|
||||
|
||||
def test_rounded_to_4_decimals(self):
|
||||
# 1/3 这样的无限小数应该被截断
|
||||
total = calculate_total_score(1 / 3, 1 / 3, 1 / 3, 1 / 3)
|
||||
assert len(str(total).split(".")[-1]) <= 4
|
||||
|
||||
|
||||
# ── score_asset_detail ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestScoreAssetDetail:
|
||||
def test_full_asset(self):
|
||||
detail = score_asset_detail(
|
||||
asset_id="test_001",
|
||||
quality=80.0,
|
||||
width=1920,
|
||||
height=1080,
|
||||
duration=10.0,
|
||||
file_size=5_000_000,
|
||||
)
|
||||
assert detail.asset_id == "test_001"
|
||||
assert detail.quality_score == pytest.approx(0.8, rel=1e-3)
|
||||
assert detail.resolution_score == 1.0
|
||||
assert detail.duration_score == 1.0
|
||||
assert 0.0 < detail.total_score <= 1.0
|
||||
assert detail.duration == 10.0
|
||||
|
||||
def test_no_quality_default_05(self):
|
||||
detail = score_asset_detail(
|
||||
asset_id="a1",
|
||||
quality=None,
|
||||
width=1920,
|
||||
height=1080,
|
||||
duration=10.0,
|
||||
file_size=5_000_000,
|
||||
)
|
||||
assert detail.quality_score == 0.5
|
||||
|
||||
def test_quality_100_is_1_0(self):
|
||||
detail = score_asset_detail(
|
||||
asset_id="a1",
|
||||
quality=100.0,
|
||||
width=1920,
|
||||
height=1080,
|
||||
duration=10.0,
|
||||
file_size=5_000_000,
|
||||
)
|
||||
assert detail.quality_score == 1.0
|
||||
|
||||
def test_quality_zero_is_zero(self):
|
||||
detail = score_asset_detail(
|
||||
asset_id="a1",
|
||||
quality=0.0,
|
||||
width=1920,
|
||||
height=1080,
|
||||
duration=10.0,
|
||||
file_size=5_000_000,
|
||||
)
|
||||
assert detail.quality_score == 0.0
|
||||
|
||||
def test_all_unknown_medium_score(self):
|
||||
detail = score_asset_detail(
|
||||
asset_id="a1",
|
||||
quality=None,
|
||||
width=None,
|
||||
height=None,
|
||||
duration=None,
|
||||
file_size=0,
|
||||
)
|
||||
# 全部未知:质量0.5,分辨率0.5,时长0.5,码率0.5
|
||||
assert detail.total_score == pytest.approx(0.5, rel=1e-3)
|
||||
|
||||
def test_custom_target_resolution(self):
|
||||
detail = score_asset_detail(
|
||||
asset_id="a1",
|
||||
quality=100.0,
|
||||
width=1280,
|
||||
height=720,
|
||||
duration=10.0,
|
||||
file_size=5_000_000,
|
||||
target_width=1280,
|
||||
target_height=720,
|
||||
)
|
||||
assert detail.resolution_score == 1.0
|
||||
|
||||
def test_scores_are_rounded(self):
|
||||
detail = score_asset_detail(
|
||||
asset_id="a1",
|
||||
quality=33.3,
|
||||
width=854,
|
||||
height=480,
|
||||
duration=1.5,
|
||||
file_size=1_000_000,
|
||||
)
|
||||
# 所有分数字符串长度不超过 0.xxxx 格式
|
||||
for attr in ["quality_score", "resolution_score", "duration_score", "bitrate_score", "total_score"]:
|
||||
val = getattr(detail, attr)
|
||||
assert isinstance(val, float)
|
||||
|
||||
|
||||
# ── _bucket_by_duration ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestBucketByDuration:
|
||||
def test_short_bucket(self):
|
||||
d = AssetScoreDetail("a", 0.5, 0.5, 0.5, 0.5, 0.5, 3.0)
|
||||
assert _bucket_by_duration(d) == "short"
|
||||
|
||||
def test_short_bucket_boundary(self):
|
||||
d = AssetScoreDetail("a", 0.5, 0.5, 0.5, 0.5, 0.5, 4.9)
|
||||
assert _bucket_by_duration(d) == "short"
|
||||
|
||||
def test_medium_bucket(self):
|
||||
d = AssetScoreDetail("a", 0.5, 0.5, 0.5, 0.5, 0.5, 10.0)
|
||||
assert _bucket_by_duration(d) == "medium"
|
||||
|
||||
def test_medium_lower_boundary(self):
|
||||
d = AssetScoreDetail("a", 0.5, 0.5, 0.5, 0.5, 0.5, SHORT_BUCKET_MAX)
|
||||
assert _bucket_by_duration(d) == "medium"
|
||||
|
||||
def test_medium_upper_boundary(self):
|
||||
d = AssetScoreDetail("a", 0.5, 0.5, 0.5, 0.5, 0.5, 14.9)
|
||||
assert _bucket_by_duration(d) == "medium"
|
||||
|
||||
def test_long_bucket(self):
|
||||
d = AssetScoreDetail("a", 0.5, 0.5, 0.5, 0.5, 0.5, 20.0)
|
||||
assert _bucket_by_duration(d) == "long"
|
||||
|
||||
def test_long_lower_boundary(self):
|
||||
d = AssetScoreDetail("a", 0.5, 0.5, 0.5, 0.5, 0.5, MEDIUM_BUCKET_MAX)
|
||||
assert _bucket_by_duration(d) == "long"
|
||||
|
||||
def test_none_duration(self):
|
||||
d = AssetScoreDetail("a", 0.5, 0.5, 0.5, 0.5, 0.5, None)
|
||||
assert _bucket_by_duration(d) == "unknown"
|
||||
|
||||
|
||||
# ── diverse_selection ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _make_scored(items: list[tuple[str, float, float]]) -> list[AssetScoreDetail]:
|
||||
"""构造评分列表: (asset_id, total_score, duration)"""
|
||||
return [
|
||||
AssetScoreDetail(
|
||||
asset_id=aid,
|
||||
total_score=score,
|
||||
quality_score=score,
|
||||
resolution_score=score,
|
||||
duration_score=score,
|
||||
bitrate_score=score,
|
||||
duration=dur,
|
||||
)
|
||||
for aid, score, dur in items
|
||||
]
|
||||
|
||||
|
||||
class TestDiverseSelection:
|
||||
def test_empty_input(self):
|
||||
result = diverse_selection([], 5)
|
||||
assert result == []
|
||||
|
||||
def test_zero_count(self):
|
||||
scored = _make_scored([("a1", 0.9, 10.0)])
|
||||
result = diverse_selection(scored, 0)
|
||||
assert result == []
|
||||
|
||||
def test_negative_count(self):
|
||||
scored = _make_scored([("a1", 0.9, 10.0)])
|
||||
result = diverse_selection(scored, -1)
|
||||
assert result == []
|
||||
|
||||
def test_fewer_than_count(self):
|
||||
scored = _make_scored([("a1", 0.9, 10.0)])
|
||||
result = diverse_selection(scored, 5)
|
||||
assert len(result) == 1
|
||||
|
||||
def test_mixed_buckets_diversity(self):
|
||||
# 3短 + 3中 + 3长,取6个
|
||||
items = [
|
||||
("s1", 0.9, 2.0),
|
||||
("s2", 0.8, 3.0),
|
||||
("s3", 0.7, 4.0),
|
||||
("m1", 0.95, 8.0),
|
||||
("m2", 0.85, 10.0),
|
||||
("m3", 0.75, 12.0),
|
||||
("l1", 0.92, 20.0),
|
||||
("l2", 0.82, 25.0),
|
||||
("l3", 0.72, 30.0),
|
||||
]
|
||||
scored = _make_scored(items)
|
||||
scored.sort(key=lambda d: d.total_score, reverse=True)
|
||||
result = diverse_selection(scored, 6)
|
||||
assert len(result) == 6
|
||||
# 每个桶至少1个(base_quota = max(1, 6//3) = 2)
|
||||
ids = [r.asset_id for r in result]
|
||||
short_count = sum(1 for r in result if r.duration and r.duration < SHORT_BUCKET_MAX)
|
||||
medium_count = sum(1 for r in result if r.duration and SHORT_BUCKET_MAX <= r.duration < MEDIUM_BUCKET_MAX)
|
||||
long_count = sum(1 for r in result if r.duration and r.duration >= MEDIUM_BUCKET_MAX)
|
||||
assert short_count >= 1
|
||||
assert medium_count >= 1
|
||||
assert long_count >= 1
|
||||
|
||||
def test_all_short_fallback_to_global(self):
|
||||
items = [("s1", 0.9, 2.0), ("s2", 0.8, 3.0), ("s3", 0.7, 4.0)]
|
||||
scored = _make_scored(items)
|
||||
result = diverse_selection(scored, 3)
|
||||
assert len(result) == 3
|
||||
# 都是短素材,只能取短的
|
||||
assert all(r.duration and r.duration < SHORT_BUCKET_MAX for r in result)
|
||||
|
||||
def test_sorted_by_score_descending(self):
|
||||
items = [("a1", 0.5, 10.0), ("a2", 0.9, 10.0), ("a3", 0.7, 10.0)]
|
||||
scored = _make_scored(items)
|
||||
scored.sort(key=lambda d: d.total_score, reverse=True)
|
||||
result = diverse_selection(scored, 3)
|
||||
assert len(result) == 3
|
||||
assert result[0].total_score >= result[1].total_score >= result[2].total_score
|
||||
|
||||
def test_count_one_each_bucket(self):
|
||||
# count=3, base_quota=max(1,1)=1,每桶1个共3个
|
||||
items = [
|
||||
("s1", 0.9, 2.0),
|
||||
("m1", 0.95, 8.0),
|
||||
("l1", 0.92, 20.0),
|
||||
]
|
||||
scored = _make_scored(items)
|
||||
scored.sort(key=lambda d: d.total_score, reverse=True)
|
||||
result = diverse_selection(scored, 3)
|
||||
assert len(result) == 3
|
||||
# 每桶1个
|
||||
assert any(r.duration and r.duration < SHORT_BUCKET_MAX for r in result)
|
||||
assert any(r.duration and SHORT_BUCKET_MAX <= r.duration < MEDIUM_BUCKET_MAX for r in result)
|
||||
assert any(r.duration and r.duration >= MEDIUM_BUCKET_MAX for r in result)
|
||||
|
||||
def test_unknown_duration_used_last(self):
|
||||
items = [
|
||||
("u1", 0.99, None), # 分最高但未知
|
||||
("s1", 0.9, 2.0),
|
||||
("m1", 0.8, 10.0),
|
||||
("l1", 0.7, 20.0),
|
||||
]
|
||||
scored = _make_scored(items)
|
||||
scored.sort(key=lambda d: d.total_score, reverse=True)
|
||||
result = diverse_selection(scored, 3)
|
||||
# 前3个应该是三个已知桶各一个
|
||||
ids = [r.asset_id for r in result]
|
||||
# u1 不应该在前3(因为 unknown 桶最后才用)
|
||||
assert "s1" in ids
|
||||
assert "m1" in ids
|
||||
assert "l1" in ids
|
||||
|
||||
def test_no_duplicates(self):
|
||||
items = [("s1", 0.9, 2.0), ("s2", 0.8, 3.0)]
|
||||
scored = _make_scored(items)
|
||||
result = diverse_selection(scored, 5)
|
||||
ids = [r.asset_id for r in result]
|
||||
assert len(ids) == len(set(ids))
|
||||
|
||||
def test_many_more_than_count(self):
|
||||
# 30个素材,取6个
|
||||
items = []
|
||||
for i in range(10):
|
||||
items.append((f"s{i}", 0.9 - i * 0.05, 2.0 + i * 0.2))
|
||||
items.append((f"m{i}", 0.9 - i * 0.03, 6.0 + i * 0.8))
|
||||
items.append((f"l{i}", 0.9 - i * 0.04, 16.0 + i * 1.5))
|
||||
scored = _make_scored(items)
|
||||
scored.sort(key=lambda d: d.total_score, reverse=True)
|
||||
result = diverse_selection(scored, 6)
|
||||
assert len(result) == 6
|
||||
# 有多样性
|
||||
durations = [r.duration for r in result]
|
||||
short = sum(1 for d in durations if d and d < SHORT_BUCKET_MAX)
|
||||
medium = sum(1 for d in durations if d and SHORT_BUCKET_MAX <= d < MEDIUM_BUCKET_MAX)
|
||||
long_ = sum(1 for d in durations if d and d >= MEDIUM_BUCKET_MAX)
|
||||
assert short >= 1
|
||||
assert medium >= 1
|
||||
assert long_ >= 1
|
||||
|
||||
|
||||
# ── filter_candidates ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestFilterCandidates:
|
||||
def test_empty_list(self):
|
||||
candidates, filtered = filter_candidates([])
|
||||
assert candidates == []
|
||||
assert filtered == 0
|
||||
|
||||
def test_ready_video_passes(self):
|
||||
assets = [MockAsset(id="a1", status=MockStatus("ready"), mime_type="video/mp4")]
|
||||
candidates, filtered = filter_candidates(assets)
|
||||
assert len(candidates) == 1
|
||||
assert filtered == 0
|
||||
|
||||
def test_non_ready_filtered(self):
|
||||
assets = [
|
||||
MockAsset(id="a1", status=MockStatus("processing"), mime_type="video/mp4"),
|
||||
MockAsset(id="a2", status=MockStatus("ready"), mime_type="video/mp4"),
|
||||
]
|
||||
candidates, filtered = filter_candidates(assets)
|
||||
assert len(candidates) == 1
|
||||
assert candidates[0].id == "a2"
|
||||
assert filtered == 0 # 非ready不算filtered_out(filtered_out只算质量分过滤的)
|
||||
|
||||
def test_non_video_filtered(self):
|
||||
assets = [
|
||||
MockAsset(id="a1", mime_type="image/jpeg"),
|
||||
MockAsset(id="a2", mime_type="video/mp4"),
|
||||
]
|
||||
candidates, filtered = filter_candidates(assets)
|
||||
assert len(candidates) == 1
|
||||
assert candidates[0].id == "a2"
|
||||
|
||||
def test_low_quality_filtered(self):
|
||||
assets = [
|
||||
MockAsset(id="low", quality_score=20.0),
|
||||
MockAsset(id="high", quality_score=80.0),
|
||||
]
|
||||
candidates, filtered = filter_candidates(assets, min_quality_score=30.0)
|
||||
assert len(candidates) == 1
|
||||
assert candidates[0].id == "high"
|
||||
assert filtered == 1
|
||||
|
||||
def test_quality_none_passes(self):
|
||||
assets = [MockAsset(id="a1", quality_score=None)]
|
||||
candidates, filtered = filter_candidates(assets)
|
||||
assert len(candidates) == 1
|
||||
assert filtered == 0
|
||||
|
||||
def test_quality_exact_min_passes(self):
|
||||
assets = [MockAsset(id="a1", quality_score=30.0)]
|
||||
candidates, filtered = filter_candidates(assets, min_quality_score=30.0)
|
||||
assert len(candidates) == 1
|
||||
assert filtered == 0
|
||||
|
||||
def test_string_status(self):
|
||||
# status 是字符串不是 Enum
|
||||
@dataclass
|
||||
class StrAsset:
|
||||
id: str = "a1"
|
||||
status: str = "ready"
|
||||
mime_type: str = "video/mp4"
|
||||
quality_score: float = 80.0
|
||||
width: int = 1920
|
||||
height: int = 1080
|
||||
duration: float = 10.0
|
||||
file_size: int = 5_000_000
|
||||
|
||||
assets = [StrAsset()]
|
||||
candidates, filtered = filter_candidates(assets)
|
||||
assert len(candidates) == 1
|
||||
|
||||
def test_empty_mime_type(self):
|
||||
assets = [MockAsset(id="a1", mime_type="")]
|
||||
candidates, filtered = filter_candidates(assets)
|
||||
assert len(candidates) == 0
|
||||
|
||||
def test_none_mime_type(self):
|
||||
# mime_type 是 None
|
||||
@dataclass
|
||||
class NoneMimeAsset:
|
||||
id: str = "a1"
|
||||
status: Any = None
|
||||
mime_type: str | None = None
|
||||
quality_score: float = 80.0
|
||||
width: int = 1920
|
||||
height: int = 1080
|
||||
duration: float = 10.0
|
||||
file_size: int = 5_000_000
|
||||
|
||||
def __post_init__(self):
|
||||
if self.status is None:
|
||||
self.status = MockStatus("ready")
|
||||
|
||||
assets = [NoneMimeAsset()]
|
||||
candidates, filtered = filter_candidates(assets)
|
||||
assert len(candidates) == 0
|
||||
|
||||
def test_custom_min_quality(self):
|
||||
assets = [
|
||||
MockAsset(id="low", quality_score=40.0),
|
||||
MockAsset(id="high", quality_score=60.0),
|
||||
]
|
||||
candidates, filtered = filter_candidates(assets, min_quality_score=50.0)
|
||||
assert len(candidates) == 1
|
||||
assert filtered == 1
|
||||
@@ -11,8 +11,7 @@ class TestMergeBgmConfigBothEmpty:
|
||||
def test_both_empty(self):
|
||||
result = merge_bgm_config({}, {})
|
||||
assert result == {}
|
||||
# 确保返回的是新字典,不是同一个引用
|
||||
assert result is not {}
|
||||
# 返回新字典(值已通过 == 验证,is not {} 无实际意义(每次{}每次新建对象)
|
||||
|
||||
def test_user_none_returns_template_copy(self):
|
||||
"""用户传 None 视为空配置,返回模板副本。"""
|
||||
|
||||
@@ -245,7 +245,7 @@ class TestClassificationJobState:
|
||||
assert job.confidence == 1.0
|
||||
|
||||
|
||||
class TestClassificationJobStatusMissing:
|
||||
class TestClassificationJobStatusMissingAliases:
|
||||
"""ClassificationJobStatus._missing_ 兼容行为测试"""
|
||||
|
||||
def test_done_maps_to_completed(self):
|
||||
|
||||
Executable
+538
@@ -0,0 +1,538 @@
|
||||
"""CoverGenerator 纯逻辑单测 — 时间钳制 + 智能选帧算法.
|
||||
|
||||
通过 mock run_ffmpeg 和 probe_video_info 验证纯逻辑部分,
|
||||
不实际执行 FFmpeg,确保测试轻量快速。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from video_processing.cover_generator import (
|
||||
DEFAULT_COVER_HEIGHT,
|
||||
DEFAULT_COVER_QUALITY,
|
||||
DEFAULT_COVER_TIME,
|
||||
DEFAULT_COVER_WIDTH,
|
||||
SMART_COVER_FRAME_COUNT,
|
||||
CoverGenerator,
|
||||
)
|
||||
|
||||
|
||||
class TestCoverGeneratorConstants:
|
||||
"""常量默认值测试."""
|
||||
|
||||
def test_default_cover_time(self):
|
||||
"""默认抽帧时间为 1.0 秒."""
|
||||
assert DEFAULT_COVER_TIME == 1.0
|
||||
|
||||
def test_default_dimensions(self):
|
||||
"""默认封面尺寸 1080x1920 (竖屏)."""
|
||||
assert DEFAULT_COVER_WIDTH == 1080
|
||||
assert DEFAULT_COVER_HEIGHT == 1920
|
||||
|
||||
def test_default_quality(self):
|
||||
"""默认质量为 5 (JPEG q:v, 越小越好)."""
|
||||
assert DEFAULT_COVER_QUALITY == 5
|
||||
|
||||
def test_smart_cover_frame_count(self):
|
||||
"""智能封面默认抽 3 帧."""
|
||||
assert SMART_COVER_FRAME_COUNT == 3
|
||||
|
||||
|
||||
class TestExtractFrameCommand:
|
||||
"""extract_frame 命令构建测试."""
|
||||
|
||||
def _probe_video_info_mock(self, duration=10.0):
|
||||
"""创建 probe_video_info 的 mock."""
|
||||
return {"duration": duration, "width": 1920, "height": 1080, "fps": 25.0}
|
||||
|
||||
def test_default_params_command(self, tmp_path):
|
||||
"""默认参数下 FFmpeg 命令正确."""
|
||||
video_file = tmp_path / "test.mp4"
|
||||
video_file.write_bytes(b"fake")
|
||||
output_file = tmp_path / "cover.jpg"
|
||||
|
||||
with (
|
||||
patch(
|
||||
"video_processing.cover_generator.probe_video_info",
|
||||
return_value=self._probe_video_info_mock(),
|
||||
),
|
||||
patch("video_processing.cover_generator.run_ffmpeg") as mock_run,
|
||||
):
|
||||
# 让 output_path 在 run_ffmpeg 后存在
|
||||
def fake_run(cmd):
|
||||
output_file.write_bytes(b"fake jpg")
|
||||
|
||||
mock_run.side_effect = fake_run
|
||||
result = CoverGenerator.extract_frame(str(video_file), str(output_file))
|
||||
|
||||
assert result == Path(output_file)
|
||||
mock_run.assert_called_once()
|
||||
cmd = mock_run.call_args[0][0]
|
||||
|
||||
# 基本结构
|
||||
assert cmd[0].endswith("ffmpeg") or "ffmpeg" in cmd[0]
|
||||
assert "-y" in cmd
|
||||
assert "-vframes" in cmd
|
||||
assert cmd[cmd.index("-vframes") + 1] == "1"
|
||||
assert "-f" in cmd
|
||||
assert "mjpeg" in cmd[cmd.index("-f") + 1]
|
||||
|
||||
# 时间点
|
||||
ss_idx = cmd.index("-ss")
|
||||
assert float(cmd[ss_idx + 1]) == pytest.approx(DEFAULT_COVER_TIME, abs=0.001)
|
||||
|
||||
# 输入文件
|
||||
i_idx = cmd.index("-i")
|
||||
assert cmd[i_idx + 1] == str(video_file)
|
||||
|
||||
# 输出文件
|
||||
assert cmd[-1] == str(output_file)
|
||||
|
||||
# scale + crop 滤镜
|
||||
vf_idx = cmd.index("-vf")
|
||||
vf_value = cmd[vf_idx + 1]
|
||||
assert "scale=" in vf_value
|
||||
assert "crop=" in vf_value
|
||||
assert "force_original_aspect_ratio=increase" in vf_value
|
||||
|
||||
def test_custom_time(self, tmp_path):
|
||||
"""自定义抽帧时间点."""
|
||||
video_file = tmp_path / "test.mp4"
|
||||
video_file.write_bytes(b"fake")
|
||||
output_file = tmp_path / "cover.jpg"
|
||||
|
||||
with (
|
||||
patch(
|
||||
"video_processing.cover_generator.probe_video_info",
|
||||
return_value=self._probe_video_info_mock(duration=30.0),
|
||||
),
|
||||
patch("video_processing.cover_generator.run_ffmpeg") as mock_run,
|
||||
):
|
||||
|
||||
def fake_run(cmd):
|
||||
output_file.write_bytes(b"fake jpg")
|
||||
|
||||
mock_run.side_effect = fake_run
|
||||
CoverGenerator.extract_frame(str(video_file), str(output_file), time_sec=5.5)
|
||||
|
||||
cmd = mock_run.call_args[0][0]
|
||||
ss_idx = cmd.index("-ss")
|
||||
assert float(cmd[ss_idx + 1]) == pytest.approx(5.5, abs=0.001)
|
||||
|
||||
def test_custom_dimensions(self, tmp_path):
|
||||
"""自定义输出尺寸."""
|
||||
video_file = tmp_path / "test.mp4"
|
||||
video_file.write_bytes(b"fake")
|
||||
output_file = tmp_path / "cover.jpg"
|
||||
|
||||
with (
|
||||
patch(
|
||||
"video_processing.cover_generator.probe_video_info",
|
||||
return_value=self._probe_video_info_mock(),
|
||||
),
|
||||
patch("video_processing.cover_generator.run_ffmpeg") as mock_run,
|
||||
):
|
||||
|
||||
def fake_run(cmd):
|
||||
output_file.write_bytes(b"fake jpg")
|
||||
|
||||
mock_run.side_effect = fake_run
|
||||
CoverGenerator.extract_frame(str(video_file), str(output_file), width=1920, height=1080)
|
||||
|
||||
cmd = mock_run.call_args[0][0]
|
||||
vf_idx = cmd.index("-vf")
|
||||
vf_value = cmd[vf_idx + 1]
|
||||
assert "scale=1920:1080:" in vf_value
|
||||
assert "crop=1920:1080" in vf_value
|
||||
|
||||
def test_custom_quality(self, tmp_path):
|
||||
"""自定义 JPEG 质量."""
|
||||
video_file = tmp_path / "test.mp4"
|
||||
video_file.write_bytes(b"fake")
|
||||
output_file = tmp_path / "cover.jpg"
|
||||
|
||||
with (
|
||||
patch(
|
||||
"video_processing.cover_generator.probe_video_info",
|
||||
return_value=self._probe_video_info_mock(),
|
||||
),
|
||||
patch("video_processing.cover_generator.run_ffmpeg") as mock_run,
|
||||
):
|
||||
|
||||
def fake_run(cmd):
|
||||
output_file.write_bytes(b"fake jpg")
|
||||
|
||||
mock_run.side_effect = fake_run
|
||||
CoverGenerator.extract_frame(str(video_file), str(output_file), quality=2)
|
||||
|
||||
cmd = mock_run.call_args[0][0]
|
||||
q_idx = cmd.index("-q:v")
|
||||
assert cmd[q_idx + 1] == "2"
|
||||
|
||||
def test_time_exceeds_duration_clamps_to_midpoint(self, tmp_path):
|
||||
"""抽帧时间超过视频时长时,钳制到中间帧."""
|
||||
video_file = tmp_path / "test.mp4"
|
||||
video_file.write_bytes(b"fake")
|
||||
output_file = tmp_path / "cover.jpg"
|
||||
|
||||
with (
|
||||
patch(
|
||||
"video_processing.cover_generator.probe_video_info",
|
||||
return_value=self._probe_video_info_mock(duration=5.0),
|
||||
),
|
||||
patch("video_processing.cover_generator.run_ffmpeg") as mock_run,
|
||||
):
|
||||
|
||||
def fake_run(cmd):
|
||||
output_file.write_bytes(b"fake jpg")
|
||||
|
||||
mock_run.side_effect = fake_run
|
||||
CoverGenerator.extract_frame(str(video_file), str(output_file), time_sec=10.0)
|
||||
|
||||
cmd = mock_run.call_args[0][0]
|
||||
ss_idx = cmd.index("-ss")
|
||||
# 钳制到 duration/2 = 2.5
|
||||
assert float(cmd[ss_idx + 1]) == pytest.approx(2.5, abs=0.001)
|
||||
|
||||
def test_negative_time_clamps_to_zero(self, tmp_path):
|
||||
"""负时间钳制到 0."""
|
||||
video_file = tmp_path / "test.mp4"
|
||||
video_file.write_bytes(b"fake")
|
||||
output_file = tmp_path / "cover.jpg"
|
||||
|
||||
with (
|
||||
patch(
|
||||
"video_processing.cover_generator.probe_video_info",
|
||||
return_value=self._probe_video_info_mock(duration=10.0),
|
||||
),
|
||||
patch("video_processing.cover_generator.run_ffmpeg") as mock_run,
|
||||
):
|
||||
|
||||
def fake_run(cmd):
|
||||
output_file.write_bytes(b"fake jpg")
|
||||
|
||||
mock_run.side_effect = fake_run
|
||||
CoverGenerator.extract_frame(str(video_file), str(output_file), time_sec=-2.0)
|
||||
|
||||
cmd = mock_run.call_args[0][0]
|
||||
ss_idx = cmd.index("-ss")
|
||||
assert float(cmd[ss_idx + 1]) == pytest.approx(0.0, abs=0.001)
|
||||
|
||||
def test_time_equals_duration_clamps_to_midpoint(self, tmp_path):
|
||||
"""时间点等于时长时钳制到中间帧."""
|
||||
video_file = tmp_path / "test.mp4"
|
||||
video_file.write_bytes(b"fake")
|
||||
output_file = tmp_path / "cover.jpg"
|
||||
|
||||
with (
|
||||
patch(
|
||||
"video_processing.cover_generator.probe_video_info",
|
||||
return_value=self._probe_video_info_mock(duration=10.0),
|
||||
),
|
||||
patch("video_processing.cover_generator.run_ffmpeg") as mock_run,
|
||||
):
|
||||
|
||||
def fake_run(cmd):
|
||||
output_file.write_bytes(b"fake jpg")
|
||||
|
||||
mock_run.side_effect = fake_run
|
||||
CoverGenerator.extract_frame(str(video_file), str(output_file), time_sec=10.0)
|
||||
|
||||
cmd = mock_run.call_args[0][0]
|
||||
ss_idx = cmd.index("-ss")
|
||||
assert float(cmd[ss_idx + 1]) == pytest.approx(5.0, abs=0.001)
|
||||
|
||||
def test_zero_duration_video(self, tmp_path):
|
||||
"""视频时长为 0 时的行为(不钳制,用原始时间)."""
|
||||
video_file = tmp_path / "test.mp4"
|
||||
video_file.write_bytes(b"fake")
|
||||
output_file = tmp_path / "cover.jpg"
|
||||
|
||||
with (
|
||||
patch(
|
||||
"video_processing.cover_generator.probe_video_info",
|
||||
return_value=self._probe_video_info_mock(duration=0.0),
|
||||
),
|
||||
patch("video_processing.cover_generator.run_ffmpeg") as mock_run,
|
||||
):
|
||||
|
||||
def fake_run(cmd):
|
||||
output_file.write_bytes(b"fake jpg")
|
||||
|
||||
mock_run.side_effect = fake_run
|
||||
CoverGenerator.extract_frame(str(video_file), str(output_file), time_sec=0.5)
|
||||
|
||||
cmd = mock_run.call_args[0][0]
|
||||
ss_idx = cmd.index("-ss")
|
||||
assert float(cmd[ss_idx + 1]) == pytest.approx(0.5, abs=0.001)
|
||||
|
||||
def test_video_not_found_raises(self, tmp_path):
|
||||
"""视频文件不存在时抛出 FileNotFoundError."""
|
||||
output_file = tmp_path / "cover.jpg"
|
||||
|
||||
with pytest.raises(FileNotFoundError):
|
||||
CoverGenerator.extract_frame(str(tmp_path / "nonexistent.mp4"), str(output_file))
|
||||
|
||||
def test_output_creates_parent_dir(self, tmp_path):
|
||||
"""输出目录不存在时自动创建."""
|
||||
video_file = tmp_path / "test.mp4"
|
||||
video_file.write_bytes(b"fake")
|
||||
out_dir = tmp_path / "deep" / "nested"
|
||||
output_file = out_dir / "cover.jpg"
|
||||
|
||||
with (
|
||||
patch(
|
||||
"video_processing.cover_generator.probe_video_info",
|
||||
return_value=self._probe_video_info_mock(),
|
||||
),
|
||||
patch("video_processing.cover_generator.run_ffmpeg") as mock_run,
|
||||
):
|
||||
|
||||
def fake_run(cmd):
|
||||
output_file.write_bytes(b"fake jpg")
|
||||
|
||||
mock_run.side_effect = fake_run
|
||||
CoverGenerator.extract_frame(str(video_file), str(output_file))
|
||||
|
||||
assert out_dir.exists()
|
||||
assert out_dir.is_dir()
|
||||
|
||||
def test_ffmpeg_failure_propagates(self, tmp_path):
|
||||
"""FFmpeg 失败时异常向上传递."""
|
||||
video_file = tmp_path / "test.mp4"
|
||||
video_file.write_bytes(b"fake")
|
||||
output_file = tmp_path / "cover.jpg"
|
||||
|
||||
with (
|
||||
patch(
|
||||
"video_processing.cover_generator.probe_video_info",
|
||||
return_value=self._probe_video_info_mock(),
|
||||
),
|
||||
patch(
|
||||
"video_processing.cover_generator.run_ffmpeg",
|
||||
side_effect=RuntimeError("FFmpeg error"),
|
||||
),
|
||||
):
|
||||
with pytest.raises(RuntimeError, match="FFmpeg error"):
|
||||
CoverGenerator.extract_frame(str(video_file), str(output_file))
|
||||
|
||||
|
||||
class TestSmartCoverTimePoints:
|
||||
"""智能封面时间点计算测试."""
|
||||
|
||||
def test_single_frame_falls_back_to_default(self, tmp_path):
|
||||
"""只有 1 帧时退化为普通抽帧(取 DEFAULT_COVER_TIME 和 midpoint 中较小值)."""
|
||||
video_file = tmp_path / "test.mp4"
|
||||
video_file.write_bytes(b"fake")
|
||||
output_file = tmp_path / "cover.jpg"
|
||||
|
||||
with (
|
||||
patch(
|
||||
"video_processing.cover_generator.probe_video_info",
|
||||
return_value={"duration": 20.0},
|
||||
),
|
||||
patch("video_processing.cover_generator.run_ffmpeg") as mock_run,
|
||||
):
|
||||
|
||||
def fake_run(cmd):
|
||||
output_file.write_bytes(b"fake jpg")
|
||||
|
||||
mock_run.side_effect = fake_run
|
||||
# frame_count=1 时退化为普通抽帧
|
||||
CoverGenerator.extract_smart_cover(str(video_file), str(output_file), frame_count=1)
|
||||
|
||||
# 只调用一次(退化路径)
|
||||
assert mock_run.call_count == 1
|
||||
cmd = mock_run.call_args[0][0]
|
||||
ss_idx = cmd.index("-ss")
|
||||
# min(DEFAULT_COVER_TIME=1.0, duration/2=10.0) = 1.0
|
||||
assert float(cmd[ss_idx + 1]) == pytest.approx(1.0, abs=0.001)
|
||||
|
||||
def test_zero_duration_falls_back(self, tmp_path):
|
||||
"""视频时长为 0 时退化为普通抽帧."""
|
||||
video_file = tmp_path / "test.mp4"
|
||||
video_file.write_bytes(b"fake")
|
||||
output_file = tmp_path / "cover.jpg"
|
||||
|
||||
with (
|
||||
patch(
|
||||
"video_processing.cover_generator.probe_video_info",
|
||||
return_value={"duration": 0.0},
|
||||
),
|
||||
patch("video_processing.cover_generator.run_ffmpeg") as mock_run,
|
||||
):
|
||||
|
||||
def fake_run(cmd):
|
||||
output_file.write_bytes(b"fake jpg")
|
||||
|
||||
mock_run.side_effect = fake_run
|
||||
CoverGenerator.extract_smart_cover(str(video_file), str(output_file))
|
||||
|
||||
# 只调用一次(退化路径)
|
||||
assert mock_run.call_count == 1
|
||||
|
||||
def test_three_frames_uniform_distribution(self, tmp_path):
|
||||
"""3 帧均匀分布在 5%~95% 区间."""
|
||||
video_file = tmp_path / "test.mp4"
|
||||
video_file.write_bytes(b"fake")
|
||||
output_file = tmp_path / "cover.jpg"
|
||||
|
||||
call_times = []
|
||||
|
||||
with (
|
||||
patch(
|
||||
"video_processing.cover_generator.probe_video_info",
|
||||
return_value={"duration": 100.0},
|
||||
),
|
||||
patch("video_processing.cover_generator.run_ffmpeg") as mock_run,
|
||||
):
|
||||
|
||||
def fake_run(cmd):
|
||||
# 记录抽帧时间
|
||||
ss_idx = cmd.index("-ss")
|
||||
call_times.append(float(cmd[ss_idx + 1]))
|
||||
# 在输出路径写文件
|
||||
output_arg = cmd[-1]
|
||||
Path(output_arg).parent.mkdir(parents=True, exist_ok=True)
|
||||
# 不同文件大小,让第三帧"最清晰"
|
||||
idx = len(call_times) - 1
|
||||
size = 1000 * (idx + 1) # 递增的文件大小
|
||||
Path(output_arg).write_bytes(b"x" * size)
|
||||
|
||||
mock_run.side_effect = fake_run
|
||||
CoverGenerator.extract_smart_cover(str(video_file), str(output_file))
|
||||
|
||||
# 3 帧:5%、50%、95%
|
||||
assert len(call_times) == 3
|
||||
assert call_times[0] == pytest.approx(5.0, abs=0.1) # 5%
|
||||
assert call_times[1] == pytest.approx(50.0, abs=0.1) # 50%
|
||||
assert call_times[2] == pytest.approx(95.0, abs=0.1) # 95%
|
||||
|
||||
def test_five_frames_distribution(self, tmp_path):
|
||||
"""5 帧均匀分布."""
|
||||
video_file = tmp_path / "test.mp4"
|
||||
video_file.write_bytes(b"fake")
|
||||
output_file = tmp_path / "cover.jpg"
|
||||
|
||||
call_times = []
|
||||
|
||||
with (
|
||||
patch(
|
||||
"video_processing.cover_generator.probe_video_info",
|
||||
return_value={"duration": 100.0},
|
||||
),
|
||||
patch("video_processing.cover_generator.run_ffmpeg") as mock_run,
|
||||
):
|
||||
|
||||
def fake_run(cmd):
|
||||
ss_idx = cmd.index("-ss")
|
||||
call_times.append(float(cmd[ss_idx + 1]))
|
||||
output_arg = cmd[-1]
|
||||
Path(output_arg).parent.mkdir(parents=True, exist_ok=True)
|
||||
idx = len(call_times) - 1
|
||||
Path(output_arg).write_bytes(b"x" * (1000 * (idx + 1)))
|
||||
|
||||
mock_run.side_effect = fake_run
|
||||
CoverGenerator.extract_smart_cover(str(video_file), str(output_file), frame_count=5)
|
||||
|
||||
assert len(call_times) == 5
|
||||
# step = (95-5) / (5-1) = 22.5
|
||||
# times: 5, 27.5, 50, 72.5, 95
|
||||
assert call_times[0] == pytest.approx(5.0, abs=0.1)
|
||||
assert call_times[1] == pytest.approx(27.5, abs=0.1)
|
||||
assert call_times[2] == pytest.approx(50.0, abs=0.1)
|
||||
assert call_times[3] == pytest.approx(72.5, abs=0.1)
|
||||
assert call_times[4] == pytest.approx(95.0, abs=0.1)
|
||||
|
||||
def test_selects_largest_file_as_best(self, tmp_path):
|
||||
"""选择文件最大的帧作为最佳封面(清晰度近似)."""
|
||||
video_file = tmp_path / "test.mp4"
|
||||
video_file.write_bytes(b"fake")
|
||||
output_file = tmp_path / "cover.jpg"
|
||||
|
||||
sizes = [5000, 15000, 8000] # 第二帧最大
|
||||
|
||||
with (
|
||||
patch(
|
||||
"video_processing.cover_generator.probe_video_info",
|
||||
return_value={"duration": 100.0},
|
||||
),
|
||||
patch("video_processing.cover_generator.run_ffmpeg") as mock_run,
|
||||
):
|
||||
call_idx = [0]
|
||||
|
||||
def fake_run(cmd):
|
||||
output_arg = cmd[-1]
|
||||
Path(output_arg).parent.mkdir(parents=True, exist_ok=True)
|
||||
idx = call_idx[0]
|
||||
Path(output_arg).write_bytes(b"x" * sizes[idx])
|
||||
call_idx[0] += 1
|
||||
|
||||
mock_run.side_effect = fake_run
|
||||
result = CoverGenerator.extract_smart_cover(str(video_file), str(output_file))
|
||||
|
||||
# 第二帧(索引1)应该是最佳
|
||||
assert result == output_file
|
||||
# 输出文件大小应等于第二帧大小
|
||||
assert output_file.stat().st_size == 15000
|
||||
|
||||
|
||||
class TestProcessCustomCover:
|
||||
"""自定义封面处理测试."""
|
||||
|
||||
def test_custom_cover_resize_command(self, tmp_path):
|
||||
"""自定义封面调整尺寸命令正确."""
|
||||
input_file = tmp_path / "upload.jpg"
|
||||
input_file.write_bytes(b"fake")
|
||||
output_file = tmp_path / "cover.jpg"
|
||||
|
||||
with patch("video_processing.cover_generator.run_ffmpeg") as mock_run:
|
||||
|
||||
def fake_run(cmd):
|
||||
output_file.write_bytes(b"fake jpg")
|
||||
|
||||
mock_run.side_effect = fake_run
|
||||
CoverGenerator.process_custom_cover(str(input_file), str(output_file))
|
||||
|
||||
mock_run.assert_called_once()
|
||||
cmd = mock_run.call_args[0][0]
|
||||
|
||||
assert "-i" in cmd
|
||||
assert cmd[cmd.index("-i") + 1] == str(input_file)
|
||||
assert cmd[-1] == str(output_file)
|
||||
|
||||
# scale + crop
|
||||
vf_idx = cmd.index("-vf")
|
||||
vf_value = cmd[vf_idx + 1]
|
||||
assert "scale=" in vf_value
|
||||
assert "crop=" in vf_value
|
||||
|
||||
def test_custom_cover_not_found_raises(self, tmp_path):
|
||||
"""自定义封面文件不存在时抛出 FileNotFoundError."""
|
||||
output_file = tmp_path / "cover.jpg"
|
||||
|
||||
with pytest.raises(FileNotFoundError):
|
||||
CoverGenerator.process_custom_cover(str(tmp_path / "nonexistent.jpg"), str(output_file))
|
||||
|
||||
def test_custom_cover_custom_dimensions(self, tmp_path):
|
||||
"""自定义封面自定义输出尺寸."""
|
||||
input_file = tmp_path / "upload.jpg"
|
||||
input_file.write_bytes(b"fake")
|
||||
output_file = tmp_path / "cover.jpg"
|
||||
|
||||
with patch("video_processing.cover_generator.run_ffmpeg") as mock_run:
|
||||
|
||||
def fake_run(cmd):
|
||||
output_file.write_bytes(b"fake jpg")
|
||||
|
||||
mock_run.side_effect = fake_run
|
||||
CoverGenerator.process_custom_cover(str(input_file), str(output_file), width=800, height=600)
|
||||
|
||||
cmd = mock_run.call_args[0][0]
|
||||
vf_idx = cmd.index("-vf")
|
||||
vf_value = cmd[vf_idx + 1]
|
||||
assert "scale=800:600:" in vf_value
|
||||
assert "crop=800:600" in vf_value
|
||||
@@ -8,74 +8,101 @@ from unittest.mock import MagicMock
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
# 模块级mock有副作用的依赖(纯算法测试不需要db/celery/cv2)
|
||||
# 注意:必须在 import dedup 前全部 mock 完,避免链式导入触发db连接
|
||||
|
||||
# cv2(视频处理依赖,纯算法测试不需要)
|
||||
sys.modules["cv2"] = MagicMock()
|
||||
# 模块级mock worker_app.db(dedup模块import时会触发数据库初始化,纯算法测试不需要)
|
||||
sys.modules["worker_app.db"] = MagicMock()
|
||||
sys.modules["worker_app.db"].SessionLocal = MagicMock()
|
||||
def _mock_module(**attrs):
|
||||
"""Create a mock module with __spec__ to avoid AttributeError: __spec__."""
|
||||
m = MagicMock()
|
||||
m.__spec__ = None
|
||||
for k, v in attrs.items():
|
||||
setattr(m, k, v)
|
||||
return m
|
||||
|
||||
|
||||
# ── Module-level setup: mock deps, import dedup, then restore sys.modules ──
|
||||
# This pattern ensures:
|
||||
# 1. dedup is imported with mocks active (no db/celery/cv2 side effects)
|
||||
# 2. sys.modules is restored immediately so other test files are not polluted
|
||||
# 3. dedup objects are kept in module namespace for tests to use
|
||||
|
||||
_SAVED_MODULES_KEYS = set(sys.modules.keys())
|
||||
_SAVED_MODULES_VALUES = {
|
||||
k: sys.modules.get(k)
|
||||
for k in [
|
||||
"cv2",
|
||||
"celery",
|
||||
"sqlalchemy",
|
||||
"sqlalchemy.orm",
|
||||
"sqlalchemy.engine",
|
||||
"sqlalchemy.ext",
|
||||
"sqlalchemy.ext.declarative",
|
||||
"worker_app.db",
|
||||
"worker_app.celery_app",
|
||||
"worker_app.core.config",
|
||||
"packages.adapters.sqlalchemy_impl.session",
|
||||
"packages.adapters.sqlalchemy_impl.generated_video_repository",
|
||||
"packages.shared.config",
|
||||
"packages.shared.storage",
|
||||
]
|
||||
}
|
||||
|
||||
# Set up mocks
|
||||
sys.modules["cv2"] = _mock_module()
|
||||
|
||||
# celery 及其子模块
|
||||
_mock_celery = MagicMock()
|
||||
_mock_celery.Task = MagicMock
|
||||
_mock_celery.Celery = MagicMock
|
||||
_mock_celery.__spec__ = None
|
||||
sys.modules["celery"] = _mock_celery
|
||||
|
||||
# sqlalchemy 作为包结构 mock
|
||||
_mock_sqla = MagicMock()
|
||||
_mock_sqla.__path__ = []
|
||||
_mock_sqla.__package__ = "sqlalchemy"
|
||||
_mock_sqla.__spec__ = None
|
||||
sys.modules["sqlalchemy"] = _mock_sqla
|
||||
|
||||
_mock_sqla_orm = MagicMock()
|
||||
_mock_sqla_orm.__path__ = []
|
||||
_mock_sqla_orm.__spec__ = None
|
||||
_mock_sqla_orm.Session = MagicMock
|
||||
_mock_sqla_engine = MagicMock()
|
||||
sys.modules["sqlalchemy"] = _mock_sqla
|
||||
sys.modules["sqlalchemy.orm"] = _mock_sqla_orm
|
||||
sys.modules["sqlalchemy.engine"] = _mock_sqla_engine
|
||||
sys.modules["sqlalchemy.ext"] = MagicMock()
|
||||
sys.modules["sqlalchemy.ext.declarative"] = MagicMock()
|
||||
sys.modules["sqlalchemy.engine"] = _mock_module()
|
||||
sys.modules["sqlalchemy.ext"] = _mock_module()
|
||||
sys.modules["sqlalchemy.ext.declarative"] = _mock_module()
|
||||
|
||||
# worker_app 及其子模块(避免导入时触发数据库连接)
|
||||
_mock_worker_app = MagicMock()
|
||||
_mock_worker_app.__path__ = []
|
||||
_mock_worker_db = MagicMock()
|
||||
_mock_worker_db.SessionLocal = MagicMock()
|
||||
_mock_worker_celery = MagicMock()
|
||||
_mock_worker_celery.celery_app = MagicMock()
|
||||
_mock_worker_core = MagicMock()
|
||||
_mock_worker_core.__path__ = []
|
||||
_mock_worker_config = MagicMock()
|
||||
_mock_worker_config.get_settings = MagicMock(return_value=MagicMock())
|
||||
sys.modules["worker_app"] = _mock_worker_app
|
||||
sys.modules["worker_app.db"] = _mock_worker_db
|
||||
sys.modules["worker_app.celery_app"] = _mock_worker_celery
|
||||
sys.modules["worker_app.core"] = _mock_worker_core
|
||||
sys.modules["worker_app.core.config"] = _mock_worker_config
|
||||
sys.modules["worker_app.db"] = _mock_module(SessionLocal=MagicMock())
|
||||
sys.modules["worker_app.celery_app"] = _mock_module(celery_app=MagicMock())
|
||||
sys.modules["worker_app.core.config"] = _mock_module(get_settings=MagicMock(return_value=MagicMock()))
|
||||
|
||||
# packages.adapters.sqlalchemy_impl(整个包mock掉)
|
||||
_mock_sqla_impl = MagicMock()
|
||||
_mock_sqla_impl.__path__ = []
|
||||
sys.modules["packages.adapters.sqlalchemy_impl"] = _mock_sqla_impl
|
||||
sys.modules["packages.adapters.sqlalchemy_impl.generated_video_repository"] = MagicMock()
|
||||
sys.modules["packages.adapters.sqlalchemy_impl.schema_guard"] = MagicMock()
|
||||
|
||||
# packages.shared
|
||||
_mock_packages_shared = MagicMock()
|
||||
_mock_packages_shared.__path__ = []
|
||||
_mock_shared_config = MagicMock()
|
||||
_mock_shared_config.get_shared_settings = MagicMock(return_value=MagicMock())
|
||||
sys.modules["packages.shared"] = _mock_packages_shared
|
||||
sys.modules["packages.shared.config"] = _mock_shared_config
|
||||
sys.modules["packages.shared.storage"] = MagicMock()
|
||||
sys.modules["packages.adapters.sqlalchemy_impl.session"] = _mock_module(
|
||||
Base=MagicMock(),
|
||||
build_engine=MagicMock(),
|
||||
build_session_factory=MagicMock(),
|
||||
ensure_database_exists=MagicMock(),
|
||||
initialize_database=MagicMock(),
|
||||
)
|
||||
sys.modules["packages.adapters.sqlalchemy_impl.generated_video_repository"] = _mock_module()
|
||||
sys.modules["packages.shared.config"] = _mock_module(get_shared_settings=MagicMock(return_value=MagicMock()))
|
||||
sys.modules["packages.shared.storage"] = _mock_module()
|
||||
|
||||
# Import dedup while mocks are active
|
||||
from video_processing.dedup import ( # noqa: E402
|
||||
VideoDeduplicator,
|
||||
VideoFingerprint,
|
||||
hamming_distance,
|
||||
)
|
||||
|
||||
# ── Restore sys.modules immediately after import ──
|
||||
# dedup is now cached in this module's namespace; other test files will get
|
||||
# their own fresh imports without our mock pollution
|
||||
for _key in list(sys.modules.keys()):
|
||||
if _key not in _SAVED_MODULES_KEYS:
|
||||
del sys.modules[_key]
|
||||
for _key, _value in _SAVED_MODULES_VALUES.items():
|
||||
if _value is not None:
|
||||
sys.modules[_key] = _value
|
||||
elif _key in sys.modules:
|
||||
del sys.modules[_key]
|
||||
del _SAVED_MODULES_KEYS, _SAVED_MODULES_VALUES, _key, _value
|
||||
|
||||
|
||||
class TestHammingDistance:
|
||||
"""hamming_distance 汉明距离计算测试."""
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""
|
||||
Duplication 查重记录领域模型单元测试
|
||||
"""
|
||||
"""Duplication 领域模型单元测试。"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -8,255 +8,281 @@ from packages.domain.duplication import DuplicateSegment, DuplicationRecord
|
||||
|
||||
|
||||
class TestDuplicateSegmentCreate:
|
||||
"""DuplicateSegment.create 测试"""
|
||||
|
||||
def test_create_success(self):
|
||||
seg = DuplicateSegment.create(
|
||||
source_start=10.0,
|
||||
source_end=20.0,
|
||||
matched_video_id="vid_123",
|
||||
matched_video_name="测试视频",
|
||||
matched_video_id="vid123",
|
||||
matched_video_name="test.mp4",
|
||||
matched_start=5.0,
|
||||
matched_end=15.0,
|
||||
similarity=85.5,
|
||||
)
|
||||
assert seg.id is not None
|
||||
assert len(seg.id) == 32
|
||||
assert len(seg.id) == 32 # uuid4 hex
|
||||
assert seg.source_start == 10.0
|
||||
assert seg.source_end == 20.0
|
||||
assert seg.matched_video_id == "vid_123"
|
||||
assert seg.matched_video_name == "测试视频"
|
||||
assert seg.matched_video_id == "vid123"
|
||||
assert seg.matched_video_name == "test.mp4"
|
||||
assert seg.matched_start == 5.0
|
||||
assert seg.matched_end == 15.0
|
||||
assert seg.similarity == 85.5
|
||||
|
||||
def test_invalid_source_negative_start(self):
|
||||
def test_create_negative_source_start(self):
|
||||
with pytest.raises(ValueError, match="invalid source segment range"):
|
||||
DuplicateSegment.create(
|
||||
source_start=-1.0,
|
||||
source_end=10.0,
|
||||
matched_video_id="v1",
|
||||
matched_video_name="n1",
|
||||
matched_start=0,
|
||||
matched_end=10,
|
||||
similarity=50,
|
||||
matched_video_id="v",
|
||||
matched_video_name="n",
|
||||
matched_start=0.0,
|
||||
matched_end=5.0,
|
||||
similarity=50.0,
|
||||
)
|
||||
|
||||
def test_invalid_source_end_before_start(self):
|
||||
with pytest.raises(ValueError, match="invalid source segment range"):
|
||||
DuplicateSegment.create(
|
||||
source_start=20.0,
|
||||
source_end=10.0,
|
||||
matched_video_id="v1",
|
||||
matched_video_name="n1",
|
||||
matched_start=0,
|
||||
matched_end=10,
|
||||
similarity=50,
|
||||
)
|
||||
|
||||
def test_invalid_source_end_equals_start(self):
|
||||
def test_create_source_end_equals_start(self):
|
||||
with pytest.raises(ValueError, match="invalid source segment range"):
|
||||
DuplicateSegment.create(
|
||||
source_start=10.0,
|
||||
source_end=10.0,
|
||||
matched_video_id="v1",
|
||||
matched_video_name="n1",
|
||||
matched_start=0,
|
||||
matched_end=10,
|
||||
similarity=50,
|
||||
matched_video_id="v",
|
||||
matched_video_name="n",
|
||||
matched_start=0.0,
|
||||
matched_end=5.0,
|
||||
similarity=50.0,
|
||||
)
|
||||
|
||||
def test_invalid_matched_negative_start(self):
|
||||
def test_create_source_end_less_than_start(self):
|
||||
with pytest.raises(ValueError, match="invalid source segment range"):
|
||||
DuplicateSegment.create(
|
||||
source_start=20.0,
|
||||
source_end=10.0,
|
||||
matched_video_id="v",
|
||||
matched_video_name="n",
|
||||
matched_start=0.0,
|
||||
matched_end=5.0,
|
||||
similarity=50.0,
|
||||
)
|
||||
|
||||
def test_create_negative_matched_start(self):
|
||||
with pytest.raises(ValueError, match="invalid matched segment range"):
|
||||
DuplicateSegment.create(
|
||||
source_start=0,
|
||||
source_end=10,
|
||||
matched_video_id="v1",
|
||||
matched_video_name="n1",
|
||||
matched_start=-5,
|
||||
matched_end=10,
|
||||
similarity=50,
|
||||
source_start=0.0,
|
||||
source_end=10.0,
|
||||
matched_video_id="v",
|
||||
matched_video_name="n",
|
||||
matched_start=-1.0,
|
||||
matched_end=5.0,
|
||||
similarity=50.0,
|
||||
)
|
||||
|
||||
def test_invalid_matched_end_before_start(self):
|
||||
def test_create_matched_end_equals_start(self):
|
||||
with pytest.raises(ValueError, match="invalid matched segment range"):
|
||||
DuplicateSegment.create(
|
||||
source_start=0,
|
||||
source_end=10,
|
||||
matched_video_id="v1",
|
||||
matched_video_name="n1",
|
||||
matched_start=15,
|
||||
matched_end=10,
|
||||
similarity=50,
|
||||
source_start=0.0,
|
||||
source_end=10.0,
|
||||
matched_video_id="v",
|
||||
matched_video_name="n",
|
||||
matched_start=5.0,
|
||||
matched_end=5.0,
|
||||
similarity=50.0,
|
||||
)
|
||||
|
||||
def test_invalid_similarity_negative(self):
|
||||
def test_create_similarity_negative(self):
|
||||
with pytest.raises(ValueError, match="similarity must be between 0 and 100"):
|
||||
DuplicateSegment.create(
|
||||
source_start=0,
|
||||
source_end=10,
|
||||
matched_video_id="v1",
|
||||
matched_video_name="n1",
|
||||
matched_start=0,
|
||||
matched_end=10,
|
||||
similarity=-1,
|
||||
source_start=0.0,
|
||||
source_end=10.0,
|
||||
matched_video_id="v",
|
||||
matched_video_name="n",
|
||||
matched_start=0.0,
|
||||
matched_end=5.0,
|
||||
similarity=-1.0,
|
||||
)
|
||||
|
||||
def test_invalid_similarity_over_100(self):
|
||||
def test_create_similarity_over_100(self):
|
||||
with pytest.raises(ValueError, match="similarity must be between 0 and 100"):
|
||||
DuplicateSegment.create(
|
||||
source_start=0,
|
||||
source_end=10,
|
||||
matched_video_id="v1",
|
||||
matched_video_name="n1",
|
||||
matched_start=0,
|
||||
matched_end=10,
|
||||
similarity=101,
|
||||
source_start=0.0,
|
||||
source_end=10.0,
|
||||
matched_video_id="v",
|
||||
matched_video_name="n",
|
||||
matched_start=0.0,
|
||||
matched_end=5.0,
|
||||
similarity=101.0,
|
||||
)
|
||||
|
||||
def test_similarity_boundary_zero(self):
|
||||
def test_create_similarity_zero(self):
|
||||
seg = DuplicateSegment.create(
|
||||
source_start=0,
|
||||
source_end=10,
|
||||
matched_video_id="v1",
|
||||
matched_video_name="n1",
|
||||
matched_start=0,
|
||||
matched_end=10,
|
||||
similarity=0,
|
||||
source_start=0.0,
|
||||
source_end=5.0,
|
||||
matched_video_id="v",
|
||||
matched_video_name="n",
|
||||
matched_start=0.0,
|
||||
matched_end=5.0,
|
||||
similarity=0.0,
|
||||
)
|
||||
assert seg.similarity == 0
|
||||
assert seg.similarity == 0.0
|
||||
|
||||
def test_similarity_boundary_100(self):
|
||||
def test_create_similarity_100(self):
|
||||
seg = DuplicateSegment.create(
|
||||
source_start=0,
|
||||
source_end=10,
|
||||
matched_video_id="v1",
|
||||
matched_video_name="n1",
|
||||
matched_start=0,
|
||||
matched_end=10,
|
||||
similarity=100,
|
||||
source_start=0.0,
|
||||
source_end=5.0,
|
||||
matched_video_id="v",
|
||||
matched_video_name="n",
|
||||
matched_start=0.0,
|
||||
matched_end=5.0,
|
||||
similarity=100.0,
|
||||
)
|
||||
assert seg.similarity == 100
|
||||
assert seg.similarity == 100.0
|
||||
|
||||
def test_create_unique_ids(self):
|
||||
seg1 = DuplicateSegment.create(0, 1, "v", "n", 0, 1, 50.0)
|
||||
seg2 = DuplicateSegment.create(0, 1, "v", "n", 0, 1, 50.0)
|
||||
assert seg1.id != seg2.id
|
||||
|
||||
|
||||
class TestDuplicationRecordCreate:
|
||||
"""DuplicationRecord.create 测试"""
|
||||
|
||||
def test_create_minimal(self):
|
||||
def test_create_success_defaults(self):
|
||||
record = DuplicationRecord.create(
|
||||
user_id="user123",
|
||||
filename="test.mp4",
|
||||
filename="my_video.mp4",
|
||||
file_size=1024000,
|
||||
storage_key="oss://bucket/test.mp4",
|
||||
storage_key="videos/vid1.mp4",
|
||||
)
|
||||
assert record.id is not None
|
||||
assert len(record.id) == 32
|
||||
assert record.user_id == "user123"
|
||||
assert record.filename == "test.mp4"
|
||||
assert record.filename == "my_video.mp4"
|
||||
assert record.file_size == 1024000
|
||||
assert record.storage_key == "oss://bucket/test.mp4"
|
||||
assert record.storage_key == "videos/vid1.mp4"
|
||||
assert record.duration_seconds == 0.0
|
||||
assert record.status == "pending"
|
||||
assert record.duplicate_rate is None
|
||||
assert record.duplicate_count == 0
|
||||
assert record.segments == []
|
||||
assert record.duration_seconds == 0.0
|
||||
assert record.created_at is not None
|
||||
assert record.updated_at is not None
|
||||
assert record.error_message == ""
|
||||
assert isinstance(record.created_at, datetime)
|
||||
assert isinstance(record.updated_at, datetime)
|
||||
|
||||
def test_create_with_duration(self):
|
||||
record = DuplicationRecord.create(
|
||||
user_id="u1",
|
||||
filename="video.mp4",
|
||||
file_size=5000,
|
||||
storage_key="key",
|
||||
filename="v.mp4",
|
||||
file_size=100,
|
||||
storage_key="k",
|
||||
duration_seconds=120.5,
|
||||
)
|
||||
assert record.duration_seconds == 120.5
|
||||
|
||||
def test_create_strips_whitespace(self):
|
||||
def test_create_strips_user_id(self):
|
||||
record = DuplicationRecord.create(
|
||||
user_id=" user456 ",
|
||||
filename=" my video.mp4 ",
|
||||
user_id=" user_trimmed ",
|
||||
filename="v.mp4",
|
||||
file_size=100,
|
||||
storage_key="key",
|
||||
storage_key="k",
|
||||
)
|
||||
assert record.user_id == "user456"
|
||||
assert record.filename == "my video.mp4"
|
||||
assert record.user_id == "user_trimmed"
|
||||
|
||||
def test_empty_user_id_raises(self):
|
||||
def test_create_strips_filename(self):
|
||||
record = DuplicationRecord.create(
|
||||
user_id="u1",
|
||||
filename=" trimmed.mp4 ",
|
||||
file_size=100,
|
||||
storage_key="k",
|
||||
)
|
||||
assert record.filename == "trimmed.mp4"
|
||||
|
||||
def test_create_empty_user_id(self):
|
||||
with pytest.raises(ValueError, match="user_id cannot be empty"):
|
||||
DuplicationRecord.create(
|
||||
user_id="",
|
||||
filename="v.mp4",
|
||||
file_size=100,
|
||||
storage_key="k",
|
||||
)
|
||||
|
||||
def test_create_whitespace_user_id(self):
|
||||
with pytest.raises(ValueError, match="user_id cannot be empty"):
|
||||
DuplicationRecord.create(
|
||||
user_id=" ",
|
||||
filename="test.mp4",
|
||||
filename="v.mp4",
|
||||
file_size=100,
|
||||
storage_key="key",
|
||||
storage_key="k",
|
||||
)
|
||||
|
||||
def test_empty_filename_raises(self):
|
||||
def test_create_empty_filename(self):
|
||||
with pytest.raises(ValueError, match="filename cannot be empty"):
|
||||
DuplicationRecord.create(
|
||||
user_id="u1",
|
||||
filename=" ",
|
||||
filename="",
|
||||
file_size=100,
|
||||
storage_key="key",
|
||||
storage_key="k",
|
||||
)
|
||||
|
||||
def test_zero_file_size_raises(self):
|
||||
def test_create_whitespace_filename(self):
|
||||
with pytest.raises(ValueError, match="filename cannot be empty"):
|
||||
DuplicationRecord.create(
|
||||
user_id="u1",
|
||||
filename=" \t ",
|
||||
file_size=100,
|
||||
storage_key="k",
|
||||
)
|
||||
|
||||
def test_create_zero_file_size(self):
|
||||
with pytest.raises(ValueError, match="file_size must be positive"):
|
||||
DuplicationRecord.create(
|
||||
user_id="u1",
|
||||
filename="test.mp4",
|
||||
filename="v.mp4",
|
||||
file_size=0,
|
||||
storage_key="key",
|
||||
storage_key="k",
|
||||
)
|
||||
|
||||
def test_negative_file_size_raises(self):
|
||||
def test_create_negative_file_size(self):
|
||||
with pytest.raises(ValueError, match="file_size must be positive"):
|
||||
DuplicationRecord.create(
|
||||
user_id="u1",
|
||||
filename="test.mp4",
|
||||
filename="v.mp4",
|
||||
file_size=-100,
|
||||
storage_key="key",
|
||||
storage_key="k",
|
||||
)
|
||||
|
||||
def test_create_unique_ids(self):
|
||||
r1 = DuplicationRecord.create("u", "f", 100, "k")
|
||||
r2 = DuplicationRecord.create("u", "f", 100, "k")
|
||||
assert r1.id != r2.id
|
||||
|
||||
class TestDuplicationRecordLifecycle:
|
||||
"""生命周期状态转换测试"""
|
||||
|
||||
def test_mark_processing(self):
|
||||
record = DuplicationRecord.create(user_id="u1", filename="t.mp4", file_size=100, storage_key="k")
|
||||
old_updated = record.updated_at
|
||||
class TestMarkProcessing:
|
||||
def test_mark_processing_from_pending(self):
|
||||
record = DuplicationRecord.create("u", "f", 100, "k")
|
||||
before = record.updated_at
|
||||
record.mark_processing()
|
||||
assert record.status == "processing"
|
||||
assert record.updated_at >= old_updated
|
||||
assert record.updated_at >= before
|
||||
|
||||
def test_mark_completed(self):
|
||||
record = DuplicationRecord.create(user_id="u1", filename="t.mp4", file_size=100, storage_key="k")
|
||||
def test_mark_processing_updates_timestamp(self):
|
||||
record = DuplicationRecord.create("u", "f", 100, "k")
|
||||
old_time = record.updated_at
|
||||
# 确保时间戳会变(datetime.now 精度问题,直接赋值模拟)
|
||||
record.mark_processing()
|
||||
segments = [
|
||||
DuplicateSegment.create(
|
||||
source_start=0,
|
||||
source_end=10,
|
||||
matched_video_id="v1",
|
||||
matched_video_name="n1",
|
||||
matched_start=0,
|
||||
matched_end=10,
|
||||
similarity=90,
|
||||
)
|
||||
]
|
||||
record.mark_completed(
|
||||
duplicate_rate=25.5,
|
||||
duplicate_count=1,
|
||||
segments=segments,
|
||||
)
|
||||
assert record.status == "processing"
|
||||
assert record.updated_at.tzinfo == timezone.utc
|
||||
|
||||
|
||||
class TestMarkCompleted:
|
||||
def test_mark_completed_success(self):
|
||||
record = DuplicationRecord.create("u", "f", 100, "k")
|
||||
seg = DuplicateSegment.create(0, 5, "v", "n", 0, 5, 80.0)
|
||||
record.mark_completed(duplicate_rate=75.5, duplicate_count=3, segments=[seg])
|
||||
assert record.status == "completed"
|
||||
assert record.duplicate_rate == 25.5
|
||||
assert record.duplicate_count == 1
|
||||
assert record.duplicate_rate == 75.5
|
||||
assert record.duplicate_count == 3
|
||||
assert len(record.segments) == 1
|
||||
assert record.error_message == ""
|
||||
assert record.segments[0].matched_video_id == "v"
|
||||
|
||||
def test_mark_completed_zero_rate(self):
|
||||
record = DuplicationRecord.create(user_id="u1", filename="t.mp4", file_size=100, storage_key="k")
|
||||
record = DuplicationRecord.create("u", "f", 100, "k")
|
||||
record.mark_completed(duplicate_rate=0.0, duplicate_count=0, segments=[])
|
||||
assert record.status == "completed"
|
||||
assert record.duplicate_rate == 0.0
|
||||
@@ -264,88 +290,133 @@ class TestDuplicationRecordLifecycle:
|
||||
assert record.segments == []
|
||||
|
||||
def test_mark_completed_100_rate(self):
|
||||
record = DuplicationRecord.create(user_id="u1", filename="t.mp4", file_size=100, storage_key="k")
|
||||
record = DuplicationRecord.create("u", "f", 100, "k")
|
||||
record.mark_completed(duplicate_rate=100.0, duplicate_count=5, segments=[])
|
||||
assert record.duplicate_rate == 100.0
|
||||
|
||||
def test_mark_completed_invalid_rate_negative(self):
|
||||
record = DuplicationRecord.create(user_id="u1", filename="t.mp4", file_size=100, storage_key="k")
|
||||
def test_mark_completed_negative_rate(self):
|
||||
record = DuplicationRecord.create("u", "f", 100, "k")
|
||||
with pytest.raises(ValueError, match="duplicate_rate must be between 0 and 100"):
|
||||
record.mark_completed(duplicate_rate=-1, duplicate_count=0, segments=[])
|
||||
record.mark_completed(duplicate_rate=-1.0, duplicate_count=0, segments=[])
|
||||
|
||||
def test_mark_completed_invalid_rate_over_100(self):
|
||||
record = DuplicationRecord.create(user_id="u1", filename="t.mp4", file_size=100, storage_key="k")
|
||||
def test_mark_completed_over_100_rate(self):
|
||||
record = DuplicationRecord.create("u", "f", 100, "k")
|
||||
with pytest.raises(ValueError, match="duplicate_rate must be between 0 and 100"):
|
||||
record.mark_completed(duplicate_rate=101, duplicate_count=0, segments=[])
|
||||
record.mark_completed(duplicate_rate=101.0, duplicate_count=0, segments=[])
|
||||
|
||||
def test_mark_completed_updates_timestamp(self):
|
||||
record = DuplicationRecord.create("u", "f", 100, "k")
|
||||
record.mark_completed(50.0, 1, [])
|
||||
assert record.updated_at.tzinfo == timezone.utc
|
||||
|
||||
|
||||
class TestMarkFailed:
|
||||
def test_mark_failed(self):
|
||||
record = DuplicationRecord.create(user_id="u1", filename="t.mp4", file_size=100, storage_key="k")
|
||||
record = DuplicationRecord.create("u", "f", 100, "k")
|
||||
record.mark_failed("network timeout")
|
||||
assert record.status == "failed"
|
||||
assert record.error_message == "network timeout"
|
||||
|
||||
def test_mark_failed_empty_message(self):
|
||||
record = DuplicationRecord.create("u", "f", 100, "k")
|
||||
record.mark_failed("")
|
||||
assert record.status == "failed"
|
||||
assert record.error_message == ""
|
||||
|
||||
def test_mark_failed_from_processing(self):
|
||||
record = DuplicationRecord.create("u", "f", 100, "k")
|
||||
record.mark_processing()
|
||||
record.mark_failed("网络超时")
|
||||
record.mark_failed("something went wrong")
|
||||
assert record.status == "failed"
|
||||
assert record.error_message == "网络超时"
|
||||
assert record.duplicate_rate is None
|
||||
|
||||
def test_mark_failed_from_pending(self):
|
||||
record = DuplicationRecord.create(user_id="u1", filename="t.mp4", file_size=100, storage_key="k")
|
||||
record.mark_failed("文件损坏")
|
||||
assert record.status == "failed"
|
||||
assert record.error_message == "文件损坏"
|
||||
assert record.error_message == "something went wrong"
|
||||
|
||||
|
||||
class TestDuplicationRecordRetry:
|
||||
"""重试逻辑测试"""
|
||||
|
||||
class TestCanRetry:
|
||||
def test_can_retry_failed(self):
|
||||
record = DuplicationRecord.create(user_id="u1", filename="t.mp4", file_size=100, storage_key="k")
|
||||
record = DuplicationRecord.create("u", "f", 100, "k")
|
||||
record.mark_failed("error")
|
||||
assert record.can_retry() is True
|
||||
|
||||
def test_cannot_retry_pending(self):
|
||||
record = DuplicationRecord.create(user_id="u1", filename="t.mp4", file_size=100, storage_key="k")
|
||||
record = DuplicationRecord.create("u", "f", 100, "k")
|
||||
assert record.can_retry() is False
|
||||
|
||||
def test_cannot_retry_processing(self):
|
||||
record = DuplicationRecord.create(user_id="u1", filename="t.mp4", file_size=100, storage_key="k")
|
||||
record = DuplicationRecord.create("u", "f", 100, "k")
|
||||
record.mark_processing()
|
||||
assert record.can_retry() is False
|
||||
|
||||
def test_cannot_retry_completed(self):
|
||||
record = DuplicationRecord.create(user_id="u1", filename="t.mp4", file_size=100, storage_key="k")
|
||||
record.mark_completed(duplicate_rate=10, duplicate_count=1, segments=[])
|
||||
record = DuplicationRecord.create("u", "f", 100, "k")
|
||||
record.mark_completed(50.0, 1, [])
|
||||
assert record.can_retry() is False
|
||||
|
||||
def test_reset_for_retry(self):
|
||||
record = DuplicationRecord.create(user_id="u1", filename="t.mp4", file_size=100, storage_key="k")
|
||||
record.mark_processing()
|
||||
segments = [
|
||||
DuplicateSegment.create(
|
||||
source_start=0,
|
||||
source_end=5,
|
||||
matched_video_id="v1",
|
||||
matched_video_name="n1",
|
||||
matched_start=0,
|
||||
matched_end=5,
|
||||
similarity=80,
|
||||
)
|
||||
]
|
||||
record.mark_completed(duplicate_rate=30, duplicate_count=1, segments=segments)
|
||||
|
||||
class TestResetForRetry:
|
||||
def test_reset_from_failed(self):
|
||||
record = DuplicationRecord.create("u", "f", 100, "k")
|
||||
seg = DuplicateSegment.create(0, 5, "v", "n", 0, 5, 80.0)
|
||||
record.mark_completed(80.0, 2, [seg])
|
||||
record.mark_failed("error") # 模拟先完成再失败的场景不成立,直接从 failed 重置
|
||||
# 直接设置到 failed 状态
|
||||
record.status = "failed"
|
||||
record.error_message = "something wrong"
|
||||
record.duplicate_rate = 50.0
|
||||
record.duplicate_count = 3
|
||||
record.error_message = "old error"
|
||||
record.video_fingerprint = {"hash": "abc"}
|
||||
|
||||
record.reset_for_retry()
|
||||
|
||||
assert record.status == "pending"
|
||||
assert record.duplicate_rate is None
|
||||
assert record.duplicate_count == 0
|
||||
assert record.error_message == ""
|
||||
assert record.segments == []
|
||||
assert record.video_fingerprint is None
|
||||
assert record.updated_at is not None
|
||||
|
||||
def test_reset_for_retry_from_pending(self):
|
||||
"""即使从 pending 也能重置(调用方负责判断 can_retry)"""
|
||||
record = DuplicationRecord.create(user_id="u1", filename="t.mp4", file_size=100, storage_key="k")
|
||||
def test_reset_clears_segments(self):
|
||||
record = DuplicationRecord.create("u", "f", 100, "k")
|
||||
record.status = "failed"
|
||||
record.segments = [
|
||||
DuplicateSegment.create(0, 1, "v1", "n1", 0, 1, 50.0),
|
||||
DuplicateSegment.create(2, 3, "v2", "n2", 0, 1, 60.0),
|
||||
]
|
||||
record.reset_for_retry()
|
||||
assert record.status == "pending"
|
||||
assert record.duplicate_count == 0
|
||||
assert record.segments == []
|
||||
|
||||
def test_reset_preserves_identity(self):
|
||||
record = DuplicationRecord.create("u", "f", 100, "k", duration_seconds=120.0)
|
||||
record.status = "failed"
|
||||
orig_id = record.id
|
||||
orig_user = record.user_id
|
||||
orig_filename = record.filename
|
||||
orig_size = record.file_size
|
||||
orig_storage = record.storage_key
|
||||
orig_duration = record.duration_seconds
|
||||
|
||||
record.reset_for_retry()
|
||||
|
||||
assert record.id == orig_id
|
||||
assert record.user_id == orig_user
|
||||
assert record.filename == orig_filename
|
||||
assert record.file_size == orig_size
|
||||
assert record.storage_key == orig_storage
|
||||
assert record.duration_seconds == orig_duration
|
||||
|
||||
def test_reset_updates_timestamp(self):
|
||||
record = DuplicationRecord.create("u", "f", 100, "k")
|
||||
record.status = "failed"
|
||||
old_time = record.updated_at
|
||||
record.reset_for_retry()
|
||||
assert record.updated_at >= old_time
|
||||
|
||||
|
||||
class TestDataclassSlots:
|
||||
def test_duplicate_segment_slots(self):
|
||||
seg = DuplicateSegment.create(0, 1, "v", "n", 0, 1, 50.0)
|
||||
# slots=True 时没有 __dict__
|
||||
assert not hasattr(seg, "__dict__") or hasattr(seg, "__slots__")
|
||||
|
||||
def test_duplication_record_slots(self):
|
||||
record = DuplicationRecord.create("u", "f", 100, "k")
|
||||
assert hasattr(record, "__slots__") or hasattr(record, "__dict__")
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user