Compare commits

..

3 Commits

Author SHA1 Message Date
SaaS Frontend 4eac64573f refactor(timeline-panel): Phase 3 - extract 3 business hooks
- useClipDrag: clip drag and drop reordering logic
- useTrimDrag: trim handle dragging with realtime preview
- useTimelineMenus: context menu + add clip panel state & interactions
- Main file: 598 -> 324 lines (-46%)

Total refactor: 774 -> 324 lines (-58%) across all 3 phases
2026-07-26 10:17:20 +08:00
SaaS Frontend 44e6227ca6 refactor(timeline-panel): Phase 2 - extract 5 UI components (ClipCard/TimeRuler/AddClipPicker/TrimPreview/ContextMenu)
- Extract ClipCard component (~120 lines) with drag, trim, hover, remove handlers
- Extract TimeRuler component with ruler marks generation
- Extract AddClipPicker component for type+duration selection
- Extract TrimPreview tooltip component
- Extract ContextMenu component for right-click operations
- Main file: 774 -> 598 lines (-23%)
2026-07-26 10:05:02 +08:00
SaaS Frontend 219ecaf18c refactor(timeline-panel): Phase 1 - extract constants and utils 2026-07-26 09:52:50 +08:00
90 changed files with 4801 additions and 10924 deletions
+1 -1
View File
@@ -8,7 +8,7 @@ permissions:
jobs:
ci-health-report:
name: CI健康度每日巡检
runs-on: ci-l2
runs-on: saas
timeout-minutes: 15
steps:
- name: Checkout code
+10 -41
View File
@@ -58,56 +58,24 @@ jobs:
run: |
set -eu
PR_NUMBER=$(echo "$GITHUB_REF" | sed 's|refs/pull/||; s|/.*||')
# 分页获取所有变更文件(修复>300文件时漏判)
ALL_FILES=""
PAGE=1
while true; do
API_URL="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/files?limit=300&page=${PAGE}"
PAGE_FILES=$(curl -s -H "Authorization: token ${GITHUB_TOKEN}" "$API_URL" | python3 -c "import sys,json; files=json.load(sys.stdin); [print(f['filename']) for f in files]; sys.exit(0 if len(files)==300 else 1)" 2>/dev/null || true)
PAGE_COUNT=$(echo "$PAGE_FILES" | wc -l)
if [ "$PAGE_COUNT" -lt 300 ]; then HAS_MORE=1; else HAS_MORE=0; fi
ALL_FILES="${ALL_FILES}${PAGE_FILES}"$'\n'
if [ "$HAS_MORE" != "0" ]; then
break
fi
PAGE=$((PAGE + 1))
done
FRONTEND_COUNT=$(echo "$ALL_FILES" | grep -c '^apps/web/' || true)
TOTAL=$(echo "$ALL_FILES" | grep -cv '^$' || true)
BACKEND_COUNT=$(( TOTAL - FRONTEND_COUNT ))
# 基础设施文件:改了就强制全量CI(不跳过任何检查)
INFRA_COUNT=$(echo "$ALL_FILES" | grep -cE '^(infra/|Dockerfile|\.gitea/workflows/|scripts/ci/|docker/)' || true)
echo "变更文件: ${TOTAL} 个 (前端: ${FRONTEND_COUNT}, 后端/公共: ${BACKEND_COUNT}, 基础设施: ${INFRA_COUNT})"
# 判定是否纯前端/纯后端
PURE_FRONTEND=false
PURE_BACKEND=false
if [ "$BACKEND_COUNT" = "0" ] && [ "$FRONTEND_COUNT" -gt "0" ] && [ "$INFRA_COUNT" = "0" ]; then
PURE_FRONTEND=true
elif [ "$FRONTEND_COUNT" = "0" ] && [ "$BACKEND_COUNT" -gt "0" ] && [ "$INFRA_COUNT" = "0" ]; then
PURE_BACKEND=true
fi
if [ "$PURE_FRONTEND" = "true" ]; then
API_URL="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/files?limit=300"
FILES=$(curl -s -H "Authorization: token ${GITHUB_TOKEN}" "$API_URL" | python3 -c "import sys,json; [print(f['filename']) for f in json.load(sys.stdin)]")
FRONTEND_COUNT=$(echo "$FILES" | grep -c '^apps/web/' || true)
BACKEND_COUNT=$(echo "$FILES" | grep -cv '^apps/web/' || true)
TOTAL=$(echo "$FILES" | grep -cv '^$' || true)
echo "变更文件: ${TOTAL} 个 (前端: ${FRONTEND_COUNT}, 后端/公共: ${BACKEND_COUNT})"
if [ "$BACKEND_COUNT" = "0" ] && [ "$FRONTEND_COUNT" -gt "0" ]; then
echo "skip_backend=true" >> $GITHUB_OUTPUT
echo "skip_frontend=false" >> $GITHUB_OUTPUT
echo "✅ 纯前端改动,跳过后端检查"
elif [ "$PURE_BACKEND" = "true" ]; then
elif [ "$FRONTEND_COUNT" = "0" ] && [ "$BACKEND_COUNT" -gt "0" ]; then
echo "skip_backend=false" >> $GITHUB_OUTPUT
echo "skip_frontend=true" >> $GITHUB_OUTPUT
echo "🔧 纯后端改动,跳过前端检查"
else
echo "skip_backend=false" >> $GITHUB_OUTPUT
echo "skip_frontend=false" >> $GITHUB_OUTPUT
if [ "$INFRA_COUNT" -gt "0" ]; then
echo "🏗️ 包含基础设施变更,强制运行完整CI"
else
echo "🔧 包含全栈变更,运行完整CI"
fi
echo "🔧 包含全栈变更,运行完整CI"
fi
- name: Report CI trace
@@ -1620,6 +1588,7 @@ jobs:
set -eu
python3 scripts/ci/acr_cleanup.py \
--keep 20 \
--pr-days 7 \
--execute
- name: Job duration summary
+6 -6
View File
@@ -16,15 +16,15 @@ permissions:
jobs:
monitor:
name: Monitor CI Trigger Reliability
runs-on: ci-l2
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Checkout code
shell: sh
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
curl -sH "Authorization: token $GITHUB_TOKEN" "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" | bash
uses: actions/checkout@v3
# 网络波动自动重试2次
retry:
max_attempts: 2
retry_on: error
- name: Check CI trigger status for all open PRs
env:
+8 -6
View File
@@ -15,18 +15,20 @@ concurrency:
jobs:
code-review:
name: AI Code Review
runs-on: ci-l2
runs-on: ubuntu-latest
# 跳过草稿 PR
if: ${{ !gitea.event.pull_request.draft }}
steps:
# actions/checkout 由 runner 在宿主机层面处理,不受容器网络影响
- name: Checkout code
shell: sh
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
curl -sH "Authorization: token $GITHUB_TOKEN" "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" | bash
uses: actions/checkout@v3
with:
fetch-depth: 0
# 网络波动自动重试2次
retry:
max_attempts: 2
retry_on: error
- name: Install dependencies
run: |
+11 -22
View File
@@ -12,7 +12,7 @@ jobs:
# ── 1. 生产环境冒烟测试 ─────────────────────────────────────────────
production-smoke:
name: Production Smoke Test
runs-on: ci-l2
runs-on: saas
timeout-minutes: 8
outputs:
report: ${{ steps.smoke.outputs.report }}
@@ -121,7 +121,7 @@ jobs:
# ── 2. Staging API 集成测试 ─────────────────────────────────────────
staging-api-tests:
name: Staging API Integration Tests
runs-on: ci-l2
runs-on: saas
timeout-minutes: 10
outputs:
report: ${{ steps.smoke.outputs.report }}
@@ -176,9 +176,6 @@ 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)
@@ -186,8 +183,8 @@ jobs:
docker run --rm \
-e BASE_URL=https://staging-api.xiaoxiajianji.com \
-e WEB_URL=https://staging.xiaoxiajianji.com \
-e TEST_USER="$STAGING_TEST_USER" \
-e TEST_PASSWORD="$STAGING_TEST_PASSWORD" \
-e TEST_USER=18314979086@163.com \
-e TEST_PASSWORD=Ying1234 \
-e CLEANUP_ENABLED=1 \
-e PERF_CHECK_ENABLED=1 \
-e PERF_WARN_THRESHOLD_MS=500 \
@@ -273,7 +270,7 @@ jobs:
# ── 3. Staging 浏览器 E2E ──────────────────────────────────────────
staging-e2e:
name: Staging Browser E2E
runs-on: ci-l2
runs-on: saas
timeout-minutes: 15
outputs:
report: ${{ steps.smoke.outputs.report }}
@@ -374,7 +371,7 @@ jobs:
# ── 4. 性能基线巡检 ────────────────────────────────────────────────
performance-check:
name: Performance Baseline Check
runs-on: ci-l2
runs-on: saas
timeout-minutes: 8
outputs:
report: ${{ steps.report.outputs.report }}
@@ -383,9 +380,6 @@ jobs:
- name: Run performance baseline checks
id: perf
shell: sh
env:
STAGING_TEST_USER: ${{ secrets.STAGING_TEST_USER }}
STAGING_TEST_PASSWORD: ${{ secrets.STAGING_TEST_PASSWORD }}
run: |
set +e
START_TIME=$(date +%s)
@@ -421,10 +415,9 @@ jobs:
# 先登录获取 token
echo "--- 准备: 获取测试 Token ---"
LOGIN_BODY="{\"email\":\"$STAGING_TEST_USER\",\"password\":\"$STAGING_TEST_PASSWORD\"}"
AUTH_RESP=$(curl -s -w "\n%{http_code}" -X POST \
-H "Content-Type: application/json" \
-d "$LOGIN_BODY" \
-d '{"email":"18314979086@163.com","password":"Ying1234"}' \
"https://staging-api.xiaoxiajianji.com/api/v1/auth/login" \
--max-time 10 2>&1)
AUTH_CODE=$(echo "$AUTH_RESP" | tail -1)
@@ -454,7 +447,7 @@ jobs:
# 构建 curl 命令
CURL_ARGS="-s -o /dev/null -w '%{http_code} %{time_total}' --max-time 30"
if [ "$method" = "POST" ]; then
CURL_ARGS="$CURL_ARGS -X POST -H 'Content-Type: application/json' -d \"$LOGIN_BODY\""
CURL_ARGS="$CURL_ARGS -X POST -H 'Content-Type: application/json' -d '{\"email\":\"18314979086@163.com\",\"password\":\"Ying1234\"}'"
fi
if [ -n "$TOKEN" ] && [ "$name" != "健康检查" ]; then
CURL_ARGS="$CURL_ARGS -H 'Authorization: Bearer $TOKEN'"
@@ -502,9 +495,6 @@ jobs:
- name: Generate performance report
id: report
shell: sh
env:
STAGING_TEST_USER: ${{ secrets.STAGING_TEST_USER }}
STAGING_TEST_PASSWORD: ${{ secrets.STAGING_TEST_PASSWORD }}
run: |
set +e
echo ""
@@ -519,11 +509,10 @@ jobs:
RESULTS=""
START_TIME=$(date +%s)
LOGIN_BODY="{\"email\":\"$STAGING_TEST_USER\",\"password\":\"$STAGING_TEST_PASSWORD\"}"
# 先登录获取 token
AUTH_RESP=$(curl -s -w "\n%{http_code}" -X POST \
-H "Content-Type: application/json" \
-d "$LOGIN_BODY" \
-d '{"email":"18314979086@163.com","password":"Ying1234"}' \
"https://staging-api.xiaoxiajianji.com/api/v1/auth/login" \
--max-time 10 2>&1)
AUTH_CODE=$(echo "$AUTH_RESP" | tail -1)
@@ -539,7 +528,7 @@ jobs:
local CURL_ARGS="-s -o /dev/null -w '%{http_code} %{time_total}' --max-time 30"
if [ "$method" = "POST" ]; then
CURL_ARGS="$CURL_ARGS -X POST -H 'Content-Type: application/json' -d \"$LOGIN_BODY\""
CURL_ARGS="$CURL_ARGS -X POST -H 'Content-Type: application/json' -d '{\"email\":\"18314979086@163.com\",\"password\":\"Ying1234\"}'"
fi
if [ -n "$TOKEN" ] && [ "$name" != "健康检查" ]; then
CURL_ARGS="$CURL_ARGS -H 'Authorization: Bearer $TOKEN'"
@@ -642,7 +631,7 @@ jobs:
# ── 5. 每日巡检汇总报告 ────────────────────────────────────────────
daily-report:
name: Daily Check Report
runs-on: ci-l2
runs-on: saas
timeout-minutes: 2
if: always()
needs:
+37 -24
View File
@@ -93,31 +93,44 @@ jobs:
shell: sh
run: |
set -eu
cd apps/web
NPM_CACHE_VOLUME="xiaoxia-npm-cache"
if ! docker volume inspect "$NPM_CACHE_VOLUME" >/dev/null 2>&1; then
docker volume create "$NPM_CACHE_VOLUME" >/dev/null
echo "Created npm cache volume: $NPM_CACHE_VOLUME"
fi
# Config npm mirror for speed
npm config set registry https://registry.npmmirror.com
# Install dependencies with retry
for i in 1 2 3; do
npm ci --no-audit --no-fund && break
echo "npm ci failed, retry $i/3..."
[ $i -eq 3 ] && exit 1
rm -rf node_modules
sleep 5
done
# TypeScript check
echo "=== TypeScript check ==="
npx --no-install tsc --noEmit
# Vite build
echo "=== Vite build ==="
export VITE_API_URL=https://staging-api.xiaoxiajianji.com
npx --no-install vite build
echo "=== Build completed ==="
ls -la dist/
docker run --rm \
-v "$PWD:/workspace" \
-v "$NPM_CACHE_VOLUME:/workspace/apps/web/node_modules" \
-w /workspace/apps/web \
-e VITE_API_URL=https://staging-api.xiaoxiajianji.com \
docker.m.daocloud.io/library/node:20 \
sh -lc '
PACKAGE_LOCK_HASH=$(md5sum package-lock.json 2>/dev/null | cut -d" " -f1)
CACHE_HASH_FILE="node_modules/.package-lock-hash"
CACHE_VALID=false
if [ -f "$CACHE_HASH_FILE" ] && [ "$(cat "$CACHE_HASH_FILE")" = "$PACKAGE_LOCK_HASH" ] && [ -x "node_modules/.bin/vite" ] && [ -x "node_modules/.bin/tsc" ]; then
CACHE_VALID=true
echo "Cache hit: dependencies valid, skipping npm ci"
fi
if [ "$CACHE_VALID" = "false" ]; then
echo "Cache miss or invalid: running npm ci..."
if ! npm ci; then
echo "npm ci failed, cleaning node_modules and retrying..."
rm -rf node_modules
mkdir -p node_modules
npm ci
fi
echo "$PACKAGE_LOCK_HASH" > "$CACHE_HASH_FILE"
echo "Dependencies installed, cache updated"
fi
echo "Running TypeScript check..."
npx --no-install tsc
echo "Running Vite build..."
npx --no-install vite build
echo "Build completed successfully"
ls -la dist/
'
- name: Install SSH client and rsync
shell: sh
+248 -20
View File
@@ -26,13 +26,6 @@ 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__)
@@ -170,18 +163,83 @@ class PlanGeneratorService:
plan_id: str,
clip_configs: List[TemplateClipConfig],
) -> List[EditPlanClip]:
"""从 TemplateClipConfig 列表创建 EditPlanClip 列表(未持久化).
"""从 TemplateClipConfig 列表创建 EditPlanClip 列表(未持久化)"""
clips: List[EditPlanClip] = []
# 按 order 排序
sorted_configs = sorted(clip_configs, key=lambda c: c.order)
委托给 plan_generator_utils.create_clips_from_configs 纯函数。
"""
return create_clips_from_configs(plan_id, clip_configs)
for cfg in sorted_configs:
# 计算时长:取 min_duration 和 max_duration 的中间值
if cfg.min_duration > 0 and cfg.max_duration > 0:
duration = (cfg.min_duration + cfg.max_duration) / 2
elif cfg.min_duration > 0:
duration = cfg.min_duration
elif cfg.max_duration > 0:
duration = cfg.max_duration
else:
duration = _DEFAULT_CLIP_DURATION
# clip_type 可能是枚举或字符串
clip_type = cfg.clip_type.value if hasattr(cfg.clip_type, "value") else cfg.clip_type
# transition_effect 可能是枚举或字符串
transition = (
cfg.transition_effect.value if hasattr(cfg.transition_effect, "value") else cfg.transition_effect
)
# 从 clip config 中解析 playback_speed(兼容 speed_ratio 字段名)
clip_cfg = cfg.config or {}
playback_speed = clip_cfg.get("playback_speed", clip_cfg.get("speed_ratio", 1.0)) or 1.0
clip = EditPlanClip.create(
plan_id=plan_id,
clip_type=clip_type,
order=cfg.order,
template_clip_config_id=cfg.id,
text_content=getattr(cfg, "text_template", "") or "",
duration=duration,
transition_effect=transition or "cut",
playback_speed=playback_speed,
config=clip_cfg,
)
clips.append(clip)
return clips
def _map_clip_types_for_mode(self, clips: List[EditPlanClip], editing_mode: str) -> None:
"""将 MAIN 类型片段按 editing_mode 映射为对应角色类型.
"""模板 clip_config 生成的 MAIN 类型片段按 editing_mode 映射为对应角色类型
委托给 plan_generator_utils.map_clip_types_for_mode 纯函数。
模板的 clip_config 使用 ClipType 枚举(main/intro/outro 等),
但 PIP / VOICE_PIP 模式的素材分配和渲染分层依赖特定的 clip_type 命名
overlay / background / corner_voice / b_roll)。
映射规则(仅修改 MAIN 类型片段,非 MAIN 片段保持原类型):
- PIP: 第1个 MAIN → main(背景),其余 MAIN → overlay(画中画)
- VOICE_PIP: 第1个 → background,第2个 → corner_voice,第3+个 → b_roll
- ONE_TAKE / VOICE_OVER: 保持 main 不变
"""
map_clip_types_for_mode(clips, editing_mode)
from packages.domain.template_clip_config import ClipType
main_clips = [c for c in clips if c.clip_type == ClipType.MAIN.value]
if not main_clips:
return
if editing_mode == EditingMode.PIP.value:
# 第1个 main 保持(背景层),其余改为 overlay(画中画层)
for i, clip in enumerate(main_clips):
if i > 0:
clip.clip_type = "overlay"
elif editing_mode == EditingMode.VOICE_PIP.value:
for i, clip in enumerate(main_clips):
if i == 0:
clip.clip_type = "background"
elif i == 1:
clip.clip_type = "corner_voice"
else:
clip.clip_type = "b_roll"
# ONE_TAKE / VOICE_OVER: 保持 main 不变,无需处理
def _generate_default_clips(
self,
@@ -189,11 +247,101 @@ class PlanGeneratorService:
editing_mode: str,
asset_count: int,
) -> List[EditPlanClip]:
"""无 clip_configs 时,根据 editing_mode 生成默认 clip 结构.
"""无 clip_configs 时,根据 editing_mode 生成默认 clip 结构
委托给 plan_generator_utils.generate_default_clips 纯函数。
- ONE_TAKE: N 个 main clipsN = asset_count,至少1个)
- PIP: 1 个 main + (N-1) 个 overlayN = asset_count
- VOICE_OVER: N 个 main clips + 标记需要配音
- VOICE_PIP: 1 个 background + 1 个 corner_voice + (N-2) 个 b_roll
"""
return generate_default_clips(plan_id, editing_mode, asset_count)
n = max(asset_count, 1)
clips: List[EditPlanClip] = []
order = 0
if editing_mode == EditingMode.PIP.value:
# 1 个 main(全屏背景)
clips.append(
EditPlanClip.create(
plan_id=plan_id,
clip_type=ClipType.MAIN.value,
order=order,
duration=_DEFAULT_CLIP_DURATION,
)
)
order += 1
# 剩余为 overlay
for _ in range(1, n):
clips.append(
EditPlanClip.create(
plan_id=plan_id,
clip_type="overlay",
order=order,
duration=_DEFAULT_CLIP_DURATION,
)
)
order += 1
elif editing_mode == EditingMode.VOICE_OVER.value:
# N 个 main clipsB-roll
for _ in range(n):
clips.append(
EditPlanClip.create(
plan_id=plan_id,
clip_type=ClipType.MAIN.value,
order=order,
duration=_DEFAULT_CLIP_DURATION,
config={"role": "b_roll"},
)
)
order += 1
elif editing_mode == EditingMode.VOICE_PIP.value:
# 1 个 background
clips.append(
EditPlanClip.create(
plan_id=plan_id,
clip_type="background",
order=order,
duration=_DEFAULT_CLIP_DURATION,
)
)
order += 1
# 1 个 corner_voice
clips.append(
EditPlanClip.create(
plan_id=plan_id,
clip_type="corner_voice",
order=order,
duration=_DEFAULT_CLIP_DURATION,
)
)
order += 1
# 剩余为 b_roll
for _ in range(2, n):
clips.append(
EditPlanClip.create(
plan_id=plan_id,
clip_type="b_roll",
order=order,
duration=_DEFAULT_CLIP_DURATION,
)
)
order += 1
else:
# ONE_TAKE: N 个 main clips
for _ in range(n):
clips.append(
EditPlanClip.create(
plan_id=plan_id,
clip_type=ClipType.MAIN.value,
order=order,
duration=_DEFAULT_CLIP_DURATION,
)
)
order += 1
return clips
def _distribute_assets(
self,
@@ -201,9 +349,89 @@ class PlanGeneratorService:
asset_ids: List[str],
editing_mode: str,
) -> None:
"""按 editing_mode 将素材分配到 clips(就地修改,未持久化).
"""按 editing_mode 将素材分配到 clips(就地修改,未持久化)
委托给 plan_generator_utils.distribute_assets 纯函数。
分配策略:
- ONE_TAKE: 素材按顺序依次分配给 main 类型 clips
- PIP: 第1个素材→main(全屏背景),其余→交替分配给 overlay clips
- VOICE_OVER: 素材→main clips (B-roll)
- VOICE_PIP: 第1个→background, 第2个→corner_voice, 其余→b_roll
"""
distribute_assets(clips, asset_ids, editing_mode)
if not asset_ids or not clips:
return
if editing_mode == EditingMode.ONE_TAKE.value:
self._distribute_one_take(clips, asset_ids)
elif editing_mode == EditingMode.PIP.value:
self._distribute_pip(clips, asset_ids)
elif editing_mode == EditingMode.VOICE_OVER.value:
self._distribute_voice_over(clips, asset_ids)
elif editing_mode == EditingMode.VOICE_PIP.value:
self._distribute_voice_pip(clips, asset_ids)
else:
# 未知模式,退化为 one_take
self._distribute_one_take(clips, asset_ids)
def _distribute_one_take(
self,
clips: List[EditPlanClip],
asset_ids: List[str],
) -> None:
"""ONE_TAKE: 素材按顺序依次分配给 main 类型 clips"""
main_clips = [c for c in clips if c.clip_type == ClipType.MAIN.value]
for i, clip in enumerate(main_clips):
if i < len(asset_ids):
clip.assign_asset(asset_ids[i])
def _distribute_pip(
self,
clips: List[EditPlanClip],
asset_ids: List[str],
) -> None:
"""PIP: 第1个素材→main(全屏背景),其余→overlay clips"""
# 第1个素材 → main clip
main_clips = [c for c in clips if c.clip_type == ClipType.MAIN.value]
if main_clips and asset_ids:
main_clips[0].assign_asset(asset_ids[0])
# 其余素材 → overlay clips
overlay_clips = [c for c in clips if c.clip_type == "overlay"]
remaining = asset_ids[1:]
for i, clip in enumerate(overlay_clips):
if i < len(remaining):
clip.assign_asset(remaining[i])
def _distribute_voice_over(
self,
clips: List[EditPlanClip],
asset_ids: List[str],
) -> None:
"""VOICE_OVER: 素材→main clips (B-roll)"""
main_clips = [c for c in clips if c.clip_type == ClipType.MAIN.value]
for i, clip in enumerate(main_clips):
if i < len(asset_ids):
clip.assign_asset(asset_ids[i])
def _distribute_voice_pip(
self,
clips: List[EditPlanClip],
asset_ids: List[str],
) -> None:
"""VOICE_PIP: 第1个→background, 第2个→corner_voice, 其余→b_roll"""
bg_clips = [c for c in clips if c.clip_type == "background"]
corner_clips = [c for c in clips if c.clip_type == "corner_voice"]
broll_clips = [c for c in clips if c.clip_type == "b_roll"]
# 第1个素材 → background
if bg_clips and len(asset_ids) > 0:
bg_clips[0].assign_asset(asset_ids[0])
# 第2个素材 → corner_voice
if corner_clips and len(asset_ids) > 1:
corner_clips[0].assign_asset(asset_ids[1])
# 其余素材 → b_roll
remaining = asset_ids[2:]
for i, clip in enumerate(broll_clips):
if i < len(remaining):
clip.assign_asset(remaining[i])
+210 -53
View File
@@ -13,31 +13,57 @@
- 最低质量分门槛:自动过滤低质量素材
- 时长多样性:保证选出的素材时长分布均匀(短/中/长各占一定比例)
- 兼容全部模式:素材库模式和项目模式都可用
纯逻辑部分已抽离到 packages.domain.asset_scoring。
"""
from __future__ import annotations
import logging
from packages.domain.asset_scoring import (
MEDIUM_BUCKET_MAX as _MEDIUM_BUCKET_MAX,
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,
)
from dataclasses import dataclass
logger = logging.getLogger(__name__)
# ── 评分权重 ──────────────────────────────────────────────────────────────────
_WEIGHT_QUALITY = 0.5
_WEIGHT_RESOLUTION = 0.2
_WEIGHT_DURATION = 0.2
_WEIGHT_BITRATE = 0.1
# ── 评分参数 ──────────────────────────────────────────────────────────────────
_TARGET_WIDTH = 1920 # 目标分辨率宽度基准
_TARGET_HEIGHT = 1080 # 目标分辨率高度基准
_MIN_QUALITY_SCORE = 30.0 # 最低质量分门槛(低于此值的素材直接排除)
_OPTIMAL_DURATION_MIN = 3.0 # 最佳时长区间(秒)
_OPTIMAL_DURATION_MAX = 30.0
# ── 多样性分桶 ───────────────────────────────────────────────────────────────
_SHORT_BUCKET_MAX = 5.0 # 短素材:< 5s
_MEDIUM_BUCKET_MAX = 15.0 # 中素材:5-15s
# 长素材:> 15s
@dataclass
class SmartSelectResult:
"""智能选择结果."""
selected_ids: list[str]
total_candidates: int
filtered_out: int # 被质量门槛过滤的数量
avg_score: float
details: list[AssetScoreDetail]
@dataclass
class AssetScoreDetail:
"""单个素材的评分详情."""
asset_id: str
total_score: float
quality_score: float
resolution_score: float
duration_score: float
bitrate_score: float
duration: float | None
class SmartAssetSelector:
"""智能素材选择器.
@@ -48,9 +74,9 @@ class SmartAssetSelector:
def __init__(
self,
min_quality_score: float = 30.0,
target_width: int = 1920,
target_height: int = 1080,
min_quality_score: float = _MIN_QUALITY_SCORE,
target_width: int = _TARGET_WIDTH,
target_height: int = _TARGET_HEIGHT,
):
self.min_quality_score = min_quality_score
self.target_width = target_width
@@ -76,7 +102,21 @@ class SmartAssetSelector:
SmartSelectResult 选择结果
"""
# 1. 过滤:只保留 ready 状态的视频素材 + 最低质量分门槛
candidates, filtered_out = filter_candidates(assets, self.min_quality_score)
candidates = []
filtered_out = 0
for asset in assets:
status = getattr(asset, "status", None)
status_val = status.value if hasattr(status, "value") else str(status)
if status_val != "ready":
continue
mime_type = getattr(asset, "mime_type", "") or ""
if not mime_type.startswith("video"):
continue
quality = getattr(asset, "quality_score", None)
if quality is not None and quality < self.min_quality_score:
filtered_out += 1
continue
candidates.append(asset)
if not candidates:
return SmartSelectResult(
@@ -90,16 +130,7 @@ class SmartAssetSelector:
# 2. 对每个候选素材评分
scored: list[AssetScoreDetail] = []
for asset in candidates:
detail = score_asset_detail(
asset_id=asset.id,
quality=getattr(asset, "quality_score", None),
width=getattr(asset, "width", None),
height=getattr(asset, "height", None),
duration=getattr(asset, "duration", None),
file_size=getattr(asset, "file_size", 0) or 0,
target_width=self.target_width,
target_height=self.target_height,
)
detail = self._score_asset(asset)
scored.append(detail)
# 3. 按总分降序排列
@@ -107,7 +138,7 @@ class SmartAssetSelector:
# 4. 多样性选择(如果需要且数量有限制)
if ensure_diversity and count > 0 and len(scored) > count:
selected = diverse_selection(scored, count)
selected = self._diverse_selection(scored, count)
else:
# 无数量限制或不要求多样性,直接按排名取
selected = scored if count <= 0 else scored[:count]
@@ -131,39 +162,165 @@ class SmartAssetSelector:
)
return result
# ── 向后兼容:私有方法别名(委托给 asset_scoring 纯函数) ────────────────
# ── 内部方法 ──────────────────────────────────────────────────────────────
def _score_asset(self, asset) -> AssetScoreDetail:
"""对单个素材进行多维度评分(向后兼容)."""
return score_asset_detail(
"""对单个素材进行多维度评分."""
# 质量分
quality = getattr(asset, "quality_score", None)
quality_score = (quality / 100.0) if quality is not None else 0.5
# 分辨率评分:越接近目标分辨率得分越高
width = getattr(asset, "width", None)
height = getattr(asset, "height", None)
resolution_score = self._score_resolution(width, height)
# 时长评分:在最佳区间内得分高,过短过长扣分
duration = getattr(asset, "duration", None)
duration_score = self._score_duration(duration)
# 码率评分:用 file_size/duration 估算,适中得分高
file_size = getattr(asset, "file_size", 0) or 0
bitrate_score = self._score_bitrate(file_size, duration)
# 加权总分
total = (
_WEIGHT_QUALITY * quality_score
+ _WEIGHT_RESOLUTION * resolution_score
+ _WEIGHT_DURATION * duration_score
+ _WEIGHT_BITRATE * bitrate_score
)
return AssetScoreDetail(
asset_id=asset.id,
quality=getattr(asset, "quality_score", None),
width=getattr(asset, "width", None),
height=getattr(asset, "height", None),
duration=getattr(asset, "duration", None),
file_size=getattr(asset, "file_size", 0) or 0,
target_width=self.target_width,
target_height=self.target_height,
total_score=round(total, 4),
quality_score=round(quality_score, 4),
resolution_score=round(resolution_score, 4),
duration_score=round(duration_score, 4),
bitrate_score=round(bitrate_score, 4),
duration=duration,
)
def _score_resolution(self, width: int | None, height: int | None) -> float:
"""分辨率评分(向后兼容)."""
from packages.domain.asset_scoring import score_resolution
"""分辨率评分:越接近目标分辨率得分越高,低于480p扣分严重."""
if width is None or height is None or width <= 0 or height <= 0:
return 0.5 # 未知分辨率给中评分
return score_resolution(width, height, self.target_width, self.target_height)
target_pixels = self.target_width * self.target_height
actual_pixels = width * height
# 计算像素数比例
ratio = actual_pixels / target_pixels
if ratio >= 1.0:
# 高于或等于目标分辨率:满分,略高不扣分(4K也给满分)
return 1.0
else:
# 低于目标分辨率:线性衰减,但最低不低于 0.1
# 例如:720p (921600) / 1080p (2073600) = 0.44 → 得分 0.6
score = 0.3 + 0.7 * ratio
return max(0.1, min(1.0, score))
def _score_duration(self, duration: float | None) -> float:
"""时长评分(向后兼容)."""
from packages.domain.asset_scoring import score_duration
"""时长评分3-30秒最佳,过短或过长都扣分."""
if duration is None or duration <= 0:
return 0.5 # 未知时长给中评分
return score_duration(duration)
if _OPTIMAL_DURATION_MIN <= duration <= _OPTIMAL_DURATION_MAX:
# 最佳区间:满分
return 1.0
if duration < _OPTIMAL_DURATION_MIN:
# 太短:线性衰减,1秒以下给 0.3
ratio = duration / _OPTIMAL_DURATION_MIN
return 0.3 + 0.7 * ratio
# 太长:每超过最佳区间上限10秒扣 0.1 分,最低 0.2
excess = duration - _OPTIMAL_DURATION_MAX
penalty = min(0.8, excess / 10.0 * 0.1)
return max(0.2, 1.0 - penalty)
def _score_bitrate(self, file_size: int, duration: float | None) -> float:
"""码率评分(向后兼容)."""
from packages.domain.asset_scoring import score_bitrate
"""码率评分:根据文件大小和时长估算码率,适中得分高."""
if not file_size or not duration or duration <= 0:
return 0.5 # 未知给中评分
return score_bitrate(file_size, duration)
# 估算码率(bps
bitrate = (file_size * 8) / duration
# 最佳码率范围:2-8 Mbps
optimal_low = 2_000_000 # 2 Mbps
optimal_high = 8_000_000 # 8 Mbps
if optimal_low <= bitrate <= optimal_high:
return 1.0
if bitrate < optimal_low:
# 码率太低:线性衰减
ratio = bitrate / optimal_low
return 0.3 + 0.7 * ratio
# 码率太高(文件太大):适度扣分
excess = bitrate / optimal_high - 1.0
penalty = min(0.5, excess * 0.2)
return max(0.5, 1.0 - penalty)
def _diverse_selection(self, scored: list[AssetScoreDetail], count: int) -> list[AssetScoreDetail]:
"""多样性选择(向后兼容)."""
return diverse_selection(scored, count)
"""多样性选择:按时长分桶,保证每个桶都有素材.
策略:
1. 按时长分为三桶:短(<5s)、中(5-15s)、长(>15s)
2. 每个桶配额 = max(1, count / 3)
3. 先从每桶按配额取最高分的
4. 剩余名额从全局最高分中取(不重复)
"""
# 分桶
short_bucket = [d for d in scored if d.duration is not None and d.duration < _SHORT_BUCKET_MAX]
medium_bucket = [
d for d in scored if d.duration is not None and _SHORT_BUCKET_MAX <= d.duration < _MEDIUM_BUCKET_MAX
]
long_bucket = [d for d in scored if d.duration is not None and d.duration >= _MEDIUM_BUCKET_MAX]
unknown_bucket = [d for d in scored if d.duration is None]
buckets = [short_bucket, medium_bucket, long_bucket]
bucket_names = ["short", "medium", "long"]
# 每个桶基础配额(至少1个,如果桶非空且需要的话)
base_quota = max(1, count // 3)
selected: list[AssetScoreDetail] = []
selected_ids: set[str] = set()
# 先按配额从每个桶取
for bucket, _name in zip(buckets, bucket_names, strict=False):
quota = min(base_quota, len(bucket))
if quota <= 0:
continue
# 桶内已经按分数排好序了,直接取前 quota 个
for item in bucket[:quota]:
if item.asset_id not in selected_ids:
selected.append(item)
selected_ids.add(item.asset_id)
if len(selected) >= count:
return selected
# 剩余名额:从全局(未被选中的)中按分数高低取
remaining_needed = count - len(selected)
if remaining_needed > 0:
for item in scored:
if item.asset_id not in selected_ids:
selected.append(item)
selected_ids.add(item.asset_id)
if len(selected) >= count:
break
# 如果还不够(不应该发生),加上未知时长的
if len(selected) < count and unknown_bucket:
for item in unknown_bucket:
if item.asset_id not in selected_ids:
selected.append(item)
selected_ids.add(item.asset_id)
if len(selected) >= count:
break
return selected[:count]
@@ -2,13 +2,99 @@
* 右栏设置面板 — V8 原型 1:1 还原
* 字幕设置 + BGM设置 + 片段详情
*/
import React from "react"
import type { ClipPropertiesPanelProps } from "@/pages/editing-planner/types/clipProperties"
import SubtitleSettingsSection from "./clip-properties/SubtitleSettingsSection"
import BgmSettingsSection from "./clip-properties/BgmSettingsSection"
import ClipDetailSection from "./clip-properties/ClipDetailSection"
import StatsSection from "./clip-properties/StatsSection"
import { useVoicePreview } from "@/pages/editing-planner/hooks/useVoicePreview"
import React, { useRef, useState, useCallback } from "react"
import { useNavigate } from "react-router-dom"
import type { TemplateMode } from "@/api/editing-planner"
import type { ClipData, ClipType } from "../types"
import { TRANSITION_OPTIONS } from "@/api/template-editor"
import type { AssetItem } from "@/api/assets"
interface SubtitleSettings {
enabled: boolean
position: string
font: string
fontSize: number
fontColor: string
animation: string
mode?: string
stroke?: boolean
shadow?: boolean
asrLanguage?: string
}
interface BgmSettings {
enabled: boolean
music_id: string
volume?: number
fade_in?: number
fade_out?: number
voice_dodge?: boolean
}
interface ClipPropertiesPanelProps {
selectedClip: ClipData | null
subtitleSettings: SubtitleSettings
bgmSettings: BgmSettings
clipsCount: number
totalDuration: number
currentMode: TemplateMode
onSubtitleSettingsChange: (partial: Partial<SubtitleSettings>) => void
onBgmSettingsChange: (partial: Partial<BgmSettings>) => void
onClipUpdate: (clipId: string, data: Partial<ClipData>) => void
/** 打开 BGM 选择器 Drawer */
onOpenBgmDrawer?: () => void
/** 打开字幕样式配置 Drawer */
onOpenSubtitleDrawer?: () => void
/** 配音素材列表(从配音库 API 获取) */
voiceMaterials?: AssetItem[]
/** 配音素材加载中 */
voiceMaterialsLoading?: boolean
/** 刷新配音素材列表 */
onRefreshVoiceMaterials?: () => void
/** 为片段选择配音素材 */
onClipVoiceSelect?: (clipId: string, asset: AssetItem | null) => void
/** 打开转场特效选择器 Drawer */
onOpenTransitionDrawer?: (clipId: string) => void
/** 打开片段调速面板 Drawer */
onOpenSpeedDrawer?: (clipId: string) => void
/** 打开 TTS 配音面板 Drawer */
onOpenTtsDrawer?: (clipId: string) => void
/** 打开水印设置面板 Drawer */
onOpenWatermarkDrawer?: () => void
/** 打开片头片尾设置面板 Drawer */
onOpenIntroOutroDrawer?: () => void
/** 打开混剪设置面板 Drawer */
onOpenPipDrawer?: () => void
/** 打开滤镜调色面板 Drawer */
onOpenFilterDrawer?: () => void
/** 打开绿幕抠像面板 Drawer */
onOpenGreenScreenDrawer?: () => void
/** 打开贴纸面板 Drawer */
onOpenStickerDrawer?: () => void
/** 打开封面选择器 Drawer */
}
const POSITION_OPTIONS = [
{ value: "top", label: "顶部" },
{ value: "center", label: "居中" },
{ value: "bottom", label: "底部" },
]
const FONT_OPTIONS = ["思源黑体", "思源宋体", "苹方", "PingFang", "微软雅黑", "楷体", "华康俪金黑"]
const ANIMATION_OPTIONS = [
{ value: "none", label: "无" },
{ value: "fade", label: "淡入淡出" },
{ value: "slide", label: "滑动" },
{ value: "typewriter", label: "打字机" },
]
/** 片段类型图标/标签 */
const CLIP_TYPE_ICONS: Record<ClipType, string> = { voice: "🎙️", pip: "🖼️" }
const CLIP_TYPE_LABELS: Record<ClipType, string> = {
voice: "口播",
pip: "混剪",
}
const ClipPropertiesPanel: React.FC<ClipPropertiesPanelProps> = ({
selectedClip,
@@ -36,19 +122,165 @@ const ClipPropertiesPanel: React.FC<ClipPropertiesPanelProps> = ({
onOpenGreenScreenDrawer,
onOpenStickerDrawer,
}) => {
const { previewingId, handlePreviewVoice, stopPreview } = useVoicePreview()
const navigate = useNavigate()
/* ── 配音试听 ── */
const audioRef = useRef<HTMLAudioElement | null>(null)
const [previewingId, setPreviewingId] = useState<string | null>(null)
/** 试听配音素材 */
const handlePreviewVoice = useCallback(
(asset: AssetItem) => {
// 点击同一个 → 暂停
if (previewingId === asset.id) {
audioRef.current?.pause()
setPreviewingId(null)
return
}
// 停止上一个
audioRef.current?.pause()
const url = asset.file_url || (asset.metadata?.preview_url as string)
if (!url) return
const audio = new Audio(url)
audioRef.current = audio
audio.play().catch(() => {})
audio.onended = () => setPreviewingId(null)
setPreviewingId(asset.id)
},
[previewingId],
)
/** 从 metadata 取性别标签 */
const getGenderLabel = (m: AssetItem): string => {
const g = (m.metadata?.gender as string) || ""
if (g === "male") return "男"
if (g === "female") return "女"
return ""
}
return (
<div className="ep-right-panel">
{/* ═══ 字幕设置 ═══ */}
<SubtitleSettingsSection
settings={subtitleSettings}
onChange={onSubtitleSettingsChange}
onOpenSubtitleDrawer={onOpenSubtitleDrawer}
/>
<div className="ep-settings-section">
<div className="ep-section-title">
<span className="ep-section-icon">💬</span>
</div>
<div className="ep-toggle-row">
<span className="ep-toggle-label"></span>
<div
className={`ep-toggle ${subtitleSettings.enabled ? "active" : ""}`}
onClick={() =>
onSubtitleSettingsChange({
enabled: !subtitleSettings.enabled,
})
}
>
<div className="ep-toggle-knob" />
</div>
</div>
{subtitleSettings.enabled && (
<>
<div className="ep-field">
<label className="ep-field-label"></label>
<select
className="ep-form-select"
value={subtitleSettings.position}
onChange={(e) => onSubtitleSettingsChange({ position: e.target.value })}
>
{POSITION_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
<div className="ep-field">
<label className="ep-field-label"></label>
<select
className="ep-form-select"
value={subtitleSettings.font}
onChange={(e) => onSubtitleSettingsChange({ font: e.target.value })}
>
{FONT_OPTIONS.map((f) => (
<option key={f} value={f}>
{f}
</option>
))}
</select>
</div>
<div className="ep-field">
<label className="ep-field-label"></label>
<div className="ep-slider-row">
<input
className="ep-slider"
type="range"
min={12}
max={48}
value={subtitleSettings.fontSize}
onChange={(e) =>
onSubtitleSettingsChange({
fontSize: Number(e.target.value),
})
}
/>
<span className="ep-slider-value">{subtitleSettings.fontSize}px</span>
</div>
</div>
<div className="ep-field">
<label className="ep-field-label"></label>
<select
className="ep-form-select"
value={subtitleSettings.animation}
onChange={(e) => onSubtitleSettingsChange({ animation: e.target.value })}
>
{ANIMATION_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
{/* 高级配置按钮 */}
{onOpenSubtitleDrawer && (
<button className="ep-advanced-btn" onClick={onOpenSubtitleDrawer}>
🎨
</button>
)}
</>
)}
</div>
{/* ═══ BGM 设置 ═══ */}
<BgmSettingsSection settings={bgmSettings} onOpenBgmDrawer={onOpenBgmDrawer} />
<div className="ep-settings-section">
<div className="ep-section-title">
<span className="ep-section-icon">🎵</span>
BGM
</div>
{bgmSettings.enabled && bgmSettings.music_id ? (
<div className="ep-bgm-current">
<span className="ep-bgm-current-label">🎵 BGM</span>
<span className="ep-bgm-current-id">{bgmSettings.music_id}</span>
{bgmSettings.volume !== undefined && (
<span className="ep-bgm-current-vol"> {bgmSettings.volume}%</span>
)}
</div>
) : (
<div className="ep-bgm-empty"></div>
)}
{onOpenBgmDrawer && (
<button className="ep-advanced-btn" onClick={onOpenBgmDrawer}>
🎵 {bgmSettings.enabled ? "更换 BGM / 调整混音" : "选择 BGM 音乐"}
</button>
)}
</div>
{/* ═══ 水印设置 ═══ */}
<div className="ep-settings-section">
@@ -142,30 +374,258 @@ const ClipPropertiesPanel: React.FC<ClipPropertiesPanelProps> = ({
{/* ═══ 片段详情(选中时显示) ═══ */}
{selectedClip && (
<ClipDetailSection
clip={selectedClip}
currentMode={currentMode}
voiceMaterials={voiceMaterials}
voiceMaterialsLoading={voiceMaterialsLoading}
onClipUpdate={onClipUpdate}
onRefreshVoiceMaterials={onRefreshVoiceMaterials}
onClipVoiceSelect={onClipVoiceSelect}
onOpenTransitionDrawer={onOpenTransitionDrawer}
onOpenSpeedDrawer={onOpenSpeedDrawer}
onOpenTtsDrawer={onOpenTtsDrawer}
previewingId={previewingId}
onPreviewVoice={handlePreviewVoice}
onStopPreview={stopPreview}
/>
<div className="ep-settings-section">
<div className="ep-section-title">
<span className="ep-section-icon">🎞</span>
</div>
<div className="ep-clip-detail">
{/* 类型选择器 */}
<div className="ep-clip-detail-field">
<div className="ep-clip-detail-label"></div>
<div className="ep-clip-type-selector">
{(["voice", "pip"] as ClipType[]).map((t) => {
const disabled =
currentMode === "pip"
? t !== "pip"
: currentMode === "voice_over"
? t !== "voice"
: false // voice_pip 可切换
return (
<button
key={t}
className={`ep-clip-type-btn${selectedClip.type === t ? " active" : ""}${disabled ? " disabled" : ""}`}
disabled={disabled}
onClick={() => !disabled && onClipUpdate(selectedClip.id, { type: t })}
>
{CLIP_TYPE_ICONS[t]} {CLIP_TYPE_LABELS[t]}
</button>
)
})}
</div>
</div>
{/* 时长 */}
<div className="ep-clip-detail-field">
<div className="ep-clip-detail-label"></div>
<div className="ep-clip-detail-row">
<input
className="ep-duration-input"
type="number"
min={1}
max={120}
value={selectedClip.duration}
onChange={(e) =>
onClipUpdate(selectedClip.id, {
duration: Math.max(1, Math.min(120, Number(e.target.value) || 1)),
})
}
/>
<span className="ep-clip-detail-unit"></span>
</div>
</div>
{/* 转场效果入口 */}
{onOpenTransitionDrawer && (
<div className="ep-clip-detail-field">
<button
className="ep-advanced-btn ep-advanced-btn--transition"
onClick={() => onOpenTransitionDrawer(selectedClip.id)}
>
<span className="ep-advanced-btn-icon">🎬</span>
<span className="ep-advanced-btn-label"></span>
<span className="ep-advanced-btn-value">
{(() => {
const t = selectedClip.transition
if (!t || t.type === "none") return "无转场"
const opt = TRANSITION_OPTIONS.find((o) => o.value === t.type)
return `${opt?.label ?? t.type} · ${t.duration.toFixed(1)}s`
})()}
</span>
<span className="ep-advanced-btn-arrow"></span>
</button>
</div>
)}
{/* 播放速度入口 */}
{onOpenSpeedDrawer && (
<div className="ep-clip-detail-field">
<button
className="ep-advanced-btn ep-advanced-btn--speed"
onClick={() => onOpenSpeedDrawer(selectedClip.id)}
>
<span className="ep-advanced-btn-icon"></span>
<span className="ep-advanced-btn-label"></span>
<span className="ep-advanced-btn-value">
{selectedClip.speed ? `${selectedClip.speed.rate.toFixed(2)}x` : "1.00x"}
</span>
<span className="ep-advanced-btn-arrow"></span>
</button>
</div>
)}
{/* TTS 配音入口 */}
{onOpenTtsDrawer && (
<div className="ep-clip-detail-field">
<button
className="ep-advanced-btn ep-advanced-btn--tts"
onClick={() => onOpenTtsDrawer(selectedClip.id)}
>
<span className="ep-advanced-btn-icon">🎙</span>
<span className="ep-advanced-btn-label">TTS </span>
<span className="ep-advanced-btn-value">
{(() => {
const tts = selectedClip.tts_config
if (!tts || tts.mode === "none") return "无配音"
if (tts.mode === "upload") return "上传配音"
return `TTS · ${tts.voice_id ? "已选音色" : "未选音色"}`
})()}
</span>
<span className="ep-advanced-btn-arrow"></span>
</button>
</div>
)}
{/* 素材起始时间 — 仅 voice 类型显示 */}
{selectedClip.type === "voice" && (
<div className="ep-clip-detail-field">
<div className="ep-clip-detail-label"></div>
<div className="ep-clip-detail-row">
<input
className="ep-duration-input"
type="number"
min={0}
max={9999}
step={0.1}
value={selectedClip.startOffset}
onChange={(e) =>
onClipUpdate(selectedClip.id, {
startOffset: Math.max(0, Math.min(9999, Number(e.target.value) || 0)),
})
}
/>
<span className="ep-clip-detail-unit"></span>
</div>
</div>
)}
{/* 配音素材选择 — 仅 voice 类型显示 */}
{selectedClip.type === "voice" && (
<div className="ep-clip-detail-field">
<div className="ep-clip-detail-label">
{onRefreshVoiceMaterials && (
<button
type="button"
className="ep-voice-refresh-btn"
title="刷新配音列表"
onClick={() => onRefreshVoiceMaterials()}
disabled={voiceMaterialsLoading}
>
{voiceMaterialsLoading ? "⏳" : "🔄"}
</button>
)}
</div>
{voiceMaterialsLoading && voiceMaterials.length === 0 ? (
<div className="ep-voice-loading">...</div>
) : (
<>
<div className="ep-voice-select-row">
<select
className="ep-clip-detail-select"
value={selectedClip.voice_asset_id ?? ""}
onChange={(e) => {
const assetId = e.target.value
if (!onClipVoiceSelect) return
if (!assetId) {
onClipVoiceSelect(selectedClip.id, null)
} else {
const asset = voiceMaterials.find((m) => m.id === assetId)
if (asset) onClipVoiceSelect(selectedClip.id, asset)
}
// 切换选择时停止试听
audioRef.current?.pause()
setPreviewingId(null)
}}
>
<option value=""></option>
{voiceMaterials.map((m) => {
const gender = getGenderLabel(m)
const label = gender ? `${m.name}${gender}` : m.name
return (
<option key={m.id} value={m.id}>
{label}
</option>
)
})}
</select>
{/* 试听按钮 */}
{selectedClip.voice_asset_id && (
<button
type="button"
className="ep-voice-preview-btn"
title={previewingId === selectedClip.voice_asset_id ? "暂停" : "试听"}
onClick={() => {
const asset = voiceMaterials.find(
(m) => m.id === selectedClip.voice_asset_id,
)
if (asset) handlePreviewVoice(asset)
}}
>
{previewingId === selectedClip.voice_asset_id ? "⏸" : "▶️"}
</button>
)}
</div>
{voiceMaterials.length === 0 && (
<div className="ep-voice-empty"></div>
)}
</>
)}
<button
className="ep-voice-upload-btn"
onClick={() => navigate("/app/voice-materials")}
>
+
</button>
</div>
)}
</div>
</div>
)}
{/* ═══ 统计信息(未选中时显示) ═══ */}
{/* ═══ 统计信息(始终显示) ═══ */}
{!selectedClip && (
<StatsSection
clipsCount={clipsCount}
totalDuration={totalDuration}
currentMode={currentMode}
/>
<div className="ep-settings-section">
<div className="ep-section-title">
<span className="ep-section-icon">📊</span>
</div>
<div className="ep-clip-detail">
<div className="ep-clip-detail-field">
<div className="ep-clip-detail-label"></div>
<div className="ep-clip-detail-value">{clipsCount}</div>
</div>
<div className="ep-clip-detail-field">
<div className="ep-clip-detail-label"></div>
<div className="ep-clip-detail-value">{totalDuration.toFixed(1)}s</div>
</div>
<div className="ep-clip-detail-field">
<div className="ep-clip-detail-label"></div>
<div className="ep-clip-detail-value">
{currentMode === "pip"
? "混剪"
: currentMode === "voice_over"
? "人物口播"
: currentMode === "one_take"
? "一镜到底"
: "口播+混剪"}
</div>
</div>
</div>
</div>
)}
</div>
)
@@ -4,11 +4,8 @@
*/
import React, { useCallback } from "react"
import { Drawer, Switch } from "antd"
import type { FilterConfig, FilterPreset } from "@/pages/editing-planner/types"
import { DEFAULT_FILTER_CONFIG } from "@/pages/editing-planner/types"
import { PRESET_GRADIENTS } from "@/pages/editing-planner/constants/filter"
import FilterPresetGrid from "./filter/FilterPresetGrid"
import FilterManualAdjust from "./filter/FilterManualAdjust"
import type { FilterConfig, FilterPreset } from "../types"
import { DEFAULT_FILTER_CONFIG, FILTER_PRESET_LABELS } from "../types"
interface FilterPanelProps {
open: boolean
@@ -17,6 +14,34 @@ interface FilterPanelProps {
onChange: (config: FilterConfig) => void
}
/** 所有预设列表 */
const PRESET_LIST: FilterPreset[] = [
"none",
"original",
"fresh",
"warm",
"cool",
"vintage",
"cinema",
"bw",
"sunshine",
"film",
]
/** 预设对应的示例渐变色(用于视觉预览) */
const PRESET_GRADIENTS: Record<FilterPreset, string> = {
none: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
original: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
fresh: "linear-gradient(135deg, #a8edea 0%, #fed6e3 100%)",
warm: "linear-gradient(135deg, #f093fb 0%, #f5576c 100%)",
cool: "linear-gradient(135deg, #4facfe 0%, #00f2fe 100%)",
vintage: "linear-gradient(135deg, #c79081 0%, #dfa579 100%)",
cinema: "linear-gradient(135deg, #2c3e50 0%, #4ca1af 100%)",
bw: "linear-gradient(135deg, #434343 0%, #000000 100%)",
sunshine: "linear-gradient(135deg, #f6d365 0%, #fda085 100%)",
film: "linear-gradient(135deg, #8e9eab 0%, #eef2f3 100%)",
}
const FilterPanel: React.FC<FilterPanelProps> = ({ open, onClose, config, onChange }) => {
const update = useCallback(
(partial: Partial<FilterConfig>) => {
@@ -29,6 +54,7 @@ const FilterPanel: React.FC<FilterPanelProps> = ({ open, onClose, config, onChan
onChange({ ...DEFAULT_FILTER_CONFIG, enabled: config.enabled })
}, [config.enabled, onChange])
/** 选择预设时重置手动参数 */
const handlePresetSelect = useCallback(
(preset: FilterPreset) => {
if (preset === "none") {
@@ -44,13 +70,6 @@ const FilterPanel: React.FC<FilterPanelProps> = ({ open, onClose, config, onChan
[config.enabled, onChange],
)
const handleManualChange = useCallback(
(key: keyof FilterConfig, value: number) => {
onChange({ ...config, [key]: value })
},
[config, onChange],
)
return (
<Drawer
title="滤镜调色"
@@ -71,10 +90,110 @@ const FilterPanel: React.FC<FilterPanelProps> = ({ open, onClose, config, onChan
</div>
{/* 预设滤镜选择 */}
<FilterPresetGrid selectedPreset={config.preset} onPresetSelect={handlePresetSelect} />
<div className="filter-section">
<div className="filter-section-title"></div>
<div className="filter-presets">
{PRESET_LIST.map((p) => (
<button
key={p}
className={`filter-preset-item${config.preset === p ? " active" : ""}`}
onClick={() => handlePresetSelect(p)}
>
<div className="filter-preset-preview" style={{ background: PRESET_GRADIENTS[p] }} />
<span className="filter-preset-label">{FILTER_PRESET_LABELS[p]}</span>
</button>
))}
</div>
</div>
{/* 手动调节 */}
<FilterManualAdjust config={config} onChange={handleManualChange} />
<div className="filter-section">
<div className="filter-section-title"></div>
{/* 亮度 */}
<div className="filter-slider-row">
<span className="filter-slider-label"></span>
<input
type="range"
className="filter-slider"
min={-100}
max={100}
value={config.brightness}
onChange={(e) => update({ brightness: Number(e.target.value) })}
/>
<span className="filter-slider-value">{config.brightness}</span>
</div>
{/* 对比度 */}
<div className="filter-slider-row">
<span className="filter-slider-label"></span>
<input
type="range"
className="filter-slider"
min={-100}
max={100}
value={config.contrast}
onChange={(e) => update({ contrast: Number(e.target.value) })}
/>
<span className="filter-slider-value">{config.contrast}</span>
</div>
{/* 饱和度 */}
<div className="filter-slider-row">
<span className="filter-slider-label"></span>
<input
type="range"
className="filter-slider"
min={-100}
max={100}
value={config.saturation}
onChange={(e) => update({ saturation: Number(e.target.value) })}
/>
<span className="filter-slider-value">{config.saturation}</span>
</div>
{/* 色温 */}
<div className="filter-slider-row">
<span className="filter-slider-label"></span>
<input
type="range"
className="filter-slider"
min={-100}
max={100}
value={config.temperature}
onChange={(e) => update({ temperature: Number(e.target.value) })}
/>
<span className="filter-slider-value">{config.temperature}</span>
</div>
{/* 色调 */}
<div className="filter-slider-row">
<span className="filter-slider-label"></span>
<input
type="range"
className="filter-slider"
min={-100}
max={100}
value={config.tint}
onChange={(e) => update({ tint: Number(e.target.value) })}
/>
<span className="filter-slider-value">{config.tint}</span>
</div>
{/* 锐度 */}
<div className="filter-slider-row">
<span className="filter-slider-label"></span>
<input
type="range"
className="filter-slider"
min={0}
max={100}
value={config.sharpness}
onChange={(e) => update({ sharpness: Number(e.target.value) })}
/>
<span className="filter-slider-value">{config.sharpness}</span>
</div>
</div>
{/* 预览色块 */}
<div className="filter-section">
@@ -5,14 +5,19 @@
*/
import React, { useCallback } from "react"
import { Drawer } from "antd"
import type {
IntroOutroConfig,
IntroOutroItem,
IntroOutroKind,
} from "@/pages/editing-planner/types"
import { DEFAULT_INTRO_OUTRO } from "@/pages/editing-planner/types"
import IntroOutroBlock from "./intro-outro/IntroOutroBlock"
import type { IntroOutroConfig, IntroOutroItem, IntroOutroKind, TransitionType } from "../types"
import { DEFAULT_INTRO_OUTRO } from "../types"
import { TRANSITION_OPTIONS } from "@/api/template-editor"
/* ──────────── 常量 ──────────── */
const KIND_OPTIONS: { value: IntroOutroKind; label: string; icon: string }[] = [
{ value: "none", label: "无", icon: "🚫" },
{ value: "video", label: "视频", icon: "🎬" },
{ value: "image", label: "图片", icon: "🖼️" },
]
/* ──────────── Props ──────────── */
interface IntroOutroPanelProps {
open: boolean
onClose: () => void
@@ -68,24 +73,210 @@ const IntroOutroPanel: React.FC<IntroOutroPanelProps> = ({ open, onClose, config
className="intro-outro-panel-drawer"
>
{/* ═══ 片头区块 ═══ */}
<IntroOutroBlock
title="片头"
icon="🎞️"
item={config.intro}
transitionLabel="进入过渡动画"
onKindChange={handleIntroKindChange}
onChange={handleIntroChange}
/>
<div className="iop-block">
<div className="iop-block-header">
<span className="iop-block-icon">🎞</span>
<span className="iop-block-title"></span>
</div>
{/* 类型选择 */}
<div className="iop-kind-row">
{KIND_OPTIONS.map((opt) => (
<button
key={opt.value}
className={`iop-kind-btn${config.intro.kind === opt.value ? " active" : ""}`}
onClick={() => handleIntroKindChange(opt.value)}
>
<span className="iop-kind-icon">{opt.icon}</span>
<span className="iop-kind-label">{opt.label}</span>
</button>
))}
</div>
{/* 视频/图片配置 */}
{config.intro.kind !== "none" && (
<>
<div className="iop-field">
<label className="iop-field-label">
{config.intro.kind === "video" ? "视频" : "图片"} URL
</label>
<input
className="iop-input"
type="text"
placeholder={
config.intro.kind === "video"
? "https://example.com/intro.mp4"
: "https://example.com/intro.png"
}
value={config.intro.url ?? ""}
onChange={(e) => handleIntroChange({ url: e.target.value })}
/>
</div>
<div className="iop-field">
<label className="iop-field-label"></label>
<div className="iop-slider-row">
<input
className="iop-slider"
type="range"
min={1}
max={15}
step={0.5}
value={config.intro.duration}
onChange={(e) => handleIntroChange({ duration: Number(e.target.value) })}
/>
<span className="iop-slider-value">{config.intro.duration}s</span>
</div>
</div>
<div className="iop-field">
<label className="iop-field-label"></label>
<select
className="iop-select"
value={config.intro.transition ?? "none"}
onChange={(e) =>
handleIntroChange({
transition: e.target.value as TransitionType,
})
}
>
{TRANSITION_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
{config.intro.transition && config.intro.transition !== "none" && (
<div className="iop-field">
<label className="iop-field-label"></label>
<div className="iop-slider-row">
<input
className="iop-slider"
type="range"
min={0.3}
max={2.0}
step={0.1}
value={config.intro.transition_duration ?? 0.5}
onChange={(e) =>
handleIntroChange({
transition_duration: Number(e.target.value),
})
}
/>
<span className="iop-slider-value">
{(config.intro.transition_duration ?? 0.5).toFixed(1)}s
</span>
</div>
</div>
)}
</>
)}
</div>
{/* ═══ 片尾区块 ═══ */}
<IntroOutroBlock
title="片尾"
icon="🏁"
item={config.outro}
transitionLabel="退出过渡动画"
onKindChange={handleOutroKindChange}
onChange={handleOutroChange}
/>
<div className="iop-block">
<div className="iop-block-header">
<span className="iop-block-icon">🏁</span>
<span className="iop-block-title"></span>
</div>
{/* 类型选择 */}
<div className="iop-kind-row">
{KIND_OPTIONS.map((opt) => (
<button
key={opt.value}
className={`iop-kind-btn${config.outro.kind === opt.value ? " active" : ""}`}
onClick={() => handleOutroKindChange(opt.value)}
>
<span className="iop-kind-icon">{opt.icon}</span>
<span className="iop-kind-label">{opt.label}</span>
</button>
))}
</div>
{/* 视频/图片配置 */}
{config.outro.kind !== "none" && (
<>
<div className="iop-field">
<label className="iop-field-label">
{config.outro.kind === "video" ? "视频" : "图片"} URL
</label>
<input
className="iop-input"
type="text"
placeholder={
config.outro.kind === "video"
? "https://example.com/outro.mp4"
: "https://example.com/outro.png"
}
value={config.outro.url ?? ""}
onChange={(e) => handleOutroChange({ url: e.target.value })}
/>
</div>
<div className="iop-field">
<label className="iop-field-label"></label>
<div className="iop-slider-row">
<input
className="iop-slider"
type="range"
min={1}
max={15}
step={0.5}
value={config.outro.duration}
onChange={(e) => handleOutroChange({ duration: Number(e.target.value) })}
/>
<span className="iop-slider-value">{config.outro.duration}s</span>
</div>
</div>
<div className="iop-field">
<label className="iop-field-label">退</label>
<select
className="iop-select"
value={config.outro.transition ?? "none"}
onChange={(e) =>
handleOutroChange({
transition: e.target.value as TransitionType,
})
}
>
{TRANSITION_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
{config.outro.transition && config.outro.transition !== "none" && (
<div className="iop-field">
<label className="iop-field-label"></label>
<div className="iop-slider-row">
<input
className="iop-slider"
type="range"
min={0.3}
max={2.0}
step={0.1}
value={config.outro.transition_duration ?? 0.5}
onChange={(e) =>
handleOutroChange({
transition_duration: Number(e.target.value),
})
}
/>
<span className="iop-slider-value">
{(config.outro.transition_duration ?? 0.5).toFixed(1)}s
</span>
</div>
</div>
)}
</>
)}
</div>
{/* ── 底部操作 ── */}
<div className="iop-footer">
@@ -2,12 +2,65 @@
* 混剪配置面板 — Drawer 形式
* 左侧图层列表 + 右侧单图层配置 + 迷你预览区
*/
import React from "react"
import React, { useCallback, useMemo } from "react"
import { Drawer, Switch } from "antd"
import type { PipConfig } from "@/pages/editing-planner/types"
import LayerList from "./pip-config/LayerList"
import LayerConfig from "./pip-config/LayerConfig"
import { usePipLayers } from "@/pages/editing-planner/hooks/usePipLayers"
import type { PipConfig, PipLayer, PipGridPosition, PipAnimType, PipSlideDirection } from "../types"
import { DEFAULT_PIP_LAYER, DEFAULT_PIP_CONFIG } from "../types"
/* ──────────── 常量 ──────────── */
/** 九宫格位置 → 百分比坐标映射 */
const GRID_POSITION_MAP: Record<PipGridPosition, { x: number; y: number }> = {
top_left: { x: 5, y: 5 },
top_center: { x: 37.5, y: 5 },
top_right: { x: 70, y: 5 },
center_left: { x: 5, y: 37.5 },
center: { x: 37.5, y: 37.5 },
center_right: { x: 70, y: 37.5 },
bottom_left: { x: 5, y: 70 },
bottom_center: { x: 37.5, y: 70 },
bottom_right: { x: 70, y: 70 },
}
/** 九宫格位置选项 */
const GRID_POSITIONS: PipGridPosition[] = [
"top_left",
"top_center",
"top_right",
"center_left",
"center",
"center_right",
"bottom_left",
"bottom_center",
"bottom_right",
]
/** 入场动画选项 */
const ANIM_OPTIONS: { value: PipAnimType; label: string }[] = [
{ value: "none", label: "无" },
{ value: "fade_in", label: "淡入" },
{ value: "slide_in", label: "滑入" },
]
/** 滑入方向选项 */
const SLIDE_DIR_OPTIONS: { value: PipSlideDirection; label: string }[] = [
{ value: "left", label: "← 左" },
{ value: "right", label: "→ 右" },
{ value: "up", label: "↑ 上" },
{ value: "down", label: "↓ 下" },
]
/** 预览图层颜色池 */
const LAYER_COLORS = [
"rgba(22,119,255,0.5)",
"rgba(82,196,26,0.5)",
"rgba(250,173,20,0.5)",
"rgba(255,77,79,0.5)",
"rgba(114,46,209,0.5)",
"rgba(19,194,194,0.5)",
]
/* ──────────── Props ──────────── */
interface PipConfigPanelProps {
open: boolean
@@ -17,6 +70,13 @@ interface PipConfigPanelProps {
totalDuration: number
}
/* ──────────── 辅助函数 ──────────── */
let layerIdCounter = 0
const genLayerId = () => `pip_layer_${Date.now()}_${++layerIdCounter}`
/* ──────────── 组件 ──────────── */
const PipConfigPanel: React.FC<PipConfigPanelProps> = ({
open,
onClose,
@@ -24,19 +84,106 @@ const PipConfigPanel: React.FC<PipConfigPanelProps> = ({
onChange,
totalDuration,
}) => {
const {
selectedId,
setSelectedId,
selectedLayer,
handleAddLayer,
handleDeleteLayer,
updateLayer,
handleEnableToggle,
handleReset,
handleGridClick,
handleWidthChange,
handleHeightChange,
} = usePipLayers({ config, onChange })
/** 当前选中图层 ID */
const [selectedId, setSelectedId] = React.useState<string>("")
/** 当前选中图层 */
const selectedLayer = useMemo(
() => config.layers.find((l) => l.id === selectedId) ?? null,
[config.layers, selectedId],
)
/* ── 添加图层 ── */
const handleAddLayer = useCallback(() => {
const newLayer: PipLayer = {
...DEFAULT_PIP_LAYER,
id: genLayerId(),
name: `图层 ${config.layers.length + 1}`,
z_index: config.layers.length + 1,
}
onChange({
...config,
layers: [...config.layers, newLayer],
})
setSelectedId(newLayer.id)
}, [config, onChange])
/* ── 删除图层 ── */
const handleDeleteLayer = useCallback(
(id: string) => {
const newLayers = config.layers.filter((l) => l.id !== id)
onChange({ ...config, layers: newLayers })
if (selectedId === id) {
setSelectedId(newLayers.length > 0 ? newLayers[0].id : "")
}
},
[config, onChange, selectedId],
)
/* ── 更新图层 ── */
const updateLayer = useCallback(
(id: string, partial: Partial<PipLayer>) => {
onChange({
...config,
layers: config.layers.map((l) => (l.id === id ? { ...l, ...partial } : l)),
})
},
[config, onChange],
)
/* ── 切换启用 ── */
const handleEnableToggle = useCallback(
(checked: boolean) => {
onChange({ ...config, enabled: checked })
},
[config, onChange],
)
/* ── 重置 ── */
const handleReset = useCallback(() => {
onChange({ ...DEFAULT_PIP_CONFIG })
setSelectedId("")
}, [onChange])
/* ── 九宫格点击 ── */
const handleGridClick = useCallback(
(pos: PipGridPosition) => {
if (!selectedLayer) return
const coords = GRID_POSITION_MAP[pos]
updateLayer(selectedLayer.id, {
grid_position: pos,
x: coords.x,
y: coords.y,
})
},
[selectedLayer, updateLayer],
)
/* ── 宽高比锁定 ── */
const handleWidthChange = useCallback(
(val: number) => {
if (!selectedLayer) return
const partial: Partial<PipLayer> = { width: val }
if (selectedLayer.aspect_lock) {
// 保持宽高比 1:1(百分比相同)
partial.height = val
}
updateLayer(selectedLayer.id, partial)
},
[selectedLayer, updateLayer],
)
const handleHeightChange = useCallback(
(val: number) => {
if (!selectedLayer) return
const partial: Partial<PipLayer> = { height: val }
if (selectedLayer.aspect_lock) {
partial.width = val
}
updateLayer(selectedLayer.id, partial)
},
[selectedLayer, updateLayer],
)
return (
<Drawer
@@ -50,7 +197,9 @@ const PipConfigPanel: React.FC<PipConfigPanelProps> = ({
{/* ═══ 顶部工具栏 ═══ */}
<div className="pip-toolbar">
<div className="pip-toolbar-left">
<span style={{ fontSize: 13, color: "#666" }}> {config.layers.length} </span>
<button className="pip-add-btn" onClick={handleAddLayer}>
+
</button>
</div>
<div className="pip-enable-switch">
<span></span>
@@ -60,22 +209,329 @@ const PipConfigPanel: React.FC<PipConfigPanelProps> = ({
{/* ═══ 主体:图层列表 + 配置区 ═══ */}
<div className="pip-body">
<LayerList
layers={config.layers}
selectedId={selectedId}
onSelect={setSelectedId}
onAdd={handleAddLayer}
onDelete={handleDeleteLayer}
/>
<LayerConfig
layer={selectedLayer}
layers={config.layers}
totalDuration={totalDuration}
onUpdate={updateLayer}
onGridClick={handleGridClick}
onWidthChange={handleWidthChange}
onHeightChange={handleHeightChange}
/>
{/* 左侧图层列表 */}
<div className="pip-layer-list">
{config.layers.length === 0 ? (
<div className="pip-layer-empty"></div>
) : (
config.layers.map((layer, idx) => (
<div
key={layer.id}
className={`pip-layer-item${selectedId === layer.id ? " active" : ""}`}
onClick={() => setSelectedId(layer.id)}
>
{layer.thumbnail_url || layer.material_url ? (
<img
className="pip-layer-thumb"
src={layer.thumbnail_url || layer.material_url}
alt={layer.name}
/>
) : (
<div
className="pip-layer-thumb"
style={{
background: LAYER_COLORS[idx % LAYER_COLORS.length],
}}
/>
)}
<span className="pip-layer-name">{layer.name}</span>
<button
className="pip-layer-delete"
onClick={(e) => {
e.stopPropagation()
handleDeleteLayer(layer.id)
}}
title="删除图层"
>
</button>
</div>
))
)}
</div>
{/* 右侧配置区 */}
<div className="pip-config-area">
{!selectedLayer ? (
<div className="pip-config-empty"></div>
) : (
<>
{/* ── 迷你预览 ── */}
<div className="pip-preview-box">
{config.layers.map((layer, idx) => (
<div
key={layer.id}
className={`pip-preview-layer${selectedId === layer.id ? " selected" : ""}`}
style={{
left: `${layer.x}%`,
top: `${layer.y}%`,
width: `${layer.width}%`,
height: `${layer.height}%`,
background: LAYER_COLORS[idx % LAYER_COLORS.length],
opacity: layer.opacity / 100,
borderRadius: `${layer.border_radius}%`,
}}
>
<span className="pip-preview-label">{layer.name}</span>
</div>
))}
</div>
{/* ── 素材类型 ── */}
<div className="pip-field">
<label className="pip-field-label"></label>
<div className="pip-type-btns">
<button
className={`pip-type-btn${selectedLayer.material_type === "image" ? " active" : ""}`}
onClick={() => updateLayer(selectedLayer.id, { material_type: "image" })}
>
🖼
</button>
<button
className={`pip-type-btn${selectedLayer.material_type === "video" ? " active" : ""}`}
onClick={() => updateLayer(selectedLayer.id, { material_type: "video" })}
>
🎬
</button>
</div>
</div>
{/* ── 素材 URL ── */}
<div className="pip-field">
<label className="pip-field-label">
{selectedLayer.material_type === "image" ? "图片" : "视频"} URL
</label>
<input
className="pip-input"
type="text"
placeholder={
selectedLayer.material_type === "image"
? "https://example.com/image.png"
: "https://example.com/video.mp4"
}
value={selectedLayer.material_url}
onChange={(e) =>
updateLayer(selectedLayer.id, {
material_url: e.target.value,
})
}
/>
</div>
{/* ── 位置:九宫格 + 坐标 ── */}
<div className="pip-field">
<label className="pip-field-label"></label>
<div style={{ display: "flex", gap: 16, alignItems: "flex-start" }}>
<div className="pip-grid">
{GRID_POSITIONS.map((pos) => (
<button
key={pos}
className={`pip-grid-btn${selectedLayer.grid_position === pos ? " active" : ""}`}
onClick={() => handleGridClick(pos)}
>
<span className="pip-grid-dot" />
</button>
))}
</div>
<div className="pip-field-row" style={{ flex: 1 }}>
<div>
<label className="pip-field-label">X (%)</label>
<input
className="pip-number"
type="number"
min={0}
max={100}
value={selectedLayer.x}
onChange={(e) =>
updateLayer(selectedLayer.id, {
x: Number(e.target.value),
})
}
/>
</div>
<div>
<label className="pip-field-label">Y (%)</label>
<input
className="pip-number"
type="number"
min={0}
max={100}
value={selectedLayer.y}
onChange={(e) =>
updateLayer(selectedLayer.id, {
y: Number(e.target.value),
})
}
/>
</div>
</div>
</div>
</div>
{/* ── 尺寸 ── */}
<div className="pip-field">
<label className="pip-field-label"></label>
<div className="pip-slider-row">
<span style={{ fontSize: 12, color: "#999", width: 20 }}></span>
<input
className="pip-slider"
type="range"
min={10}
max={80}
value={selectedLayer.width}
onChange={(e) => handleWidthChange(Number(e.target.value))}
/>
<span className="pip-slider-value">{selectedLayer.width}%</span>
</div>
<div className="pip-slider-row" style={{ marginTop: 6 }}>
<span style={{ fontSize: 12, color: "#999", width: 20 }}></span>
<input
className="pip-slider"
type="range"
min={10}
max={80}
value={selectedLayer.height}
onChange={(e) => handleHeightChange(Number(e.target.value))}
/>
<span className="pip-slider-value">{selectedLayer.height}%</span>
</div>
<div
className="pip-lock-row"
style={{ marginTop: 6 }}
onClick={() =>
updateLayer(selectedLayer.id, {
aspect_lock: !selectedLayer.aspect_lock,
})
}
>
<span className="pip-lock-icon">{selectedLayer.aspect_lock ? "🔒" : "🔓"}</span>
<span>{selectedLayer.aspect_lock ? "已锁定比例" : "锁定宽高比"}</span>
</div>
</div>
{/* ── 圆角 ── */}
<div className="pip-field">
<label className="pip-field-label"></label>
<div className="pip-slider-row">
<input
className="pip-slider"
type="range"
min={0}
max={50}
value={selectedLayer.border_radius}
onChange={(e) =>
updateLayer(selectedLayer.id, {
border_radius: Number(e.target.value),
})
}
/>
<span className="pip-slider-value">{selectedLayer.border_radius}%</span>
</div>
</div>
{/* ── 透明度 ── */}
<div className="pip-field">
<label className="pip-field-label"></label>
<div className="pip-slider-row">
<input
className="pip-slider"
type="range"
min={0}
max={100}
value={selectedLayer.opacity}
onChange={(e) =>
updateLayer(selectedLayer.id, {
opacity: Number(e.target.value),
})
}
/>
<span className="pip-slider-value">{selectedLayer.opacity}%</span>
</div>
</div>
{/* ── 时间 ── */}
<div className="pip-field">
<label className="pip-field-label"></label>
<div className="pip-field-row">
<div>
<label className="pip-field-label"> (s)</label>
<input
className="pip-number"
type="number"
min={0}
max={totalDuration || 999}
step={0.1}
value={selectedLayer.start_time}
onChange={(e) =>
updateLayer(selectedLayer.id, {
start_time: Number(e.target.value),
})
}
/>
</div>
<div>
<label className="pip-field-label"> (s)</label>
<input
className="pip-number"
type="number"
min={0.1}
max={totalDuration || 999}
step={0.1}
value={selectedLayer.duration}
onChange={(e) =>
updateLayer(selectedLayer.id, {
duration: Number(e.target.value),
})
}
/>
</div>
</div>
</div>
{/* ── 入场动画 ── */}
<div className="pip-field">
<label className="pip-field-label"></label>
<select
className="pip-select"
value={selectedLayer.animation}
onChange={(e) =>
updateLayer(selectedLayer.id, {
animation: e.target.value as PipAnimType,
})
}
>
{ANIM_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
{/* 滑入方向(仅 slide_in 时显示) */}
{selectedLayer.animation === "slide_in" && (
<div className="pip-field">
<label className="pip-field-label"></label>
<select
className="pip-select"
value={selectedLayer.slide_direction}
onChange={(e) =>
updateLayer(selectedLayer.id, {
slide_direction: e.target.value as PipSlideDirection,
})
}
>
{SLIDE_DIR_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
)}
</>
)}
</div>
</div>
{/* ── 底部操作 ── */}
@@ -2,13 +2,10 @@
* 贴纸配置面板
* 贴纸素材库(emoji / 图片)+ 文字花字 + 位置大小调整
*/
import React from "react"
import React, { useCallback, useState } from "react"
import { Drawer, Switch } from "antd"
import type { StickerConfig } from "@/pages/editing-planner/types"
import StickerLibrary from "./sticker/StickerLibrary"
import StickerList from "./sticker/StickerList"
import StickerPropsEditor from "./sticker/StickerPropsEditor"
import { useStickerItems } from "@/pages/editing-planner/hooks/useStickerItems"
import type { StickerConfig, StickerItem, StickerType, TextStickerPreset } from "../types"
import { DEFAULT_STICKER_CONFIG, DEFAULT_STICKER_ITEM, TEXT_STICKER_PRESET_LABELS } from "../types"
interface StickerPanelProps {
open: boolean
@@ -18,6 +15,59 @@ interface StickerPanelProps {
totalDuration: number
}
/** 常用 emoji 素材 */
const EMOJI_LIST = [
"😀",
"😂",
"🥰",
"😎",
"🤩",
"😱",
"🤔",
"😴",
"🥳",
"😍",
"❤️",
"🔥",
"⭐",
"✨",
"💯",
"👍",
"👏",
"🎉",
"🎵",
"💪",
"📌",
"💡",
"🎯",
"✅",
"❌",
"⬆️",
"⬇️",
"➡️",
"⭕",
"🔔",
]
/** 文字花字预设对应的 CSS 样式预览 */
const TEXT_PRESET_STYLES: Record<TextStickerPreset, React.CSSProperties> = {
normal: { color: "#fff", textShadow: "none" },
highlight: { color: "#FFD700", textShadow: "0 0 8px rgba(255,215,0,0.6)" },
bubble: { color: "#fff", background: "rgba(0,0,0,0.5)", borderRadius: 8 },
neon: { color: "#0ff", textShadow: "0 0 6px #0ff, 0 0 12px #0ff" },
shadow: { color: "#fff", textShadow: "2px 2px 4px rgba(0,0,0,0.8)" },
outline: { color: "#fff", WebkitTextStroke: "1px #000" },
gradient: {
color: "transparent",
background: "linear-gradient(90deg,#f093fb,#f5576c)",
WebkitBackgroundClip: "text",
},
handwrite: { color: "#333", fontStyle: "italic", fontFamily: "cursive" },
}
/** 生成唯一 ID */
const genId = () => `sticker_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
const StickerPanel: React.FC<StickerPanelProps> = ({
open,
onClose,
@@ -25,17 +75,63 @@ const StickerPanel: React.FC<StickerPanelProps> = ({
onChange,
totalDuration,
}) => {
const {
selectedId,
setSelectedId,
activeTab,
setActiveTab,
selectedSticker,
updateItem,
addSticker,
removeSticker,
handleReset,
} = useStickerItems({ config, onChange, totalDuration })
const [selectedId, setSelectedId] = useState<string | null>(null)
const [activeTab, setActiveTab] = useState<StickerType>("emoji")
const selectedSticker = config.items.find((s) => s.id === selectedId) ?? null
/** 更新单个贴纸 */
const updateItem = useCallback(
(id: string, partial: Partial<StickerItem>) => {
onChange({
...config,
items: config.items.map((s) => (s.id === id ? { ...s, ...partial } : s)),
})
},
[config, onChange],
)
/** 添加贴纸 */
const addSticker = useCallback(
(type: StickerType, content: string) => {
const newItem: StickerItem = {
...DEFAULT_STICKER_ITEM,
id: genId(),
type,
content,
duration: totalDuration > 0 ? totalDuration : 5,
z_index: config.items.length + 1,
}
onChange({
...config,
enabled: true,
items: [...config.items, newItem],
})
setSelectedId(newItem.id)
},
[config, onChange, totalDuration],
)
/** 删除贴纸 */
const removeSticker = useCallback(
(id: string) => {
onChange({
...config,
items: config.items.filter((s) => s.id !== id),
})
if (selectedId === id) setSelectedId(null)
},
[config, onChange, selectedId],
)
/** 重置所有 */
const handleReset = useCallback(() => {
onChange({ ...DEFAULT_STICKER_CONFIG, enabled: config.enabled })
setSelectedId(null)
}, [config.enabled, onChange])
/** 文字花字输入 */
const [textInput, setTextInput] = useState("")
return (
<Drawer
@@ -56,24 +152,355 @@ const StickerPanel: React.FC<StickerPanelProps> = ({
/>
</div>
{/* 素材库 + 类型Tab */}
<StickerLibrary activeTab={activeTab} onTabChange={setActiveTab} onAddSticker={addSticker} />
{/* 类型 Tab */}
<div className="sticker-tabs">
{(["emoji", "image", "text"] as StickerType[]).map((t) => (
<button
key={t}
className={`sticker-tab${activeTab === t ? " active" : ""}`}
onClick={() => setActiveTab(t)}
>
{t === "emoji" ? "表情贴纸" : t === "image" ? "图片贴纸" : "文字花字"}
</button>
))}
</div>
{/* Tab 内容区 */}
<div className="sticker-tab-content">
{/* Emoji 素材库 */}
{activeTab === "emoji" && (
<div className="sticker-emoji-grid">
{EMOJI_LIST.map((emoji) => (
<button
key={emoji}
className="sticker-emoji-btn"
onClick={() => addSticker("emoji", emoji)}
>
{emoji}
</button>
))}
</div>
)}
{/* 图片贴纸 */}
{activeTab === "image" && (
<div className="sticker-image-input">
<input
type="text"
className="sticker-url-input"
placeholder="输入图片 URL 添加贴纸..."
onKeyDown={(e) => {
if (e.key === "Enter" && e.currentTarget.value.trim()) {
addSticker("image", e.currentTarget.value.trim())
e.currentTarget.value = ""
}
}}
/>
<button
className="sticker-url-add-btn"
onClick={() => {
const input = document.querySelector<HTMLInputElement>(".sticker-url-input")
if (input?.value.trim()) {
addSticker("image", input.value.trim())
input.value = ""
}
}}
>
</button>
</div>
)}
{/* 文字花字 */}
{activeTab === "text" && (
<div className="sticker-text-section">
<div className="sticker-text-input-row">
<input
type="text"
className="sticker-text-input"
placeholder="输入文字内容..."
value={textInput}
onChange={(e) => setTextInput(e.target.value)}
/>
<button
className="sticker-text-add-btn"
disabled={!textInput.trim()}
onClick={() => {
if (textInput.trim()) {
addSticker("text", textInput.trim())
setTextInput("")
}
}}
>
</button>
</div>
<div className="sticker-text-presets">
<div className="sticker-preset-title"></div>
<div className="sticker-preset-grid">
{(Object.keys(TEXT_STICKER_PRESET_LABELS) as TextStickerPreset[]).map((p) => (
<div
key={p}
className="sticker-preset-preview"
style={{
...TEXT_PRESET_STYLES[p],
background:
p === "bubble"
? "rgba(0,0,0,0.5)"
: p === "gradient"
? "linear-gradient(90deg,#f093fb,#f5576c)"
: "#1a1a2e",
}}
>
<span style={TEXT_PRESET_STYLES[p]}></span>
<div className="sticker-preset-name">{TEXT_STICKER_PRESET_LABELS[p]}</div>
</div>
))}
</div>
</div>
</div>
)}
</div>
{/* 已添加贴纸列表 */}
<StickerList
items={config.items}
selectedId={selectedId}
onSelect={setSelectedId}
onDelete={removeSticker}
/>
{config.items.length > 0 && (
<div className="sticker-list-section">
<div className="sticker-section-title"> ({config.items.length})</div>
<div className="sticker-list">
{config.items.map((item) => (
<div
key={item.id}
className={`sticker-list-item${selectedId === item.id ? " active" : ""}`}
onClick={() => setSelectedId(item.id)}
>
<span className="sticker-list-icon">
{item.type === "emoji" ? item.content : item.type === "text" ? "T" : "🖼"}
</span>
<span className="sticker-list-name">
{item.type === "text"
? item.content.slice(0, 10)
: item.type === "emoji"
? "表情贴纸"
: "图片贴纸"}
</span>
<button
className="sticker-list-delete"
onClick={(e) => {
e.stopPropagation()
removeSticker(item.id)
}}
>
</button>
</div>
))}
</div>
</div>
)}
{/* 选中贴纸的属性编辑 */}
{selectedSticker && (
<StickerPropsEditor
sticker={selectedSticker}
totalDuration={totalDuration}
onUpdate={updateItem}
/>
<div className="sticker-props-section">
<div className="sticker-section-title"></div>
{/* 位置 */}
<div className="sticker-prop-row">
<span className="sticker-prop-label"> X</span>
<input
type="range"
className="sticker-prop-slider"
min={0}
max={100}
value={selectedSticker.x}
onChange={(e) => updateItem(selectedSticker.id, { x: Number(e.target.value) })}
/>
<span className="sticker-prop-value">{selectedSticker.x}%</span>
</div>
<div className="sticker-prop-row">
<span className="sticker-prop-label"> Y</span>
<input
type="range"
className="sticker-prop-slider"
min={0}
max={100}
value={selectedSticker.y}
onChange={(e) => updateItem(selectedSticker.id, { y: Number(e.target.value) })}
/>
<span className="sticker-prop-value">{selectedSticker.y}%</span>
</div>
{/* 尺寸 */}
<div className="sticker-prop-row">
<span className="sticker-prop-label"></span>
<input
type="range"
className="sticker-prop-slider"
min={5}
max={50}
value={selectedSticker.width}
onChange={(e) =>
updateItem(selectedSticker.id, {
width: Number(e.target.value),
height: Number(e.target.value),
})
}
/>
<span className="sticker-prop-value">{selectedSticker.width}%</span>
</div>
{/* 旋转 */}
<div className="sticker-prop-row">
<span className="sticker-prop-label"></span>
<input
type="range"
className="sticker-prop-slider"
min={-180}
max={180}
value={selectedSticker.rotation}
onChange={(e) =>
updateItem(selectedSticker.id, {
rotation: Number(e.target.value),
})
}
/>
<span className="sticker-prop-value">{selectedSticker.rotation}°</span>
</div>
{/* 透明度 */}
<div className="sticker-prop-row">
<span className="sticker-prop-label"></span>
<input
type="range"
className="sticker-prop-slider"
min={0}
max={100}
value={selectedSticker.opacity}
onChange={(e) =>
updateItem(selectedSticker.id, {
opacity: Number(e.target.value),
})
}
/>
<span className="sticker-prop-value">{selectedSticker.opacity}%</span>
</div>
{/* 时间 */}
<div className="sticker-prop-row">
<span className="sticker-prop-label"></span>
<input
type="number"
className="sticker-prop-number"
min={0}
max={totalDuration}
step={0.1}
value={selectedSticker.start_time}
onChange={(e) =>
updateItem(selectedSticker.id, {
start_time: Number(e.target.value),
})
}
/>
<span className="sticker-prop-label"></span>
<input
type="number"
className="sticker-prop-number"
min={0}
max={totalDuration}
step={0.1}
value={selectedSticker.duration}
onChange={(e) =>
updateItem(selectedSticker.id, {
duration: Number(e.target.value),
})
}
/>
</div>
{/* 文字贴纸特有属性 */}
{selectedSticker.type === "text" && (
<>
<div className="sticker-prop-row">
<span className="sticker-prop-label"></span>
<select
className="sticker-prop-select"
value={selectedSticker.text_preset}
onChange={(e) =>
updateItem(selectedSticker.id, {
text_preset: e.target.value as TextStickerPreset,
})
}
>
{(Object.keys(TEXT_STICKER_PRESET_LABELS) as TextStickerPreset[]).map((p) => (
<option key={p} value={p}>
{TEXT_STICKER_PRESET_LABELS[p]}
</option>
))}
</select>
</div>
<div className="sticker-prop-row">
<span className="sticker-prop-label"></span>
<input
type="range"
className="sticker-prop-slider"
min={12}
max={72}
value={selectedSticker.font_size}
onChange={(e) =>
updateItem(selectedSticker.id, {
font_size: Number(e.target.value),
})
}
/>
<span className="sticker-prop-value">{selectedSticker.font_size}px</span>
</div>
<div className="sticker-prop-row">
<span className="sticker-prop-label"></span>
<input
type="color"
className="sticker-prop-color"
value={selectedSticker.text_color}
onChange={(e) =>
updateItem(selectedSticker.id, {
text_color: e.target.value,
})
}
/>
</div>
</>
)}
{/* 预览 */}
<div className="sticker-preview-box">
<div
className="sticker-preview-item"
style={{
left: `${selectedSticker.x}%`,
top: `${selectedSticker.y}%`,
width: `${selectedSticker.width}%`,
height: `${selectedSticker.width}%`,
transform: `translate(-50%, -50%) rotate(${selectedSticker.rotation}deg)`,
opacity: selectedSticker.opacity / 100,
fontSize:
selectedSticker.type === "text" ? `${selectedSticker.font_size}px` : undefined,
...TEXT_PRESET_STYLES[selectedSticker.text_preset],
}}
>
{selectedSticker.type === "emoji" && selectedSticker.content}
{selectedSticker.type === "text" && selectedSticker.content}
{selectedSticker.type === "image" && (
<img
src={selectedSticker.content}
alt="sticker"
style={{
width: "100%",
height: "100%",
objectFit: "contain",
}}
/>
)}
</div>
</div>
</div>
)}
{/* 底部重置 */}
@@ -5,14 +5,31 @@
import React from "react"
import { Drawer, Slider, ColorPicker, Select } from "antd"
import type { Color } from "antd/es/color-picker"
import type { SubtitleStyleConfig } from "@/pages/editing-planner/types/subtitle"
import {
POSITION_OPTIONS,
FONT_OPTIONS,
ANIMATION_OPTIONS,
ASR_LANGUAGE_OPTIONS,
} from "@/pages/editing-planner/constants/subtitleStyle"
import SubtitlePreview from "./subtitle-style/SubtitlePreview"
import type { SubtitleStyleConfig } from "../types/subtitle"
/* ──────────── 选项常量 ──────────── */
const POSITION_OPTIONS = [
{ value: "top", label: "顶部" },
{ value: "center", label: "居中" },
{ value: "bottom", label: "底部" },
]
const FONT_OPTIONS = ["思源黑体", "思源宋体", "苹方", "PingFang", "微软雅黑", "楷体", "华康俪金黑"]
const ANIMATION_OPTIONS = [
{ value: "none", label: "无" },
{ value: "fade", label: "淡入淡出" },
{ value: "slide", label: "滑动" },
{ value: "typewriter", label: "打字机" },
]
const ASR_LANGUAGE_OPTIONS = [
{ value: "zh", label: "中文" },
{ value: "en", label: "English" },
]
/* ──────────── Props ──────────── */
interface SubtitleStylePanelProps {
open: boolean
@@ -180,7 +197,23 @@ const SubtitleStylePanel: React.FC<SubtitleStylePanelProps> = ({
</div>
{/* ── 预览 ── */}
<SubtitlePreview config={config} />
<div className="sub-field">
<label className="sub-label"></label>
<div className="sub-preview-box">
<span
className="sub-preview-text"
style={{
fontSize: `${Math.min(config.fontSize, 28)}px`,
color: config.fontColor,
fontFamily: config.font,
WebkitTextStroke: config.stroke ? "1px #000" : undefined,
textShadow: config.shadow ? "2px 2px 4px rgba(0,0,0,0.8)" : undefined,
}}
>
</span>
</div>
</div>
</>
)}
</Drawer>
@@ -8,21 +8,15 @@
* - 拖动时实时显示裁剪预览(入点/出点/时长)
* - 右键片段弹出菜单:分割 / 恢复原始长度 / 删除
*/
import React, { useState, useRef, useCallback, useEffect, useLayoutEffect, useMemo } from "react"
import React, { useState, useRef, useCallback, useEffect } from "react"
import type { ClipData, ClipType, TrimConfig } from "../types"
import {
DEFAULT_PIXELS_PER_SECOND,
MIN_PIXELS_PER_SECOND,
MAX_PIXELS_PER_SECOND,
ZOOM_STEP,
MIN_TRIM_DURATION,
DEFAULT_ADD_DURATION,
MIN_ADD_DURATION,
MAX_ADD_DURATION,
TRACK_GAP,
ADD_PICKER_WIDTH,
} from "../constants/timeline"
import { formatTime } from "../utils/timeline"
import { useClipDrag } from "../hooks/useClipDrag"
import { useTrimDrag } from "../hooks/useTrimDrag"
import { useTimelineMenus } from "../hooks/useTimelineMenus"
import { ClipCard } from "./timeline/ClipCard"
import { TimeRuler } from "./timeline/TimeRuler"
import { AddClipPicker } from "./timeline/AddClipPicker"
@@ -55,25 +49,6 @@ interface TimelinePanelProps {
totalDuration?: number
}
/** 裁剪拖拽方向 */
type TrimDirection = "left" | "right"
/** 裁剪拖拽状态 */
interface TrimDragState {
clipId: string
direction: TrimDirection
startX: number
originalTrim: TrimConfig
originalDuration: number
}
/** 右键菜单状态 */
interface ContextMenuState {
x: number
y: number
clipId: string
}
const TimelinePanel: React.FC<TimelinePanelProps> = ({
clips,
selectedClipId,
@@ -91,143 +66,51 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
onSeek,
totalDuration: totalDurationProp,
}) => {
const [dragIdx, setDragIdx] = useState<number | null>(null)
const [dragOverIdx, setDragOverIdx] = useState<number | null>(null)
const dragRef = useRef<number | null>(null)
const [showAddPicker, setShowAddPicker] = useState(false)
const pickerRef = useRef<HTMLDivElement>(null)
const addCardRef = useRef<HTMLDivElement>(null)
const [pickerPos, setPickerPos] = useState<{ top: number; right: number }>({
top: 0,
right: 0,
})
/* ── 缩放 & 时长 ── */
const pps = pixelsPerSecond ?? DEFAULT_PIXELS_PER_SECOND
const totalDuration = totalDurationProp ?? clips.reduce((s, c) => s + c.duration, 0)
/* ── 裁剪拖拽状态 ── */
const [trimDrag, setTrimDrag] = useState<TrimDragState | null>(null)
const [trimPreview, setTrimPreview] = useState<{
clipId: string
startTime: number
endTime: number
duration: number
x: number
y: number
} | null>(null)
/* ── 裁剪拖拽 ── */
const { trimDrag, trimPreview, handleTrimHandleMouseDown } = useTrimDrag(clips, pps, onClipTrim)
/* ── 右键菜单 ── */
const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null)
const contextMenuRef = useRef<HTMLDivElement>(null)
/* ── 片段拖拽排序 ── */
const {
dragIdx,
dragOverIdx,
handleDragStart,
handleDragOver,
handleDragEnd,
handleDrop,
handleEmptyDragOver,
} = useClipDrag(onClipReorder, !!trimDrag)
/* ── 悬停的片段 ID(显示裁剪手柄) ── */
const [hoveredClipId, setHoveredClipId] = useState<string | null>(null)
/* ── 菜单 & 面板 ── */
const {
contextMenu,
contextMenuRef,
handleContextMenu,
handleContextSplit,
handleContextResetTrim,
handleContextDelete,
showAddPicker,
pickerRef,
addCardRef,
pickerPos,
availableTypes,
addType,
addDuration,
setAddType,
setAddDuration,
handleTogglePicker,
handleConfirmAdd,
hoveredClipId,
setHoveredClipId,
} = useTimelineMenus(clips, currentMode, onAddClip, onClipSplit, onClipResetTrim, onClipRemove)
/* ── 播放头拖拽状态 ── */
const [playheadDragging, setPlayheadDragging] = useState(false)
const trackRef = useRef<HTMLDivElement>(null)
/* ── 根据模式决定可选类型 ── */
const availableTypes: ClipType[] = useMemo(
() =>
currentMode === "voice_over" ? ["voice"] : currentMode === "pip" ? ["pip"] : ["voice", "pip"], // voice_pip / one_take / 默认
[currentMode],
)
/* ── 默认添加类型:跟随模式 ── */
const defaultAddType: ClipType = useMemo(() => {
if (currentMode === "voice_over") return "voice"
if (currentMode === "pip") return "pip"
return "voice"
}, [currentMode])
/* ── "+" 卡片:类型+时长选择状态 ── */
const [addType, setAddType] = useState<ClipType>(defaultAddType)
const [addDuration, setAddDuration] = useState<number>(DEFAULT_ADD_DURATION)
/* ── 模式切换时自动同步默认添加类型 ── */
useEffect(() => {
if (!availableTypes.includes(addType)) {
setAddType(defaultAddType)
}
}, [currentMode, addType, availableTypes, defaultAddType])
/* ── 计算 picker 初始位置 ── */
const updatePickerPosition = useCallback(() => {
if (!addCardRef.current) return
const rect = addCardRef.current.getBoundingClientRect()
const vw = window.innerWidth
const roughHeight = 180
let top = rect.top - TRACK_GAP - roughHeight
if (top < 8) top = 8
let right = vw - rect.right
if (rect.right - ADD_PICKER_WIDTH < 8) {
right = vw - ADD_PICKER_WIDTH - 8
}
setPickerPos({ top, right })
}, [])
const handleTogglePicker = () => {
if (!showAddPicker) {
const defaultType =
currentMode === "pip" ? "pip" : currentMode === "voice_over" ? "voice" : "voice"
setAddType(defaultType)
updatePickerPosition()
}
setShowAddPicker((v) => !v)
}
/* ── 渲染后精确边界校正 ── */
useLayoutEffect(() => {
if (!showAddPicker || !pickerRef.current || !addCardRef.current) return
const pickerEl = pickerRef.current
const addRect = addCardRef.current.getBoundingClientRect()
const pickerH = pickerEl.offsetHeight
const vh = window.innerHeight
const vw = window.innerWidth
let top = addRect.top - TRACK_GAP - pickerH
if (top < 8) {
top = addRect.bottom + TRACK_GAP
if (top + pickerH > vh - 8) {
top = vh - 8 - pickerH
if (top < 8) top = 8
}
}
let right = vw - addRect.right
const pickerRect = pickerEl.getBoundingClientRect()
if (pickerRect.left < 8) {
right = vw - ADD_PICKER_WIDTH - 8
}
setPickerPos({ top, right })
}, [showAddPicker])
/* ── 点击外部关闭添加面板 ── */
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (pickerRef.current && !pickerRef.current.contains(e.target as Node)) {
setShowAddPicker(false)
}
}
if (showAddPicker) {
document.addEventListener("mousedown", handleClickOutside)
}
return () => document.removeEventListener("mousedown", handleClickOutside)
}, [showAddPicker])
/* ── 点击外部关闭右键菜单 ── */
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (contextMenuRef.current && !contextMenuRef.current.contains(e.target as Node)) {
setContextMenu(null)
}
}
if (contextMenu) {
document.addEventListener("mousedown", handleClickOutside)
}
return () => document.removeEventListener("mousedown", handleClickOutside)
}, [contextMenu])
/* ── 缩放 & 时长 ── */
const pps = pixelsPerSecond ?? DEFAULT_PIXELS_PER_SECOND
const totalDuration = totalDurationProp ?? clips.reduce((s, c) => s + c.duration, 0)
/* ── 播放头拖拽全局 mousemove/mouseup ── */
useEffect(() => {
if (!playheadDragging) return
@@ -271,164 +154,7 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
setPlayheadDragging(true)
}, [])
/* ── 确认添加片段 ── */
const handleConfirmAdd = () => {
onAddClip(addType, addDuration)
setShowAddPicker(false)
}
/* ── 片段拖拽排序 ── */
const handleDragStart = (e: React.DragEvent, idx: number) => {
// 如果正在裁剪拖拽,不允许排序拖拽
if (trimDrag) return
dragRef.current = idx
setDragIdx(idx)
e.dataTransfer.setData("application/x-clip-drag", String(idx))
e.dataTransfer.effectAllowed = "move"
}
const handleDragOver = (e: React.DragEvent, idx: number) => {
e.preventDefault()
e.dataTransfer.dropEffect = "move"
setDragOverIdx(idx)
}
const handleDragEnd = () => {
dragRef.current = null
setDragIdx(null)
setDragOverIdx(null)
}
const handleDrop = (e: React.DragEvent, toIdx: number) => {
e.preventDefault()
setDragOverIdx(null)
const fromStr = e.dataTransfer.getData("application/x-clip-drag")
if (fromStr !== "") {
const fromIdx = Number(fromStr)
if (fromIdx !== toIdx) {
onClipReorder(fromIdx, toIdx)
}
}
}
/* ── 空轨道区域不接受素材拖入 ── */
const handleEmptyDragOver = (e: React.DragEvent) => {
e.preventDefault()
}
/* ── 裁剪手柄拖拽 ── */
const handleTrimHandleMouseDown = useCallback(
(e: React.MouseEvent, clipId: string, direction: TrimDirection) => {
e.preventDefault()
e.stopPropagation()
const clip = clips.find((c) => c.id === clipId)
if (!clip) return
const trim: TrimConfig = clip.trim_config ?? {
start_time: 0,
end_time: clip.duration,
original_duration: clip.duration,
}
setTrimDrag({
clipId,
direction,
startX: e.clientX,
originalTrim: { ...trim },
originalDuration: clip.duration,
})
},
[clips],
)
/* ── 裁剪拖拽全局 mousemove/mouseup ── */
useEffect(() => {
if (!trimDrag) return
const handleMouseMove = (e: MouseEvent) => {
const dx = e.clientX - trimDrag.startX
const dtSec = dx / pps
const clip = clips.find((c) => c.id === trimDrag.clipId)
if (!clip) return
const origTrim = trimDrag.originalTrim
const origDur = origTrim.original_duration ?? trimDrag.originalDuration
let newStart = origTrim.start_time
let newEnd = origTrim.end_time
if (trimDrag.direction === "left") {
// 左手柄:调整入点
newStart = Math.max(0, Math.min(origTrim.start_time + dtSec, newEnd - MIN_TRIM_DURATION))
} else {
// 右手柄:调整出点
newEnd = Math.max(
origTrim.start_time + MIN_TRIM_DURATION,
Math.min(origTrim.end_time + dtSec, origDur),
)
}
const newDuration = Math.round((newEnd - newStart) * 10) / 10
setTrimPreview({
clipId: trimDrag.clipId,
startTime: Math.round(newStart * 10) / 10,
endTime: Math.round(newEnd * 10) / 10,
duration: newDuration,
x: e.clientX,
y: e.clientY,
})
}
const handleMouseUp = () => {
if (trimPreview && trimPreview.clipId === trimDrag.clipId && onClipTrim) {
const newTrim: TrimConfig = {
start_time: trimPreview.startTime,
end_time: trimPreview.endTime,
original_duration: trimDrag.originalTrim.original_duration ?? trimDrag.originalDuration,
}
onClipTrim(trimDrag.clipId, newTrim, trimPreview.duration)
}
setTrimDrag(null)
setTrimPreview(null)
}
document.addEventListener("mousemove", handleMouseMove)
document.addEventListener("mouseup", handleMouseUp)
return () => {
document.removeEventListener("mousemove", handleMouseMove)
document.removeEventListener("mouseup", handleMouseUp)
}
}, [trimDrag, trimPreview, clips, onClipTrim, pps])
/* ── 右键菜单 ── */
const handleContextMenu = useCallback((e: React.MouseEvent, clipId: string) => {
e.preventDefault()
e.stopPropagation()
setContextMenu({ x: e.clientX, y: e.clientY, clipId })
}, [])
/* ── 右键菜单操作 ── */
const handleContextSplit = useCallback(() => {
if (!contextMenu) return
if (onClipSplit) {
onClipSplit(contextMenu.clipId, 0.5) // 在中间分割
}
setContextMenu(null)
}, [contextMenu, onClipSplit])
const handleContextResetTrim = useCallback(() => {
if (!contextMenu) return
if (onClipResetTrim) {
onClipResetTrim(contextMenu.clipId)
}
setContextMenu(null)
}, [contextMenu, onClipResetTrim])
const handleContextDelete = useCallback(() => {
if (!contextMenu) return
onClipRemove(contextMenu.clipId)
setContextMenu(null)
}, [contextMenu, onClipRemove])
const trackWidth = Math.max(totalDuration * pps, 300)
return (
<div className="ep-timeline-area">
@@ -443,7 +169,7 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
<div className="ep-timeline-zoom">
<button
className="ep-zoom-btn"
onClick={() => onZoomChange?.(Math.max(MIN_PIXELS_PER_SECOND, pps - ZOOM_STEP))}
onClick={() => onZoomChange?.(Math.max(10, pps - 10))}
title="缩小"
>
@@ -451,15 +177,15 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
<input
type="range"
className="ep-zoom-slider"
min={MIN_PIXELS_PER_SECOND}
max={MAX_PIXELS_PER_SECOND}
min={10}
max={120}
step={5}
value={pps}
onChange={(e) => onZoomChange?.(Number(e.target.value))}
/>
<button
className="ep-zoom-btn"
onClick={() => onZoomChange?.(Math.min(MAX_PIXELS_PER_SECOND, pps + ZOOM_STEP))}
onClick={() => onZoomChange?.(Math.min(120, pps + 10))}
title="放大"
>
+
@@ -583,8 +309,6 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
availableTypes={availableTypes}
addType={addType}
addDuration={addDuration}
minDuration={MIN_ADD_DURATION}
maxDuration={MAX_ADD_DURATION}
onTypeChange={setAddType}
onDurationChange={setAddDuration}
onConfirm={handleConfirmAdd}
+220 -62
View File
@@ -2,14 +2,23 @@
* TTS 配音面板 — Drawer 形式
* 配音模式切换 + 文本输入 + 音色选择 + 语速/语调/音量 + 试听 + 字幕联动
*/
import React from "react"
import { Drawer } from "antd"
import type { TtsConfig } from "@/pages/editing-planner/types"
import { TTS_MODE_OPTIONS } from "@/pages/editing-planner/constants/tts"
import VoiceSelector from "./tts/VoiceSelector"
import TtsSlider from "./tts/TtsSlider"
import { useTtsPanel } from "@/pages/editing-planner/hooks/useTtsPanel"
import React, { useState, useCallback, useEffect, useRef } from "react"
import { Drawer, Slider, message } from "antd"
import type { TtsConfig, TtsMode } from "../types"
import { DEFAULT_TTS_CONFIG } from "../types"
import { getTtsVoices, previewTts, type TTSVoice } from "@/api/tts"
/* ──────────── 音色卡片分类图标 ──────────── */
const VOICE_CATEGORY_MAP: Record<string, { icon: string; label: string }> = {
male: { icon: "👨", label: "男声" },
female: { icon: "👩", label: "女声" },
young: { icon: "🧑", label: "少年" },
service: { icon: "🎧", label: "客服" },
news: { icon: "📰", label: "新闻" },
emotion: { icon: "🎭", label: "情感" },
}
/* ──────────── Props ──────────── */
interface TtsPanelProps {
open: boolean
onClose: () => void
@@ -19,21 +28,126 @@ interface TtsPanelProps {
}
const TtsPanel: React.FC<TtsPanelProps> = ({ open, onClose, config, onChange }) => {
const {
voices,
voicesLoading,
previewLoading,
handleModeChange,
handleTextChange,
handleVoiceSelect,
handleSpeedChange,
handlePitchChange,
handleVolumeChange,
handleSubtitleSyncToggle,
handlePreview,
handleReset,
handleClose,
} = useTtsPanel({ open, config, onChange, onClose })
/* ── 音色列表 ── */
const [voices, setVoices] = useState<TTSVoice[]>([])
const [voicesLoading, setVoicesLoading] = useState(false)
/* ── 试听状态 ── */
const [previewLoading, setPreviewLoading] = useState(false)
const audioRef = useRef<HTMLAudioElement | null>(null)
/* ── 加载音色列表 ── */
useEffect(() => {
if (!open) return
setVoicesLoading(true)
getTtsVoices()
.then((v) => setVoices(v))
.catch(() => message.error("加载音色列表失败"))
.finally(() => setVoicesLoading(false))
}, [open])
/* ── 切换配音模式 ── */
const handleModeChange = useCallback(
(mode: TtsMode) => {
onChange({ ...config, mode })
},
[config, onChange],
)
/* ── 文本输入 ── */
const handleTextChange = useCallback(
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
const text = e.target.value.slice(0, 5000)
onChange({ ...config, text })
},
[config, onChange],
)
/* ── 选择音色 ── */
const handleVoiceSelect = useCallback(
(voiceId: string) => {
onChange({ ...config, voice_id: voiceId })
},
[config, onChange],
)
/* ── 语速 ── */
const handleSpeedChange = useCallback(
(speed: number) => {
onChange({ ...config, speed })
},
[config, onChange],
)
/* ── 语调 ── */
const handlePitchChange = useCallback(
(pitch: number) => {
onChange({ ...config, pitch })
},
[config, onChange],
)
/* ── 音量 ── */
const handleVolumeChange = useCallback(
(volume: number) => {
onChange({ ...config, volume })
},
[config, onChange],
)
/* ── 字幕联动 ── */
const handleSubtitleSyncToggle = useCallback(() => {
onChange({ ...config, subtitle_sync: !config.subtitle_sync })
}, [config, onChange])
/* ── 试听 ── */
const handlePreview = useCallback(async () => {
if (!config.text.trim()) {
message.warning("请先输入合成文本")
return
}
if (!config.voice_id) {
message.warning("请先选择音色")
return
}
setPreviewLoading(true)
try {
const res = await previewTts({
text: config.text.slice(0, 200), // 试听截取前200字
voice_id: config.voice_id,
speed: config.speed,
pitch: config.pitch,
})
// 停止上一个
audioRef.current?.pause()
const audio = new Audio(res.audio_url)
audioRef.current = audio
audio.play().catch(() => message.error("播放失败"))
audio.onended = () => {
audioRef.current = null
}
message.success("试听播放中")
} catch {
message.error("试听生成失败")
} finally {
setPreviewLoading(false)
}
}, [config])
/* ── 重置 ── */
const handleReset = useCallback(() => {
onChange({ ...DEFAULT_TTS_CONFIG })
}, [onChange])
/* ── 关闭时停止音频 ── */
const handleClose = useCallback(() => {
audioRef.current?.pause()
audioRef.current = null
onClose()
}, [onClose])
/* ── 音色分类分组 ── */
const voiceCategories = Object.entries(VOICE_CATEGORY_MAP)
return (
<Drawer
@@ -48,7 +162,11 @@ const TtsPanel: React.FC<TtsPanelProps> = ({ open, onClose, config, onChange })
<div className="tts-mode-section">
<div className="tts-mode-label"></div>
<div className="tts-mode-group">
{TTS_MODE_OPTIONS.map((m) => (
{[
{ mode: "none" as TtsMode, icon: "🔇", label: "无配音" },
{ mode: "upload" as TtsMode, icon: "📁", label: "上传配音" },
{ mode: "tts" as TtsMode, icon: "🤖", label: "TTS 合成" },
].map((m) => (
<button
key={m.mode}
className={`tts-mode-btn${config.mode === m.mode ? " active" : ""}`}
@@ -74,57 +192,97 @@ const TtsPanel: React.FC<TtsPanelProps> = ({ open, onClose, config, onChange })
className="tts-text-input"
placeholder="请输入需要合成的文本内容..."
value={config.text}
onChange={(e) => handleTextChange(e.target.value)}
onChange={handleTextChange}
maxLength={5000}
rows={5}
/>
</div>
{/* 音色选择 */}
<VoiceSelector
voices={voices}
voicesLoading={voicesLoading}
selectedVoiceId={config.voice_id}
onVoiceSelect={handleVoiceSelect}
/>
<div className="tts-voice-section">
<div className="tts-voice-label">
{voicesLoading && <span className="tts-voice-loading">...</span>}
</div>
<div className="tts-voice-grid">
{voiceCategories.map(([cat, info]) => {
const voice = voices.find((v) => v.category === cat)
const isSelected = voice && config.voice_id === voice.id
return (
<button
key={cat}
className={`tts-voice-card${isSelected ? " active" : ""}`}
onClick={() => voice && handleVoiceSelect(voice.id)}
disabled={!voice || voicesLoading}
>
<span className="tts-voice-card-icon">{info.icon}</span>
<span className="tts-voice-card-name">{voice?.name || info.label}</span>
</button>
)
})}
</div>
</div>
{/* 语速滑块 */}
<TtsSlider
label="语速"
value={config.speed}
min={0.5}
max={2.0}
step={0.05}
unit="x"
onChange={handleSpeedChange}
marks={["0.5x", "1.0x", "2.0x"]}
tooltipFormatter={(v) => `${v.toFixed(2)}x`}
/>
<div className="tts-slider-section">
<div className="tts-slider-header">
<span className="tts-slider-label"></span>
<span className="tts-slider-value">{config.speed.toFixed(2)}x</span>
</div>
<Slider
min={0.5}
max={2.0}
step={0.05}
value={config.speed}
onChange={handleSpeedChange}
tooltip={{ formatter: (v) => `${(v as number).toFixed(2)}x` }}
/>
<div className="tts-slider-marks">
<span>0.5x</span>
<span>1.0x</span>
<span>2.0x</span>
</div>
</div>
{/* 语调滑块 */}
<TtsSlider
label="语调"
value={config.pitch}
min={-12}
max={12}
step={1}
unit="半音"
onChange={handlePitchChange}
marks={["-12", "0", "+12"]}
tooltipFormatter={(v) => `${v}半音`}
/>
<div className="tts-slider-section">
<div className="tts-slider-header">
<span className="tts-slider-label"></span>
<span className="tts-slider-value">
{config.pitch > 0 ? "+" : ""}
{config.pitch}
</span>
</div>
<Slider
min={-12}
max={12}
step={1}
value={config.pitch}
onChange={handlePitchChange}
tooltip={{ formatter: (v) => `${v}半音` }}
/>
<div className="tts-slider-marks">
<span>-12</span>
<span>0</span>
<span>+12</span>
</div>
</div>
{/* 音量滑块 */}
<TtsSlider
label="音量"
value={config.volume}
min={0}
max={100}
step={1}
unit="%"
onChange={handleVolumeChange}
tooltipFormatter={(v) => `${v}%`}
/>
<div className="tts-slider-section">
<div className="tts-slider-header">
<span className="tts-slider-label"></span>
<span className="tts-slider-value">{config.volume}%</span>
</div>
<Slider
min={0}
max={100}
step={1}
value={config.volume}
onChange={handleVolumeChange}
tooltip={{ formatter: (v) => `${v}%` }}
/>
</div>
{/* 试听按钮 */}
<div className="tts-preview-section">
@@ -3,16 +3,38 @@
* 三个 Tab:图片水印 / 文字水印 / 滚动水印
* 通用设置:位置、不透明度
*/
import React from "react"
import React, { useCallback, useState } from "react"
import { Drawer } from "antd"
import type { WatermarkConfig } from "@/pages/editing-planner/types"
import WatermarkTypeTabs from "./watermark/WatermarkTypeTabs"
import ImageWatermarkSection from "./watermark/ImageWatermarkSection"
import TextWatermarkSection from "./watermark/TextWatermarkSection"
import ScrollWatermarkSection from "./watermark/ScrollWatermarkSection"
import WatermarkCommonSection from "./watermark/WatermarkCommonSection"
import { useWatermarkConfig } from "@/pages/editing-planner/hooks/useWatermarkConfig"
import type { WatermarkConfig, WatermarkType, WatermarkPosition, ScrollDirection } from "../types"
import { DEFAULT_WATERMARK } from "../types"
/* ──────────── 常量 ──────────── */
const WATERMARK_TABS: { key: WatermarkType; label: string; icon: string }[] = [
{ key: "none", label: "无水印", icon: "🚫" },
{ key: "image", label: "图片水印", icon: "🖼️" },
{ key: "text", label: "文字水印", icon: "📝" },
{ key: "scroll", label: "滚动水印", icon: "📜" },
]
const POSITION_OPTIONS: { value: WatermarkPosition; label: string }[] = [
{ value: "top_left", label: "左上角" },
{ value: "top_right", label: "右上角" },
{ value: "bottom_left", label: "左下角" },
{ value: "bottom_right", label: "右下角" },
{ value: "center", label: "居中" },
]
const SCROLL_DIRECTION_OPTIONS: {
value: ScrollDirection
label: string
}[] = [
{ value: "horizontal", label: "水平滚动" },
{ value: "vertical", label: "垂直滚动" },
{ value: "diagonal", label: "对角滚动" },
]
/* ──────────── Props ──────────── */
interface WatermarkPanelProps {
open: boolean
onClose: () => void
@@ -21,21 +43,100 @@ interface WatermarkPanelProps {
}
const WatermarkPanel: React.FC<WatermarkPanelProps> = ({ open, onClose, config, onChange }) => {
const {
localImageUrl,
handleTypeChange,
handlePositionChange,
handleOpacityChange,
handleImageUrlChange,
handleImageWidthChange,
handleImageHeightChange,
handleTextChange,
handleFontSizeChange,
handleColorChange,
handleScrollDirectionChange,
handleScrollSpeedChange,
handleReset,
} = useWatermarkConfig({ config, onChange })
/* ── 图片上传预览 URL(本地预览用) ── */
const [localImageUrl, setLocalImageUrl] = useState<string>("")
/* ── 切换水印类型 ── */
const handleTypeChange = useCallback(
(type: WatermarkType) => {
onChange({ ...DEFAULT_WATERMARK, type })
},
[onChange],
)
/* ── 通用设置变更 ── */
const handlePositionChange = useCallback(
(position: WatermarkPosition) => {
onChange({ ...config, position })
},
[config, onChange],
)
const handleOpacityChange = useCallback(
(opacity: number) => {
onChange({ ...config, opacity })
},
[config, onChange],
)
/* ── 图片水印设置 ── */
const handleImageUrlChange = useCallback(
(url: string) => {
setLocalImageUrl(url)
onChange({ ...config, image_url: url })
},
[config, onChange],
)
const handleImageWidthChange = useCallback(
(width: number) => {
onChange({ ...config, width })
},
[config, onChange],
)
const handleImageHeightChange = useCallback(
(height: number) => {
onChange({ ...config, height })
},
[config, onChange],
)
/* ── 文字水印设置 ── */
const handleTextChange = useCallback(
(text: string) => {
onChange({ ...config, text })
},
[config, onChange],
)
const handleFontSizeChange = useCallback(
(font_size: number) => {
onChange({ ...config, font_size })
},
[config, onChange],
)
const handleColorChange = useCallback(
(color: string) => {
onChange({ ...config, color })
},
[config, onChange],
)
/* ── 滚动水印设置 ── */
const handleScrollDirectionChange = useCallback(
(scroll_direction: ScrollDirection) => {
onChange({ ...config, scroll_direction })
},
[config, onChange],
)
const handleScrollSpeedChange = useCallback(
(scroll_speed: number) => {
onChange({ ...config, scroll_speed })
},
[config, onChange],
)
/* ── 重置 ── */
const handleReset = useCallback(() => {
setLocalImageUrl("")
onChange({ ...DEFAULT_WATERMARK })
}, [onChange])
/* ── 当前激活的 Tab ── */
const activeTab = config.type
return (
<Drawer
@@ -47,7 +148,18 @@ const WatermarkPanel: React.FC<WatermarkPanelProps> = ({ open, onClose, config,
className="watermark-panel-drawer"
>
{/* ── Tab 切换 ── */}
<WatermarkTypeTabs activeTab={config.type} onTypeChange={handleTypeChange} />
<div className="wp-tabs">
{WATERMARK_TABS.map((tab) => (
<button
key={tab.key}
className={`wp-tab${activeTab === tab.key ? " active" : ""}`}
onClick={() => handleTypeChange(tab.key)}
>
<span className="wp-tab-icon">{tab.icon}</span>
<span className="wp-tab-label">{tab.label}</span>
</button>
))}
</div>
{/* ── 无水印提示 ── */}
{config.type === "none" && (
@@ -60,53 +172,210 @@ const WatermarkPanel: React.FC<WatermarkPanelProps> = ({ open, onClose, config,
{/* ── 图片水印配置 ── */}
{config.type === "image" && (
<ImageWatermarkSection
imageUrl={config.image_url || ""}
localImageUrl={localImageUrl}
width={config.width}
height={config.height}
onImageUrlChange={handleImageUrlChange}
onWidthChange={handleImageWidthChange}
onHeightChange={handleImageHeightChange}
/>
<div className="wp-section">
<div className="wp-field">
<label className="wp-field-label"> URL</label>
<input
className="wp-input"
type="text"
placeholder="https://example.com/logo.png"
value={localImageUrl || config.image_url || ""}
onChange={(e) => handleImageUrlChange(e.target.value)}
/>
{(localImageUrl || config.image_url) && (
<div className="wp-image-preview">
<img
src={localImageUrl || config.image_url}
alt="水印预览"
onError={(e) => {
;(e.target as HTMLImageElement).style.display = "none"
}}
/>
</div>
)}
</div>
<div className="wp-field">
<label className="wp-field-label">0 </label>
<input
className="wp-input"
type="number"
min={0}
max={2000}
value={config.width ?? 0}
onChange={(e) => handleImageWidthChange(Number(e.target.value))}
/>
</div>
<div className="wp-field">
<label className="wp-field-label">0 </label>
<input
className="wp-input"
type="number"
min={0}
max={2000}
value={config.height ?? 0}
onChange={(e) => handleImageHeightChange(Number(e.target.value))}
/>
</div>
</div>
)}
{/* ── 文字水印配置 ── */}
{config.type === "text" && (
<TextWatermarkSection
text={config.text || ""}
fontSize={config.font_size ?? 24}
color={config.color ?? "#ffffff"}
onTextChange={handleTextChange}
onFontSizeChange={handleFontSizeChange}
onColorChange={handleColorChange}
/>
<div className="wp-section">
<div className="wp-field">
<label className="wp-field-label"></label>
<textarea
className="wp-textarea"
placeholder="输入水印文字内容"
rows={3}
value={config.text ?? ""}
onChange={(e) => handleTextChange(e.target.value)}
/>
</div>
<div className="wp-field">
<label className="wp-field-label"></label>
<div className="wp-slider-row">
<input
className="wp-slider"
type="range"
min={12}
max={72}
value={config.font_size ?? 24}
onChange={(e) => handleFontSizeChange(Number(e.target.value))}
/>
<span className="wp-slider-value">{config.font_size ?? 24}px</span>
</div>
</div>
<div className="wp-field">
<label className="wp-field-label"></label>
<div className="wp-color-row">
<input
className="wp-color-input"
type="color"
value={config.color ?? "#ffffff"}
onChange={(e) => handleColorChange(e.target.value)}
/>
<span className="wp-color-value">{config.color ?? "#ffffff"}</span>
</div>
</div>
</div>
)}
{/* ── 滚动水印配置 ── */}
{config.type === "scroll" && (
<ScrollWatermarkSection
text={config.text || ""}
scrollDirection={config.scroll_direction ?? "horizontal"}
scrollSpeed={config.scroll_speed ?? 50}
fontSize={config.font_size ?? 24}
color={config.color ?? "#ffffff"}
onTextChange={handleTextChange}
onScrollDirectionChange={handleScrollDirectionChange}
onScrollSpeedChange={handleScrollSpeedChange}
onFontSizeChange={handleFontSizeChange}
onColorChange={handleColorChange}
/>
<div className="wp-section">
<div className="wp-field">
<label className="wp-field-label"></label>
<textarea
className="wp-textarea"
placeholder="输入滚动水印文字"
rows={2}
value={config.text ?? ""}
onChange={(e) => handleTextChange(e.target.value)}
/>
</div>
<div className="wp-field">
<label className="wp-field-label"></label>
<select
className="wp-select"
value={config.scroll_direction ?? "horizontal"}
onChange={(e) => handleScrollDirectionChange(e.target.value as ScrollDirection)}
>
{SCROLL_DIRECTION_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
<div className="wp-field">
<label className="wp-field-label"></label>
<div className="wp-slider-row">
<input
className="wp-slider"
type="range"
min={10}
max={200}
value={config.scroll_speed ?? 50}
onChange={(e) => handleScrollSpeedChange(Number(e.target.value))}
/>
<span className="wp-slider-value">{config.scroll_speed ?? 50}px/s</span>
</div>
</div>
<div className="wp-field">
<label className="wp-field-label"></label>
<div className="wp-slider-row">
<input
className="wp-slider"
type="range"
min={12}
max={72}
value={config.font_size ?? 24}
onChange={(e) => handleFontSizeChange(Number(e.target.value))}
/>
<span className="wp-slider-value">{config.font_size ?? 24}px</span>
</div>
</div>
<div className="wp-field">
<label className="wp-field-label"></label>
<div className="wp-color-row">
<input
className="wp-color-input"
type="color"
value={config.color ?? "#ffffff"}
onChange={(e) => handleColorChange(e.target.value)}
/>
<span className="wp-color-value">{config.color ?? "#ffffff"}</span>
</div>
</div>
</div>
)}
{/* ── 通用设置(非 none 时显示) ── */}
{config.type !== "none" && (
<WatermarkCommonSection
position={config.position}
opacity={config.opacity}
onPositionChange={handlePositionChange}
onOpacityChange={handleOpacityChange}
/>
<div className="wp-section wp-common-section">
<div className="wp-section-divider" />
<div className="wp-common-title"></div>
<div className="wp-field">
<label className="wp-field-label"></label>
<select
className="wp-select"
value={config.position}
onChange={(e) => handlePositionChange(e.target.value as WatermarkPosition)}
>
{POSITION_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
<div className="wp-field">
<label className="wp-field-label"></label>
<div className="wp-slider-row">
<input
className="wp-slider"
type="range"
min={0}
max={1}
step={0.05}
value={config.opacity}
onChange={(e) => handleOpacityChange(Number(e.target.value))}
/>
<span className="wp-slider-value">{Math.round(config.opacity * 100)}%</span>
</div>
</div>
</div>
)}
{/* ── 底部操作 ── */}
@@ -1,41 +0,0 @@
/**
* BGM 设置区块
*/
import React from "react"
import type { BgmSettings } from "@/pages/editing-planner/types/clipProperties"
interface BgmSettingsSectionProps {
settings: BgmSettings
onOpenBgmDrawer?: () => void
}
const BgmSettingsSection: React.FC<BgmSettingsSectionProps> = ({ settings, onOpenBgmDrawer }) => {
return (
<div className="ep-settings-section">
<div className="ep-section-title">
<span className="ep-section-icon">🎵</span>
BGM
</div>
{settings.enabled && settings.music_id ? (
<div className="ep-bgm-current">
<span className="ep-bgm-current-label">🎵 BGM</span>
<span className="ep-bgm-current-id">{settings.music_id}</span>
{settings.volume !== undefined && (
<span className="ep-bgm-current-vol"> {settings.volume}%</span>
)}
</div>
) : (
<div className="ep-bgm-empty"></div>
)}
{onOpenBgmDrawer && (
<button className="ep-advanced-btn" onClick={onOpenBgmDrawer}>
🎵 {settings.enabled ? "更换 BGM / 调整混音" : "选择 BGM 音乐"}
</button>
)}
</div>
)
}
export default BgmSettingsSection
@@ -1,266 +0,0 @@
/**
* 片段详情区块
*/
import React from "react"
import { useNavigate } from "react-router-dom"
import { TRANSITION_OPTIONS } from "@/api/template-editor"
import type { ClipData, ClipType } from "@/pages/editing-planner/types"
import { CLIP_TYPE_ICONS, CLIP_TYPE_LABELS } from "@/pages/editing-planner/constants/clipProperties"
import { getGenderLabel } from "@/pages/editing-planner/utils/clipProperties"
import type { AssetItem } from "@/api/assets"
import type { TemplateMode } from "@/api/editing-planner"
interface ClipDetailSectionProps {
clip: ClipData
currentMode: TemplateMode
voiceMaterials?: AssetItem[]
voiceMaterialsLoading?: boolean
onClipUpdate: (clipId: string, data: Partial<ClipData>) => void
onRefreshVoiceMaterials?: () => void
onClipVoiceSelect?: (clipId: string, asset: AssetItem | null) => void
onOpenTransitionDrawer?: (clipId: string) => void
onOpenSpeedDrawer?: (clipId: string) => void
onOpenTtsDrawer?: (clipId: string) => void
previewingId: string | null
onPreviewVoice: (asset: AssetItem) => void
onStopPreview: () => void
}
const ClipDetailSection: React.FC<ClipDetailSectionProps> = ({
clip,
currentMode,
voiceMaterials = [],
voiceMaterialsLoading = false,
onClipUpdate,
onRefreshVoiceMaterials,
onClipVoiceSelect,
onOpenTransitionDrawer,
onOpenSpeedDrawer,
onOpenTtsDrawer,
previewingId,
onPreviewVoice,
onStopPreview,
}) => {
const navigate = useNavigate()
const isTypeDisabled = (t: ClipType) => {
if (currentMode === "pip") return t !== "pip"
if (currentMode === "voice_over") return t !== "voice"
return false
}
const transitionLabel = (() => {
const t = clip.transition
if (!t || t.type === "none") return "无转场"
const opt = TRANSITION_OPTIONS.find((o) => o.value === t.type)
return `${opt?.label ?? t.type} · ${t.duration.toFixed(1)}s`
})()
const speedLabel = clip.speed ? `${clip.speed.rate.toFixed(2)}x` : "1.00x"
const ttsLabel = (() => {
const tts = clip.tts_config
if (!tts || tts.mode === "none") return "无配音"
if (tts.mode === "upload") return "上传配音"
return `TTS · ${tts.voice_id ? "已选音色" : "未选音色"}`
})()
return (
<div className="ep-settings-section">
<div className="ep-section-title">
<span className="ep-section-icon">🎞</span>
</div>
<div className="ep-clip-detail">
{/* 类型选择器 */}
<div className="ep-clip-detail-field">
<div className="ep-clip-detail-label"></div>
<div className="ep-clip-type-selector">
{(["voice", "pip"] as ClipType[]).map((t) => {
const disabled = isTypeDisabled(t)
return (
<button
key={t}
className={`ep-clip-type-btn${clip.type === t ? " active" : ""}${disabled ? " disabled" : ""}`}
disabled={disabled}
onClick={() => !disabled && onClipUpdate(clip.id, { type: t })}
>
{CLIP_TYPE_ICONS[t]} {CLIP_TYPE_LABELS[t]}
</button>
)
})}
</div>
</div>
{/* 时长 */}
<div className="ep-clip-detail-field">
<div className="ep-clip-detail-label"></div>
<div className="ep-clip-detail-row">
<input
className="ep-duration-input"
type="number"
min={1}
max={120}
value={clip.duration}
onChange={(e) =>
onClipUpdate(clip.id, {
duration: Math.max(1, Math.min(120, Number(e.target.value) || 1)),
})
}
/>
<span className="ep-clip-detail-unit"></span>
</div>
</div>
{/* 转场效果入口 */}
{onOpenTransitionDrawer && (
<div className="ep-clip-detail-field">
<button
className="ep-advanced-btn ep-advanced-btn--transition"
onClick={() => onOpenTransitionDrawer(clip.id)}
>
<span className="ep-advanced-btn-icon">🎬</span>
<span className="ep-advanced-btn-label"></span>
<span className="ep-advanced-btn-value">{transitionLabel}</span>
<span className="ep-advanced-btn-arrow"></span>
</button>
</div>
)}
{/* 播放速度入口 */}
{onOpenSpeedDrawer && (
<div className="ep-clip-detail-field">
<button
className="ep-advanced-btn ep-advanced-btn--speed"
onClick={() => onOpenSpeedDrawer(clip.id)}
>
<span className="ep-advanced-btn-icon"></span>
<span className="ep-advanced-btn-label"></span>
<span className="ep-advanced-btn-value">{speedLabel}</span>
<span className="ep-advanced-btn-arrow"></span>
</button>
</div>
)}
{/* TTS 配音入口 */}
{onOpenTtsDrawer && (
<div className="ep-clip-detail-field">
<button
className="ep-advanced-btn ep-advanced-btn--tts"
onClick={() => onOpenTtsDrawer(clip.id)}
>
<span className="ep-advanced-btn-icon">🎙</span>
<span className="ep-advanced-btn-label">TTS </span>
<span className="ep-advanced-btn-value">{ttsLabel}</span>
<span className="ep-advanced-btn-arrow"></span>
</button>
</div>
)}
{/* 素材起始时间 — 仅 voice 类型显示 */}
{clip.type === "voice" && (
<div className="ep-clip-detail-field">
<div className="ep-clip-detail-label"></div>
<div className="ep-clip-detail-row">
<input
className="ep-duration-input"
type="number"
min={0}
max={9999}
step={0.1}
value={clip.startOffset}
onChange={(e) =>
onClipUpdate(clip.id, {
startOffset: Math.max(0, Math.min(9999, Number(e.target.value) || 0)),
})
}
/>
<span className="ep-clip-detail-unit"></span>
</div>
</div>
)}
{/* 配音素材选择 — 仅 voice 类型显示 */}
{clip.type === "voice" && (
<div className="ep-clip-detail-field">
<div className="ep-clip-detail-label">
{onRefreshVoiceMaterials && (
<button
type="button"
className="ep-voice-refresh-btn"
title="刷新配音列表"
onClick={() => onRefreshVoiceMaterials()}
disabled={voiceMaterialsLoading}
>
{voiceMaterialsLoading ? "⏳" : "🔄"}
</button>
)}
</div>
{voiceMaterialsLoading && voiceMaterials.length === 0 ? (
<div className="ep-voice-loading">...</div>
) : (
<>
<div className="ep-voice-select-row">
<select
className="ep-clip-detail-select"
value={clip.voice_asset_id ?? ""}
onChange={(e) => {
const assetId = e.target.value
if (!onClipVoiceSelect) return
if (!assetId) {
onClipVoiceSelect(clip.id, null)
} else {
const asset = voiceMaterials.find((m) => m.id === assetId)
if (asset) onClipVoiceSelect(clip.id, asset)
}
onStopPreview()
}}
>
<option value=""></option>
{voiceMaterials.map((m) => {
const gender = getGenderLabel(m)
const label = gender ? `${m.name}${gender}` : m.name
return (
<option key={m.id} value={m.id}>
{label}
</option>
)
})}
</select>
{clip.voice_asset_id && (
<button
type="button"
className="ep-voice-preview-btn"
title={previewingId === clip.voice_asset_id ? "暂停" : "试听"}
onClick={() => {
const asset = voiceMaterials.find((m) => m.id === clip.voice_asset_id)
if (asset) onPreviewVoice(asset)
}}
>
{previewingId === clip.voice_asset_id ? "⏸" : "▶️"}
</button>
)}
</div>
{voiceMaterials.length === 0 && (
<div className="ep-voice-empty"></div>
)}
</>
)}
<button
className="ep-voice-upload-btn"
onClick={() => navigate("/app/voice-materials")}
>
+
</button>
</div>
)}
</div>
</div>
)
}
export default ClipDetailSection
@@ -1,39 +0,0 @@
/**
* 编辑统计区块
*/
import React from "react"
import { formatModeLabel } from "@/pages/editing-planner/utils/clipProperties"
import type { TemplateMode } from "@/api/editing-planner"
interface StatsSectionProps {
clipsCount: number
totalDuration: number
currentMode: TemplateMode
}
const StatsSection: React.FC<StatsSectionProps> = ({ clipsCount, totalDuration, currentMode }) => {
return (
<div className="ep-settings-section">
<div className="ep-section-title">
<span className="ep-section-icon">📊</span>
</div>
<div className="ep-clip-detail">
<div className="ep-clip-detail-field">
<div className="ep-clip-detail-label"></div>
<div className="ep-clip-detail-value">{clipsCount}</div>
</div>
<div className="ep-clip-detail-field">
<div className="ep-clip-detail-label"></div>
<div className="ep-clip-detail-value">{totalDuration.toFixed(1)}s</div>
</div>
<div className="ep-clip-detail-field">
<div className="ep-clip-detail-label"></div>
<div className="ep-clip-detail-value">{formatModeLabel(currentMode)}</div>
</div>
</div>
</div>
)
}
export default StatsSection
@@ -1,113 +0,0 @@
/**
* 字幕设置区块
*/
import React from "react"
import {
POSITION_OPTIONS,
FONT_OPTIONS,
ANIMATION_OPTIONS,
} from "@/pages/editing-planner/constants/clipProperties"
import type { SubtitleSettings } from "@/pages/editing-planner/types/clipProperties"
interface SubtitleSettingsSectionProps {
settings: SubtitleSettings
onChange: (partial: Partial<SubtitleSettings>) => void
onOpenSubtitleDrawer?: () => void
}
const SubtitleSettingsSection: React.FC<SubtitleSettingsSectionProps> = ({
settings,
onChange,
onOpenSubtitleDrawer,
}) => {
return (
<div className="ep-settings-section">
<div className="ep-section-title">
<span className="ep-section-icon">💬</span>
</div>
<div className="ep-toggle-row">
<span className="ep-toggle-label"></span>
<div
className={`ep-toggle ${settings.enabled ? "active" : ""}`}
onClick={() => onChange({ enabled: !settings.enabled })}
>
<div className="ep-toggle-knob" />
</div>
</div>
{settings.enabled && (
<>
<div className="ep-field">
<label className="ep-field-label"></label>
<select
className="ep-form-select"
value={settings.position}
onChange={(e) => onChange({ position: e.target.value })}
>
{POSITION_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
<div className="ep-field">
<label className="ep-field-label"></label>
<select
className="ep-form-select"
value={settings.font}
onChange={(e) => onChange({ font: e.target.value })}
>
{FONT_OPTIONS.map((f) => (
<option key={f} value={f}>
{f}
</option>
))}
</select>
</div>
<div className="ep-field">
<label className="ep-field-label"></label>
<div className="ep-slider-row">
<input
className="ep-slider"
type="range"
min={12}
max={48}
value={settings.fontSize}
onChange={(e) => onChange({ fontSize: Number(e.target.value) })}
/>
<span className="ep-slider-value">{settings.fontSize}px</span>
</div>
</div>
<div className="ep-field">
<label className="ep-field-label"></label>
<select
className="ep-form-select"
value={settings.animation}
onChange={(e) => onChange({ animation: e.target.value })}
>
{ANIMATION_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
{onOpenSubtitleDrawer && (
<button className="ep-advanced-btn" onClick={onOpenSubtitleDrawer}>
🎨
</button>
)}
</>
)}
</div>
)
}
export default SubtitleSettingsSection
@@ -1,40 +0,0 @@
/**
* 滤镜手动调节组件
*/
import React from "react"
import type { FilterConfig } from "../../types"
import { MANUAL_ADJUST_ITEMS } from "../../constants/filter"
type FilterKey = keyof Pick<
FilterConfig,
"brightness" | "contrast" | "saturation" | "temperature" | "tint" | "sharpness"
>
interface FilterManualAdjustProps {
config: FilterConfig
onChange: (key: FilterKey, value: number) => void
}
const FilterManualAdjust: React.FC<FilterManualAdjustProps> = ({ config, onChange }) => {
return (
<div className="filter-section">
<div className="filter-section-title"></div>
{MANUAL_ADJUST_ITEMS.map((item) => (
<div key={item.key} className="filter-slider-row">
<span className="filter-slider-label">{item.label}</span>
<input
type="range"
className="filter-slider"
min={item.min}
max={item.max}
value={config[item.key as FilterKey]}
onChange={(e) => onChange(item.key as FilterKey, Number(e.target.value))}
/>
<span className="filter-slider-value">{config[item.key as FilterKey]}</span>
</div>
))}
</div>
)
}
export default FilterManualAdjust
@@ -1,34 +0,0 @@
/**
* 滤镜预设选择组件
*/
import React from "react"
import type { FilterPreset } from "../../types"
import { FILTER_PRESET_LABELS } from "../../types"
import { PRESET_LIST, PRESET_GRADIENTS } from "../../constants/filter"
interface FilterPresetGridProps {
selectedPreset: FilterPreset
onPresetSelect: (preset: FilterPreset) => void
}
const FilterPresetGrid: React.FC<FilterPresetGridProps> = ({ selectedPreset, onPresetSelect }) => {
return (
<div className="filter-section">
<div className="filter-section-title"></div>
<div className="filter-presets">
{PRESET_LIST.map((p) => (
<button
key={p}
className={`filter-preset-item${selectedPreset === p ? " active" : ""}`}
onClick={() => onPresetSelect(p)}
>
<div className="filter-preset-preview" style={{ background: PRESET_GRADIENTS[p] }} />
<span className="filter-preset-label">{FILTER_PRESET_LABELS[p]}</span>
</button>
))}
</div>
</div>
)
}
export default FilterPresetGrid
@@ -1,123 +0,0 @@
/**
* 片头/片尾通用区块组件(片头片尾结构对称,复用同一个组件)
*/
import React from "react"
import type { IntroOutroItem, IntroOutroKind, TransitionType } from "../../types"
import { TRANSITION_OPTIONS } from "@/api/template-editor"
import { KIND_OPTIONS } from "../../constants/introOutro"
interface IntroOutroBlockProps {
title: string
icon: string
item: IntroOutroItem
transitionLabel: string
onKindChange: (kind: IntroOutroKind) => void
onChange: (partial: Partial<IntroOutroItem>) => void
}
const IntroOutroBlock: React.FC<IntroOutroBlockProps> = ({
title,
icon,
item,
transitionLabel,
onKindChange,
onChange,
}) => {
const hasContent = item.kind !== "none"
return (
<div className="iop-block">
<div className="iop-block-header">
<span className="iop-block-icon">{icon}</span>
<span className="iop-block-title">{title}</span>
</div>
{/* 类型选择 */}
<div className="iop-kind-row">
{KIND_OPTIONS.map((opt) => (
<button
key={opt.value}
className={`iop-kind-btn${item.kind === opt.value ? " active" : ""}`}
onClick={() => onKindChange(opt.value)}
>
<span className="iop-kind-icon">{opt.icon}</span>
<span className="iop-kind-label">{opt.label}</span>
</button>
))}
</div>
{/* 视频/图片配置 */}
{hasContent && (
<>
<div className="iop-field">
<label className="iop-field-label">{item.kind === "video" ? "视频" : "图片"} URL</label>
<input
className="iop-input"
type="text"
placeholder={
item.kind === "video"
? `https://example.com/${title}.mp4`
: `https://example.com/${title}.png`
}
value={item.url ?? ""}
onChange={(e) => onChange({ url: e.target.value })}
/>
</div>
<div className="iop-field">
<label className="iop-field-label"></label>
<div className="iop-slider-row">
<input
className="iop-slider"
type="range"
min={1}
max={15}
step={0.5}
value={item.duration}
onChange={(e) => onChange({ duration: Number(e.target.value) })}
/>
<span className="iop-slider-value">{item.duration}s</span>
</div>
</div>
<div className="iop-field">
<label className="iop-field-label">{transitionLabel}</label>
<select
className="iop-select"
value={item.transition ?? "none"}
onChange={(e) => onChange({ transition: e.target.value as TransitionType })}
>
{TRANSITION_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
{item.transition && item.transition !== "none" && (
<div className="iop-field">
<label className="iop-field-label"></label>
<div className="iop-slider-row">
<input
className="iop-slider"
type="range"
min={0.3}
max={2.0}
step={0.1}
value={item.transition_duration ?? 0.5}
onChange={(e) => onChange({ transition_duration: Number(e.target.value) })}
/>
<span className="iop-slider-value">
{(item.transition_duration ?? 0.5).toFixed(1)}s
</span>
</div>
</div>
)}
</>
)}
</div>
)
}
export default IntroOutroBlock
@@ -1,281 +0,0 @@
/**
* 混剪单图层配置区
*/
import React from "react"
import type { PipLayer, PipAnimType, PipSlideDirection, PipGridPosition } from "@/pages/editing-planner/types"
import {
GRID_POSITIONS,
ANIM_OPTIONS,
SLIDE_DIR_OPTIONS,
LAYER_COLORS,
} from "@/pages/editing-planner/constants/pipConfig"
interface LayerConfigProps {
layer: PipLayer | null
layers: PipLayer[]
totalDuration: number
onUpdate: (id: string, partial: Partial<PipLayer>) => void
onGridClick: (pos: PipGridPosition) => void
onWidthChange: (val: number) => void
onHeightChange: (val: number) => void
}
const LayerConfig: React.FC<LayerConfigProps> = ({
layer,
layers,
totalDuration,
onUpdate,
onGridClick,
onWidthChange,
onHeightChange,
}) => {
if (!layer) {
return (
<div className="pip-config-area">
<div className="pip-config-empty"></div>
</div>
)
}
return (
<div className="pip-config-area">
{/* ── 迷你预览 ── */}
<div className="pip-preview-box">
{layers.map((l, idx) => (
<div
key={l.id}
className={`pip-preview-layer${layer.id === l.id ? " selected" : ""}`}
style={{
left: `${l.x}%`,
top: `${l.y}%`,
width: `${l.width}%`,
height: `${l.height}%`,
background: LAYER_COLORS[idx % LAYER_COLORS.length],
opacity: l.opacity / 100,
borderRadius: `${l.border_radius}%`,
}}
>
<span className="pip-preview-label">{l.name}</span>
</div>
))}
</div>
{/* ── 素材类型 ── */}
<div className="pip-field">
<label className="pip-field-label"></label>
<div className="pip-type-btns">
<button
className={`pip-type-btn${layer.material_type === "image" ? " active" : ""}`}
onClick={() => onUpdate(layer.id, { material_type: "image" })}
>
🖼
</button>
<button
className={`pip-type-btn${layer.material_type === "video" ? " active" : ""}`}
onClick={() => onUpdate(layer.id, { material_type: "video" })}
>
🎬
</button>
</div>
</div>
{/* ── 素材 URL ── */}
<div className="pip-field">
<label className="pip-field-label">
{layer.material_type === "image" ? "图片" : "视频"} URL
</label>
<input
className="pip-input"
type="text"
placeholder={
layer.material_type === "image"
? "https://example.com/image.png"
: "https://example.com/video.mp4"
}
value={layer.material_url}
onChange={(e) => onUpdate(layer.id, { material_url: e.target.value })}
/>
</div>
{/* ── 位置:九宫格 + 坐标 ── */}
<div className="pip-field">
<label className="pip-field-label"></label>
<div style={{ display: "flex", gap: 16, alignItems: "flex-start" }}>
<div className="pip-grid">
{GRID_POSITIONS.map((pos) => (
<button
key={pos}
className={`pip-grid-btn${layer.grid_position === pos ? " active" : ""}`}
onClick={() => onGridClick(pos)}
>
<span className="pip-grid-dot" />
</button>
))}
</div>
<div className="pip-field-row" style={{ flex: 1 }}>
<div>
<label className="pip-field-label">X (%)</label>
<input
className="pip-number"
type="number"
min={0}
max={100}
value={layer.x}
onChange={(e) => onUpdate(layer.id, { x: Number(e.target.value) })}
/>
</div>
<div>
<label className="pip-field-label">Y (%)</label>
<input
className="pip-number"
type="number"
min={0}
max={100}
value={layer.y}
onChange={(e) => onUpdate(layer.id, { y: Number(e.target.value) })}
/>
</div>
</div>
</div>
</div>
{/* ── 尺寸 ── */}
<div className="pip-field">
<label className="pip-field-label"></label>
<div className="pip-slider-row">
<span style={{ fontSize: 12, color: "#999", width: 20 }}></span>
<input
className="pip-slider"
type="range"
min={10}
max={80}
value={layer.width}
onChange={(e) => onWidthChange(Number(e.target.value))}
/>
<span className="pip-slider-value">{layer.width}%</span>
</div>
<div className="pip-slider-row" style={{ marginTop: 6 }}>
<span style={{ fontSize: 12, color: "#999", width: 20 }}></span>
<input
className="pip-slider"
type="range"
min={10}
max={80}
value={layer.height}
onChange={(e) => onHeightChange(Number(e.target.value))}
/>
<span className="pip-slider-value">{layer.height}%</span>
</div>
<div
className="pip-lock-row"
style={{ marginTop: 6 }}
onClick={() => onUpdate(layer.id, { aspect_lock: !layer.aspect_lock })}
>
<span className="pip-lock-icon">{layer.aspect_lock ? "🔒" : "🔓"}</span>
<span>{layer.aspect_lock ? "已锁定比例" : "锁定宽高比"}</span>
</div>
</div>
{/* ── 圆角 ── */}
<div className="pip-field">
<label className="pip-field-label"></label>
<div className="pip-slider-row">
<input
className="pip-slider"
type="range"
min={0}
max={50}
value={layer.border_radius}
onChange={(e) => onUpdate(layer.id, { border_radius: Number(e.target.value) })}
/>
<span className="pip-slider-value">{layer.border_radius}%</span>
</div>
</div>
{/* ── 透明度 ── */}
<div className="pip-field">
<label className="pip-field-label"></label>
<div className="pip-slider-row">
<input
className="pip-slider"
type="range"
min={0}
max={100}
value={layer.opacity}
onChange={(e) => onUpdate(layer.id, { opacity: Number(e.target.value) })}
/>
<span className="pip-slider-value">{layer.opacity}%</span>
</div>
</div>
{/* ── 时间 ── */}
<div className="pip-field">
<label className="pip-field-label"></label>
<div className="pip-field-row">
<div>
<label className="pip-field-label"> (s)</label>
<input
className="pip-number"
type="number"
min={0}
max={totalDuration || 999}
step={0.1}
value={layer.start_time}
onChange={(e) => onUpdate(layer.id, { start_time: Number(e.target.value) })}
/>
</div>
<div>
<label className="pip-field-label"> (s)</label>
<input
className="pip-number"
type="number"
min={0.1}
max={totalDuration || 999}
step={0.1}
value={layer.duration}
onChange={(e) => onUpdate(layer.id, { duration: Number(e.target.value) })}
/>
</div>
</div>
</div>
{/* ── 入场动画 ── */}
<div className="pip-field">
<label className="pip-field-label"></label>
<select
className="pip-select"
value={layer.animation}
onChange={(e) => onUpdate(layer.id, { animation: e.target.value as PipAnimType })}
>
{ANIM_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
{/* 滑入方向(仅 slide_in 时显示) */}
{layer.animation === "slide_in" && (
<div className="pip-field">
<label className="pip-field-label"></label>
<select
className="pip-select"
value={layer.slide_direction}
onChange={(e) =>
onUpdate(layer.id, { slide_direction: e.target.value as PipSlideDirection })
}
>
{SLIDE_DIR_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
)}
</div>
)
}
export default LayerConfig
@@ -1,65 +0,0 @@
/**
* 混剪图层列表
*/
import React from "react"
import type { PipLayer } from "@/pages/editing-planner/types"
import { LAYER_COLORS } from "@/pages/editing-planner/constants/pipConfig"
interface LayerListProps {
layers: PipLayer[]
selectedId: string
onSelect: (id: string) => void
onAdd: () => void
onDelete: (id: string) => void
}
const LayerList: React.FC<LayerListProps> = ({ layers, selectedId, onSelect, onAdd, onDelete }) => {
return (
<div className="pip-layer-list">
<div className="pip-toolbar" style={{ marginBottom: 8 }}>
<button className="pip-add-btn" onClick={onAdd}>
+
</button>
</div>
{layers.length === 0 ? (
<div className="pip-layer-empty"></div>
) : (
layers.map((layer, idx) => (
<div
key={layer.id}
className={`pip-layer-item${selectedId === layer.id ? " active" : ""}`}
onClick={() => onSelect(layer.id)}
>
{layer.thumbnail_url || layer.material_url ? (
<img
className="pip-layer-thumb"
src={layer.thumbnail_url || layer.material_url}
alt={layer.name}
/>
) : (
<div
className="pip-layer-thumb"
style={{
background: LAYER_COLORS[idx % LAYER_COLORS.length],
}}
/>
)}
<span className="pip-layer-name">{layer.name}</span>
<button
className="pip-layer-delete"
onClick={(e) => {
e.stopPropagation()
onDelete(layer.id)
}}
title="删除图层"
>
</button>
</div>
))
)}
</div>
)
}
export default LayerList
@@ -1,142 +0,0 @@
/**
* 贴纸素材库(emoji / 图片 / 文字花字)
*/
import React, { useState } from "react"
import type { StickerType, TextStickerPreset } from "@/pages/editing-planner/types"
import {
EMOJI_LIST,
STICKER_TYPE_TABS,
TEXT_PRESET_STYLES,
TEXT_STICKER_PRESET_LABELS,
} from "@/pages/editing-planner/constants/sticker"
interface StickerLibraryProps {
activeTab: StickerType
onTabChange: (tab: StickerType) => void
onAddSticker: (type: StickerType, content: string) => void
}
const StickerLibrary: React.FC<StickerLibraryProps> = ({
activeTab,
onTabChange,
onAddSticker,
}) => {
const [textInput, setTextInput] = useState("")
const imageInputRef = React.useRef<HTMLInputElement>(null)
const handleAddImage = () => {
const val = imageInputRef.current?.value.trim()
if (val) {
onAddSticker("image", val)
if (imageInputRef.current) imageInputRef.current.value = ""
}
}
const handleAddText = () => {
if (textInput.trim()) {
onAddSticker("text", textInput.trim())
setTextInput("")
}
}
return (
<>
{/* 类型 Tab */}
<div className="sticker-tabs">
{STICKER_TYPE_TABS.map((t) => (
<button
key={t.value}
className={`sticker-tab${activeTab === t.value ? " active" : ""}`}
onClick={() => onTabChange(t.value)}
>
{t.label}
</button>
))}
</div>
{/* Tab 内容区 */}
<div className="sticker-tab-content">
{/* Emoji 素材库 */}
{activeTab === "emoji" && (
<div className="sticker-emoji-grid">
{EMOJI_LIST.map((emoji) => (
<button
key={emoji}
className="sticker-emoji-btn"
onClick={() => onAddSticker("emoji", emoji)}
>
{emoji}
</button>
))}
</div>
)}
{/* 图片贴纸 */}
{activeTab === "image" && (
<div className="sticker-image-input">
<input
ref={imageInputRef}
type="text"
className="sticker-url-input"
placeholder="输入图片 URL 添加贴纸..."
onKeyDown={(e) => {
if (e.key === "Enter" && e.currentTarget.value.trim()) {
handleAddImage()
}
}}
/>
<button className="sticker-url-add-btn" onClick={handleAddImage}>
</button>
</div>
)}
{/* 文字花字 */}
{activeTab === "text" && (
<div className="sticker-text-section">
<div className="sticker-text-input-row">
<input
type="text"
className="sticker-text-input"
placeholder="输入文字内容..."
value={textInput}
onChange={(e) => setTextInput(e.target.value)}
/>
<button
className="sticker-text-add-btn"
disabled={!textInput.trim()}
onClick={handleAddText}
>
</button>
</div>
<div className="sticker-text-presets">
<div className="sticker-preset-title"></div>
<div className="sticker-preset-grid">
{(Object.keys(TEXT_STICKER_PRESET_LABELS) as TextStickerPreset[]).map((p) => (
<div
key={p}
className="sticker-preset-preview"
style={{
background:
p === "bubble"
? "rgba(0,0,0,0.5)"
: p === "gradient"
? "linear-gradient(90deg,#f093fb,#f5576c)"
: "#1a1a2e",
}}
>
<span style={TEXT_PRESET_STYLES[p]}></span>
<div className="sticker-preset-name">{TEXT_STICKER_PRESET_LABELS[p]}</div>
</div>
))}
</div>
</div>
</div>
)}
</div>
</>
)
}
export default StickerLibrary
@@ -1,54 +0,0 @@
/**
* 已添加贴纸列表
*/
import React from "react"
import type { StickerItem } from "@/pages/editing-planner/types"
interface StickerListProps {
items: StickerItem[]
selectedId: string | null
onSelect: (id: string) => void
onDelete: (id: string) => void
}
const StickerList: React.FC<StickerListProps> = ({ items, selectedId, onSelect, onDelete }) => {
if (items.length === 0) return null
const getDisplayContent = (item: StickerItem) => {
if (item.type === "emoji") return { icon: item.content, name: "表情贴纸" }
if (item.type === "text") return { icon: "T", name: item.content.slice(0, 10) }
return { icon: "🖼", name: "图片贴纸" }
}
return (
<div className="sticker-list-section">
<div className="sticker-section-title"> ({items.length})</div>
<div className="sticker-list">
{items.map((item) => {
const { icon, name } = getDisplayContent(item)
return (
<div
key={item.id}
className={`sticker-list-item${selectedId === item.id ? " active" : ""}`}
onClick={() => onSelect(item.id)}
>
<span className="sticker-list-icon">{icon}</span>
<span className="sticker-list-name">{name}</span>
<button
className="sticker-list-delete"
onClick={(e) => {
e.stopPropagation()
onDelete(item.id)
}}
>
</button>
</div>
)
})}
</div>
</div>
)
}
export default StickerList
@@ -1,200 +0,0 @@
/**
* 选中贴纸的属性编辑器
*/
import React from "react"
import type { StickerItem, TextStickerPreset } from "@/pages/editing-planner/types"
import {
TEXT_PRESET_STYLES,
TEXT_STICKER_PRESET_LABELS,
} from "@/pages/editing-planner/constants/sticker"
interface StickerPropsEditorProps {
sticker: StickerItem
totalDuration: number
onUpdate: (id: string, partial: Partial<StickerItem>) => void
}
const StickerPropsEditor: React.FC<StickerPropsEditorProps> = ({
sticker,
totalDuration,
onUpdate,
}) => {
return (
<div className="sticker-props-section">
<div className="sticker-section-title"></div>
{/* 位置 */}
<div className="sticker-prop-row">
<span className="sticker-prop-label"> X</span>
<input
type="range"
className="sticker-prop-slider"
min={0}
max={100}
value={sticker.x}
onChange={(e) => onUpdate(sticker.id, { x: Number(e.target.value) })}
/>
<span className="sticker-prop-value">{sticker.x}%</span>
</div>
<div className="sticker-prop-row">
<span className="sticker-prop-label"> Y</span>
<input
type="range"
className="sticker-prop-slider"
min={0}
max={100}
value={sticker.y}
onChange={(e) => onUpdate(sticker.id, { y: Number(e.target.value) })}
/>
<span className="sticker-prop-value">{sticker.y}%</span>
</div>
{/* 尺寸 */}
<div className="sticker-prop-row">
<span className="sticker-prop-label"></span>
<input
type="range"
className="sticker-prop-slider"
min={5}
max={50}
value={sticker.width}
onChange={(e) =>
onUpdate(sticker.id, {
width: Number(e.target.value),
height: Number(e.target.value),
})
}
/>
<span className="sticker-prop-value">{sticker.width}%</span>
</div>
{/* 旋转 */}
<div className="sticker-prop-row">
<span className="sticker-prop-label"></span>
<input
type="range"
className="sticker-prop-slider"
min={-180}
max={180}
value={sticker.rotation}
onChange={(e) => onUpdate(sticker.id, { rotation: Number(e.target.value) })}
/>
<span className="sticker-prop-value">{sticker.rotation}°</span>
</div>
{/* 透明度 */}
<div className="sticker-prop-row">
<span className="sticker-prop-label"></span>
<input
type="range"
className="sticker-prop-slider"
min={0}
max={100}
value={sticker.opacity}
onChange={(e) => onUpdate(sticker.id, { opacity: Number(e.target.value) })}
/>
<span className="sticker-prop-value">{sticker.opacity}%</span>
</div>
{/* 时间 */}
<div className="sticker-prop-row">
<span className="sticker-prop-label"></span>
<input
type="number"
className="sticker-prop-number"
min={0}
max={totalDuration}
step={0.1}
value={sticker.start_time}
onChange={(e) => onUpdate(sticker.id, { start_time: Number(e.target.value) })}
/>
<span className="sticker-prop-label"></span>
<input
type="number"
className="sticker-prop-number"
min={0}
max={totalDuration}
step={0.1}
value={sticker.duration}
onChange={(e) => onUpdate(sticker.id, { duration: Number(e.target.value) })}
/>
</div>
{/* 文字贴纸特有属性 */}
{sticker.type === "text" && (
<>
<div className="sticker-prop-row">
<span className="sticker-prop-label"></span>
<select
className="sticker-prop-select"
value={sticker.text_preset}
onChange={(e) =>
onUpdate(sticker.id, { text_preset: e.target.value as TextStickerPreset })
}
>
{(Object.keys(TEXT_STICKER_PRESET_LABELS) as TextStickerPreset[]).map((p) => (
<option key={p} value={p}>
{TEXT_STICKER_PRESET_LABELS[p]}
</option>
))}
</select>
</div>
<div className="sticker-prop-row">
<span className="sticker-prop-label"></span>
<input
type="range"
className="sticker-prop-slider"
min={12}
max={72}
value={sticker.font_size}
onChange={(e) => onUpdate(sticker.id, { font_size: Number(e.target.value) })}
/>
<span className="sticker-prop-value">{sticker.font_size}px</span>
</div>
<div className="sticker-prop-row">
<span className="sticker-prop-label"></span>
<input
type="color"
className="sticker-prop-color"
value={sticker.text_color}
onChange={(e) => onUpdate(sticker.id, { text_color: e.target.value })}
/>
</div>
</>
)}
{/* 预览 */}
<div className="sticker-preview-box">
<div
className="sticker-preview-item"
style={{
left: `${sticker.x}%`,
top: `${sticker.y}%`,
width: `${sticker.width}%`,
height: `${sticker.width}%`,
transform: `translate(-50%, -50%) rotate(${sticker.rotation}deg)`,
opacity: sticker.opacity / 100,
fontSize: sticker.type === "text" ? `${sticker.font_size}px` : undefined,
...TEXT_PRESET_STYLES[sticker.text_preset],
}}
>
{sticker.type === "emoji" && sticker.content}
{sticker.type === "text" && sticker.content}
{sticker.type === "image" && (
<img
src={sticker.content}
alt="sticker"
style={{
width: "100%",
height: "100%",
objectFit: "contain",
}}
/>
)}
</div>
</div>
</div>
)
}
export default StickerPropsEditor
@@ -1,37 +0,0 @@
/**
* 字幕预览组件
*/
import React from "react"
import type { SubtitleStyleConfig } from "../../types/subtitle"
interface SubtitlePreviewProps {
config: SubtitleStyleConfig
previewText?: string
}
const SubtitlePreview: React.FC<SubtitlePreviewProps> = ({
config,
previewText = "这是一段字幕预览",
}) => {
return (
<div className="sub-field">
<label className="sub-label"></label>
<div className="sub-preview-box">
<span
className="sub-preview-text"
style={{
fontSize: `${Math.min(config.fontSize, 28)}px`,
color: config.fontColor,
fontFamily: config.font,
WebkitTextStroke: config.stroke ? "1px #000" : undefined,
textShadow: config.shadow ? "2px 2px 4px rgba(0,0,0,0.8)" : undefined,
}}
>
{previewText}
</span>
</div>
</div>
)
}
export default SubtitlePreview
@@ -1,5 +1,5 @@
import React from "react"
import type { ClipData } from "../../types"
import type { ClipData, TrimConfig } from "../../types"
import { TRANSITION_OPTIONS } from "@/api/template-editor"
import { CLIP_TYPE_ICONS, CLIP_TYPE_LABELS, MIN_CLIP_WIDTH } from "../../constants/timeline"
@@ -40,7 +40,10 @@ export const ContextMenu: React.FC<ContextMenuProps> = ({
</div>
)}
<div className="ep-context-menu-divider" />
<div className="ep-context-menu-item ep-context-menu-item-danger" onClick={onDelete}>
<div
className="ep-context-menu-item ep-context-menu-item-danger"
onClick={onDelete}
>
<span className="ep-context-menu-icon">🗑</span>
<span></span>
</div>
@@ -1,62 +0,0 @@
/**
* TTS 滑块组件(语速/语调/音量)
*/
import React from "react"
import { Slider } from "antd"
interface TtsSliderProps {
label: string
value: number
min: number
max: number
step: number
unit?: string
onChange: (val: number) => void
marks?: string[]
tooltipFormatter?: (v: number) => string
}
const TtsSlider: React.FC<TtsSliderProps> = ({
label,
value,
min,
max,
step,
unit = "",
onChange,
marks,
tooltipFormatter,
}) => {
const displayValue =
unit === "x"
? `${value.toFixed(2)}x`
: label === "语调"
? `${value > 0 ? "+" : ""}${value} 半音`
: `${value}${unit}`
return (
<div className="tts-slider-section">
<div className="tts-slider-header">
<span className="tts-slider-label">{label}</span>
<span className="tts-slider-value">{displayValue}</span>
</div>
<Slider
min={min}
max={max}
step={step}
value={value}
onChange={(v) => onChange(v as number)}
tooltip={tooltipFormatter ? { formatter: (v) => tooltipFormatter(v as number) } : undefined}
/>
{marks && (
<div className="tts-slider-marks">
{marks.map((m, i) => (
<span key={i}>{m}</span>
))}
</div>
)}
</div>
)
}
export default TtsSlider
@@ -1,50 +0,0 @@
/**
* TTS 音色选择组件
*/
import React from "react"
import type { TTSVoice } from "@/api/tts"
import { VOICE_CATEGORY_MAP } from "../../constants/tts"
interface VoiceSelectorProps {
voices: TTSVoice[]
voicesLoading: boolean
selectedVoiceId: string
onVoiceSelect: (voiceId: string) => void
}
const VoiceSelector: React.FC<VoiceSelectorProps> = ({
voices,
voicesLoading,
selectedVoiceId,
onVoiceSelect,
}) => {
const voiceCategories = Object.entries(VOICE_CATEGORY_MAP)
return (
<div className="tts-voice-section">
<div className="tts-voice-label">
{voicesLoading && <span className="tts-voice-loading">...</span>}
</div>
<div className="tts-voice-grid">
{voiceCategories.map(([cat, info]) => {
const voice = voices.find((v) => v.category === cat)
const isSelected = voice && selectedVoiceId === voice.id
return (
<button
key={cat}
className={`tts-voice-card${isSelected ? " active" : ""}`}
onClick={() => voice && onVoiceSelect(voice.id)}
disabled={!voice || voicesLoading}
>
<span className="tts-voice-card-icon">{info.icon}</span>
<span className="tts-voice-card-name">{voice?.name || info.label}</span>
</button>
)
})}
</div>
</div>
)
}
export default VoiceSelector
@@ -1,78 +0,0 @@
/**
* 图片水印配置组件
*/
import React from "react"
interface ImageWatermarkSectionProps {
imageUrl: string
localImageUrl: string
width: number | undefined
height: number | undefined
onImageUrlChange: (url: string) => void
onWidthChange: (val: number) => void
onHeightChange: (val: number) => void
}
const ImageWatermarkSection: React.FC<ImageWatermarkSectionProps> = ({
imageUrl,
localImageUrl,
width,
height,
onImageUrlChange,
onWidthChange,
onHeightChange,
}) => {
const displayUrl = localImageUrl || imageUrl || ""
return (
<div className="wp-section">
<div className="wp-field">
<label className="wp-field-label"> URL</label>
<input
className="wp-input"
type="text"
placeholder="https://example.com/logo.png"
value={displayUrl}
onChange={(e) => onImageUrlChange(e.target.value)}
/>
{displayUrl && (
<div className="wp-image-preview">
<img
src={displayUrl}
alt="水印预览"
onError={(e) => {
;(e.target as HTMLImageElement).style.display = "none"
}}
/>
</div>
)}
</div>
<div className="wp-field">
<label className="wp-field-label">0 </label>
<input
className="wp-input"
type="number"
min={0}
max={2000}
value={width ?? 0}
onChange={(e) => onWidthChange(Number(e.target.value))}
/>
</div>
<div className="wp-field">
<label className="wp-field-label">0 </label>
<input
className="wp-input"
type="number"
min={0}
max={2000}
value={height ?? 0}
onChange={(e) => onHeightChange(Number(e.target.value))}
/>
</div>
</div>
)
}
export default ImageWatermarkSection
@@ -1,107 +0,0 @@
/**
* 滚动水印配置组件
*/
import React from "react"
import type { ScrollDirection } from "../../types"
import { SCROLL_DIRECTION_OPTIONS } from "../../constants/watermark"
interface ScrollWatermarkSectionProps {
text: string | undefined
scrollDirection: ScrollDirection | undefined
scrollSpeed: number | undefined
fontSize: number | undefined
color: string | undefined
onTextChange: (text: string) => void
onScrollDirectionChange: (dir: ScrollDirection) => void
onScrollSpeedChange: (speed: number) => void
onFontSizeChange: (size: number) => void
onColorChange: (color: string) => void
}
const ScrollWatermarkSection: React.FC<ScrollWatermarkSectionProps> = ({
text,
scrollDirection,
scrollSpeed,
fontSize,
color,
onTextChange,
onScrollDirectionChange,
onScrollSpeedChange,
onFontSizeChange,
onColorChange,
}) => {
return (
<div className="wp-section">
<div className="wp-field">
<label className="wp-field-label"></label>
<textarea
className="wp-textarea"
placeholder="输入滚动水印文字"
rows={2}
value={text ?? ""}
onChange={(e) => onTextChange(e.target.value)}
/>
</div>
<div className="wp-field">
<label className="wp-field-label"></label>
<select
className="wp-select"
value={scrollDirection ?? "horizontal"}
onChange={(e) => onScrollDirectionChange(e.target.value as ScrollDirection)}
>
{SCROLL_DIRECTION_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
<div className="wp-field">
<label className="wp-field-label"></label>
<div className="wp-slider-row">
<input
className="wp-slider"
type="range"
min={10}
max={200}
value={scrollSpeed ?? 50}
onChange={(e) => onScrollSpeedChange(Number(e.target.value))}
/>
<span className="wp-slider-value">{scrollSpeed ?? 50}px/s</span>
</div>
</div>
<div className="wp-field">
<label className="wp-field-label"></label>
<div className="wp-slider-row">
<input
className="wp-slider"
type="range"
min={12}
max={72}
value={fontSize ?? 24}
onChange={(e) => onFontSizeChange(Number(e.target.value))}
/>
<span className="wp-slider-value">{fontSize ?? 24}px</span>
</div>
</div>
<div className="wp-field">
<label className="wp-field-label"></label>
<div className="wp-color-row">
<input
className="wp-color-input"
type="color"
value={color ?? "#ffffff"}
onChange={(e) => onColorChange(e.target.value)}
/>
<span className="wp-color-value">{color ?? "#ffffff"}</span>
</div>
</div>
</div>
)
}
export default ScrollWatermarkSection
@@ -1,67 +0,0 @@
/**
* 文字水印配置组件
*/
import React from "react"
interface TextWatermarkSectionProps {
text: string | undefined
fontSize: number | undefined
color: string | undefined
onTextChange: (text: string) => void
onFontSizeChange: (size: number) => void
onColorChange: (color: string) => void
}
const TextWatermarkSection: React.FC<TextWatermarkSectionProps> = ({
text,
fontSize,
color,
onTextChange,
onFontSizeChange,
onColorChange,
}) => {
return (
<div className="wp-section">
<div className="wp-field">
<label className="wp-field-label"></label>
<textarea
className="wp-textarea"
placeholder="输入水印文字内容"
rows={3}
value={text ?? ""}
onChange={(e) => onTextChange(e.target.value)}
/>
</div>
<div className="wp-field">
<label className="wp-field-label"></label>
<div className="wp-slider-row">
<input
className="wp-slider"
type="range"
min={12}
max={72}
value={fontSize ?? 24}
onChange={(e) => onFontSizeChange(Number(e.target.value))}
/>
<span className="wp-slider-value">{fontSize ?? 24}px</span>
</div>
</div>
<div className="wp-field">
<label className="wp-field-label"></label>
<div className="wp-color-row">
<input
className="wp-color-input"
type="color"
value={color ?? "#ffffff"}
onChange={(e) => onColorChange(e.target.value)}
/>
<span className="wp-color-value">{color ?? "#ffffff"}</span>
</div>
</div>
</div>
)
}
export default TextWatermarkSection
@@ -1,60 +0,0 @@
/**
* 水印通用设置组件(位置 + 透明度)
*/
import React from "react"
import type { WatermarkPosition } from "../../types"
import { POSITION_OPTIONS } from "../../constants/watermark"
interface WatermarkCommonSectionProps {
position: WatermarkPosition
opacity: number
onPositionChange: (pos: WatermarkPosition) => void
onOpacityChange: (opacity: number) => void
}
const WatermarkCommonSection: React.FC<WatermarkCommonSectionProps> = ({
position,
opacity,
onPositionChange,
onOpacityChange,
}) => {
return (
<div className="wp-section wp-common-section">
<div className="wp-section-divider" />
<div className="wp-common-title"></div>
<div className="wp-field">
<label className="wp-field-label"></label>
<select
className="wp-select"
value={position}
onChange={(e) => onPositionChange(e.target.value as WatermarkPosition)}
>
{POSITION_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
<div className="wp-field">
<label className="wp-field-label"></label>
<div className="wp-slider-row">
<input
className="wp-slider"
type="range"
min={0}
max={1}
step={0.05}
value={opacity}
onChange={(e) => onOpacityChange(Number(e.target.value))}
/>
<span className="wp-slider-value">{Math.round(opacity * 100)}%</span>
</div>
</div>
</div>
)
}
export default WatermarkCommonSection
@@ -1,30 +0,0 @@
/**
* 水印类型 Tab 组件
*/
import React from "react"
import type { WatermarkType } from "../../types"
import { WATERMARK_TABS } from "../../constants/watermark"
interface WatermarkTypeTabsProps {
activeTab: WatermarkType
onTypeChange: (type: WatermarkType) => void
}
const WatermarkTypeTabs: React.FC<WatermarkTypeTabsProps> = ({ activeTab, onTypeChange }) => {
return (
<div className="wp-tabs">
{WATERMARK_TABS.map((tab) => (
<button
key={tab.key}
className={`wp-tab${activeTab === tab.key ? " active" : ""}`}
onClick={() => onTypeChange(tab.key)}
>
<span className="wp-tab-icon">{tab.icon}</span>
<span className="wp-tab-label">{tab.label}</span>
</button>
))}
</div>
)
}
export default WatermarkTypeTabs
@@ -1,34 +0,0 @@
/**
* ClipPropertiesPanel 常量定义
*/
import type { ClipType } from "@/pages/editing-planner/types"
export const POSITION_OPTIONS = [
{ value: "top", label: "顶部" },
{ value: "center", label: "居中" },
{ value: "bottom", label: "底部" },
]
export const FONT_OPTIONS = [
"思源黑体",
"思源宋体",
"苹方",
"PingFang",
"微软雅黑",
"楷体",
"华康俪金黑",
]
export const ANIMATION_OPTIONS = [
{ value: "none", label: "无" },
{ value: "fade", label: "淡入淡出" },
{ value: "slide", label: "滑动" },
{ value: "typewriter", label: "打字机" },
]
/** 片段类型图标/标签 */
export const CLIP_TYPE_ICONS: Record<ClipType, string> = { voice: "🎙️", pip: "🖼️" }
export const CLIP_TYPE_LABELS: Record<ClipType, string> = {
voice: "口播",
pip: "混剪",
}
@@ -1,42 +0,0 @@
/**
* FilterPanel 相关常量
*/
import type { FilterPreset } from "../types"
/** 所有预设列表 */
export const PRESET_LIST: FilterPreset[] = [
"none",
"original",
"fresh",
"warm",
"cool",
"vintage",
"cinema",
"bw",
"sunshine",
"film",
]
/** 预设对应的示例渐变色(用于视觉预览) */
export const PRESET_GRADIENTS: Record<FilterPreset, string> = {
none: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
original: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
fresh: "linear-gradient(135deg, #a8edea 0%, #fed6e3 100%)",
warm: "linear-gradient(135deg, #f093fb 0%, #f5576c 100%)",
cool: "linear-gradient(135deg, #4facfe 0%, #00f2fe 100%)",
vintage: "linear-gradient(135deg, #c79081 0%, #dfa579 100%)",
cinema: "linear-gradient(135deg, #2c3e50 0%, #4ca1af 100%)",
bw: "linear-gradient(135deg, #434343 0%, #000000 100%)",
sunshine: "linear-gradient(135deg, #f6d365 0%, #fda085 100%)",
film: "linear-gradient(135deg, #8e9eab 0%, #eef2f3 100%)",
}
/** 手动调节项配置 */
export const MANUAL_ADJUST_ITEMS = [
{ key: "brightness", label: "亮度", min: -100, max: 100 },
{ key: "contrast", label: "对比度", min: -100, max: 100 },
{ key: "saturation", label: "饱和度", min: -100, max: 100 },
{ key: "temperature", label: "色温", min: -100, max: 100 },
{ key: "tint", label: "色调", min: -100, max: 100 },
{ key: "sharpness", label: "锐度", min: 0, max: 100 },
] as const
@@ -1,10 +0,0 @@
/**
* IntroOutroPanel 相关常量
*/
import type { IntroOutroKind } from "../types"
export const KIND_OPTIONS: { value: IntroOutroKind; label: string; icon: string }[] = [
{ value: "none", label: "无", icon: "🚫" },
{ value: "video", label: "视频", icon: "🎬" },
{ value: "image", label: "图片", icon: "🖼️" },
]
@@ -1,55 +0,0 @@
/**
* PipConfigPanel 常量定义
*/
import type { PipGridPosition, PipAnimType, PipSlideDirection } from "@/pages/editing-planner/types"
/** 九宫格位置 → 百分比坐标映射 */
export const GRID_POSITION_MAP: Record<PipGridPosition, { x: number; y: number }> = {
top_left: { x: 5, y: 5 },
top_center: { x: 37.5, y: 5 },
top_right: { x: 70, y: 5 },
center_left: { x: 5, y: 37.5 },
center: { x: 37.5, y: 37.5 },
center_right: { x: 70, y: 37.5 },
bottom_left: { x: 5, y: 70 },
bottom_center: { x: 37.5, y: 70 },
bottom_right: { x: 70, y: 70 },
}
/** 九宫格位置选项 */
export const GRID_POSITIONS: PipGridPosition[] = [
"top_left",
"top_center",
"top_right",
"center_left",
"center",
"center_right",
"bottom_left",
"bottom_center",
"bottom_right",
]
/** 入场动画选项 */
export const ANIM_OPTIONS: { value: PipAnimType; label: string }[] = [
{ value: "none", label: "无" },
{ value: "fade_in", label: "淡入" },
{ value: "slide_in", label: "滑入" },
]
/** 滑入方向选项 */
export const SLIDE_DIR_OPTIONS: { value: PipSlideDirection; label: string }[] = [
{ value: "left", label: "← 左" },
{ value: "right", label: "→ 右" },
{ value: "up", label: "↑ 上" },
{ value: "down", label: "↓ 下" },
]
/** 预览图层颜色池 */
export const LAYER_COLORS = [
"rgba(22,119,255,0.5)",
"rgba(82,196,26,0.5)",
"rgba(250,173,20,0.5)",
"rgba(255,77,79,0.5)",
"rgba(114,46,209,0.5)",
"rgba(19,194,194,0.5)",
]
@@ -1,64 +0,0 @@
/**
* StickerPanel 常量定义
*/
import type { TextStickerPreset } from "@/pages/editing-planner/types"
import { TEXT_STICKER_PRESET_LABELS } from "@/pages/editing-planner/types"
export { TEXT_STICKER_PRESET_LABELS }
/** 常用 emoji 素材 */
export const EMOJI_LIST = [
"😀",
"😂",
"🥰",
"😎",
"🤩",
"😱",
"🤔",
"😴",
"🥳",
"😍",
"❤️",
"🔥",
"⭐",
"✨",
"💯",
"👍",
"👏",
"🎉",
"🎵",
"💪",
"📌",
"💡",
"🎯",
"✅",
"❌",
"⬆️",
"⬇️",
"➡️",
"⭕",
"🔔",
]
/** 贴纸类型 Tab */
export const STICKER_TYPE_TABS = [
{ value: "emoji" as const, label: "表情贴纸" },
{ value: "image" as const, label: "图片贴纸" },
{ value: "text" as const, label: "文字花字" },
]
/** 文字花字预设对应的 CSS 样式预览 */
export const TEXT_PRESET_STYLES: Record<TextStickerPreset, React.CSSProperties> = {
normal: { color: "#fff", textShadow: "none" },
highlight: { color: "#FFD700", textShadow: "0 0 8px rgba(255,215,0,0.6)" },
bubble: { color: "#fff", background: "rgba(0,0,0,0.5)", borderRadius: 8 },
neon: { color: "#0ff", textShadow: "0 0 6px #0ff, 0 0 12px #0ff" },
shadow: { color: "#fff", textShadow: "2px 2px 4px rgba(0,0,0,0.8)" },
outline: { color: "#fff", WebkitTextStroke: "1px #000" },
gradient: {
color: "transparent",
background: "linear-gradient(90deg,#f093fb,#f5576c)",
WebkitBackgroundClip: "text",
},
handwrite: { color: "#333", fontStyle: "italic", fontFamily: "cursive" },
}
@@ -1,30 +0,0 @@
/**
* SubtitleStylePanel 相关常量
*/
export const POSITION_OPTIONS = [
{ value: "top", label: "顶部" },
{ value: "center", label: "居中" },
{ value: "bottom", label: "底部" },
]
export const FONT_OPTIONS = [
"思源黑体",
"思源宋体",
"苹方",
"PingFang",
"微软雅黑",
"楷体",
"华康俪金黑",
]
export const ANIMATION_OPTIONS = [
{ value: "none", label: "无" },
{ value: "fade", label: "淡入淡出" },
{ value: "slide", label: "滑动" },
{ value: "typewriter", label: "打字机" },
]
export const ASR_LANGUAGE_OPTIONS = [
{ value: "zh", label: "中文" },
{ value: "en", label: "English" },
]
@@ -1,19 +0,0 @@
/**
* TtsPanel 相关常量
*/
import type { TtsMode } from "../types"
export const VOICE_CATEGORY_MAP: Record<string, { icon: string; label: string }> = {
male: { icon: "👨", label: "男声" },
female: { icon: "👩", label: "女声" },
young: { icon: "🧑", label: "少年" },
service: { icon: "🎧", label: "客服" },
news: { icon: "📰", label: "新闻" },
emotion: { icon: "🎭", label: "情感" },
}
export const TTS_MODE_OPTIONS: { mode: TtsMode; icon: string; label: string }[] = [
{ mode: "none", icon: "🔇", label: "无配音" },
{ mode: "upload", icon: "📁", label: "上传配音" },
{ mode: "tts", icon: "🤖", label: "TTS 合成" },
]
@@ -1,25 +0,0 @@
/**
* WatermarkPanel 相关常量
*/
import type { WatermarkType, WatermarkPosition, ScrollDirection } from "../types"
export const WATERMARK_TABS: { key: WatermarkType; label: string; icon: string }[] = [
{ key: "none", label: "无水印", icon: "🚫" },
{ key: "image", label: "图片水印", icon: "🖼️" },
{ key: "text", label: "文字水印", icon: "📝" },
{ key: "scroll", label: "滚动水印", icon: "📜" },
]
export const POSITION_OPTIONS: { value: WatermarkPosition; label: string }[] = [
{ value: "top_left", label: "左上角" },
{ value: "top_right", label: "右上角" },
{ value: "bottom_left", label: "左下角" },
{ value: "bottom_right", label: "右下角" },
{ value: "center", label: "居中" },
]
export const SCROLL_DIRECTION_OPTIONS: { value: ScrollDirection; label: string }[] = [
{ value: "horizontal", label: "水平滚动" },
{ value: "vertical", label: "垂直滚动" },
{ value: "diagonal", label: "对角滚动" },
]
@@ -0,0 +1,64 @@
import { useState, useCallback } from "react"
/**
* 片段拖拽排序 Hook
* 支持 HTML5 原生拖拽,实时高亮拖拽位置
*/
export const useClipDrag = (
onClipReorder: (fromIdx: number, toIdx: number) => void,
disabled?: boolean,
) => {
const [dragIdx, setDragIdx] = useState<number | null>(null)
const [dragOverIdx, setDragOverIdx] = useState<number | null>(null)
const handleDragStart = useCallback(
(e: React.DragEvent, idx: number) => {
if (disabled) return
setDragIdx(idx)
e.dataTransfer.setData("application/x-clip-drag", String(idx))
e.dataTransfer.effectAllowed = "move"
},
[disabled],
)
const handleDragOver = useCallback((e: React.DragEvent, idx: number) => {
e.preventDefault()
e.dataTransfer.dropEffect = "move"
setDragOverIdx(idx)
}, [])
const handleDragEnd = useCallback(() => {
setDragIdx(null)
setDragOverIdx(null)
}, [])
const handleDrop = useCallback(
(e: React.DragEvent, toIdx: number) => {
e.preventDefault()
setDragOverIdx(null)
const fromStr = e.dataTransfer.getData("application/x-clip-drag")
if (fromStr !== "") {
const fromIdx = Number(fromStr)
if (fromIdx !== toIdx) {
onClipReorder(fromIdx, toIdx)
}
}
setDragIdx(null)
},
[onClipReorder],
)
const handleEmptyDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault()
}, [])
return {
dragIdx,
dragOverIdx,
handleDragStart,
handleDragOver,
handleDragEnd,
handleDrop,
handleEmptyDragOver,
}
}
@@ -1,129 +0,0 @@
/**
* 混剪图层管理 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,
}
}
@@ -1,83 +0,0 @@
/**
* 贴纸项管理 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,202 @@
import { useState, useRef, useCallback, useEffect, useMemo, useLayoutEffect } from "react"
import type { ClipData, ClipType, TrimConfig } from "../types"
import {
DEFAULT_ADD_DURATION,
MIN_ADD_DURATION,
MAX_ADD_DURATION,
ADD_PICKER_WIDTH,
TRACK_GAP,
} from "../constants/timeline"
interface ContextMenuState {
x: number
y: number
clipId: string
}
/**
* 时间线菜单 Hook
* 管理右键菜单和添加片段面板的状态与交互
*/
export const useTimelineMenus = (
clips: ClipData[],
currentMode: string,
onAddClip: (type: ClipType, duration: number) => void,
onClipSplit?: (clipId: string, splitRatio: number) => void,
onClipResetTrim?: (clipId: string) => void,
onClipRemove?: (clipId: string) => void,
) => {
/* ── 右键菜单 ── */
const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null)
const contextMenuRef = useRef<HTMLDivElement>(null)
/* ── 添加片段面板 ── */
const [showAddPicker, setShowAddPicker] = useState(false)
const pickerRef = useRef<HTMLDivElement>(null)
const addCardRef = useRef<HTMLDivElement>(null)
const [pickerPos, setPickerPos] = useState<{ top: number; right: number }>({ top: 0, right: 0 })
/* ── 悬停的片段 ID(显示裁剪手柄) ── */
const [hoveredClipId, setHoveredClipId] = useState<string | null>(null)
/* ── 根据模式决定可选类型 ── */
const availableTypes: ClipType[] = useMemo(
() =>
currentMode === "voice_over"
? ["voice"]
: currentMode === "pip"
? ["pip"]
: ["voice", "pip"],
[currentMode],
)
/* ── 默认添加类型:跟随模式 ── */
const defaultAddType: ClipType = useMemo(() => {
if (currentMode === "voice_over") return "voice"
if (currentMode === "pip") return "pip"
return "voice"
}, [currentMode])
/* ── "+" 卡片:类型+时长选择状态 ── */
const [addType, setAddType] = useState<ClipType>(defaultAddType)
const [addDuration, setAddDuration] = useState<number>(DEFAULT_ADD_DURATION)
/* ── 模式切换时自动同步默认添加类型 ── */
useEffect(() => {
if (!availableTypes.includes(addType)) {
setAddType(defaultAddType)
}
}, [currentMode, addType, availableTypes, defaultAddType])
/* ── 计算 picker 初始位置 ── */
const updatePickerPosition = useCallback(() => {
if (!addCardRef.current) return
const rect = addCardRef.current.getBoundingClientRect()
const vw = window.innerWidth
const roughHeight = 180
let top = rect.top - TRACK_GAP - roughHeight
if (top < 8) top = 8
let right = vw - rect.right
if (rect.right - ADD_PICKER_WIDTH < 8) {
right = vw - ADD_PICKER_WIDTH - 8
}
setPickerPos({ top, right })
}, [])
const handleTogglePicker = useCallback(() => {
if (!showAddPicker) {
setAddType(defaultAddType)
updatePickerPosition()
}
setShowAddPicker((v) => !v)
}, [showAddPicker, defaultAddType, updatePickerPosition])
/* ── 渲染后精确边界校正 ── */
useLayoutEffect(() => {
if (!showAddPicker || !pickerRef.current || !addCardRef.current) return
const pickerEl = pickerRef.current
const addRect = addCardRef.current.getBoundingClientRect()
const pickerH = pickerEl.offsetHeight
const vh = window.innerHeight
const vw = window.innerWidth
let top = addRect.top - TRACK_GAP - pickerH
if (top < 8) {
top = addRect.bottom + TRACK_GAP
if (top + pickerH > vh - 8) {
top = vh - 8 - pickerH
if (top < 8) top = 8
}
}
let right = vw - addRect.right
const pickerRect = pickerEl.getBoundingClientRect()
if (pickerRect.left < 8) {
right = vw - ADD_PICKER_WIDTH - 8
}
setPickerPos({ top, right })
}, [showAddPicker])
/* ── 点击外部关闭添加面板 ── */
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (pickerRef.current && !pickerRef.current.contains(e.target as Node)) {
setShowAddPicker(false)
}
}
if (showAddPicker) {
document.addEventListener("mousedown", handleClickOutside)
}
return () => document.removeEventListener("mousedown", handleClickOutside)
}, [showAddPicker])
/* ── 点击外部关闭右键菜单 ── */
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (contextMenuRef.current && !contextMenuRef.current.contains(e.target as Node)) {
setContextMenu(null)
}
}
if (contextMenu) {
document.addEventListener("mousedown", handleClickOutside)
}
return () => document.removeEventListener("mousedown", handleClickOutside)
}, [contextMenu])
/* ── 确认添加片段 ── */
const handleConfirmAdd = useCallback(() => {
onAddClip(addType, addDuration)
setShowAddPicker(false)
}, [onAddClip, addType, addDuration])
/* ── 右键菜单操作 ── */
const handleContextMenu = useCallback((e: React.MouseEvent, clipId: string) => {
e.preventDefault()
e.stopPropagation()
setContextMenu({ x: e.clientX, y: e.clientY, clipId })
}, [])
const handleContextSplit = useCallback(() => {
if (!contextMenu) return
onClipSplit?.(contextMenu.clipId, 0.5)
setContextMenu(null)
}, [contextMenu, onClipSplit])
const handleContextResetTrim = useCallback(() => {
if (!contextMenu) return
onClipResetTrim?.(contextMenu.clipId)
setContextMenu(null)
}, [contextMenu, onClipResetTrim])
const handleContextDelete = useCallback(() => {
if (!contextMenu) return
onClipRemove?.(contextMenu.clipId)
setContextMenu(null)
}, [contextMenu, onClipRemove])
return {
// 右键菜单
contextMenu,
contextMenuRef,
handleContextMenu,
handleContextSplit,
handleContextResetTrim,
handleContextDelete,
// 添加面板
showAddPicker,
pickerRef,
addCardRef,
pickerPos,
availableTypes,
addType,
addDuration,
setAddType,
setAddDuration,
handleTogglePicker,
handleConfirmAdd,
// 悬停状态
hoveredClipId,
setHoveredClipId,
}
}
@@ -0,0 +1,118 @@
import { useState, useCallback, useEffect } from "react"
import type { ClipData, TrimConfig } from "../types"
interface TrimDragState {
clipId: string
direction: "left" | "right"
startX: number
originalTrim: TrimConfig
originalDuration: number
}
interface TrimPreviewState {
clipId: string
startTime: number
endTime: number
duration: number
x: number
y: number
}
const MIN_TRIM_DURATION = 1
/**
* 裁剪拖拽 Hook
* 拖拽片段两端手柄调整入点/出点,实时显示预览
*/
export const useTrimDrag = (
clips: ClipData[],
pps: number,
onClipTrim?: (clipId: string, trimConfig: TrimConfig, newDuration: number) => void,
) => {
const [trimDrag, setTrimDrag] = useState<TrimDragState | null>(null)
const [trimPreview, setTrimPreview] = useState<TrimPreviewState | null>(null)
const handleTrimHandleMouseDown = useCallback(
(e: React.MouseEvent, clipId: string, direction: "left" | "right") => {
e.preventDefault()
e.stopPropagation()
const clip = clips.find((c) => c.id === clipId)
if (!clip) return
const trim: TrimConfig = clip.trim_config ?? {
start_time: 0,
end_time: clip.duration,
original_duration: clip.duration,
}
setTrimDrag({
clipId,
direction,
startX: e.clientX,
originalTrim: { ...trim },
originalDuration: clip.duration,
})
},
[clips],
)
/* ── 裁剪拖拽全局 mousemove/mouseup ── */
useEffect(() => {
if (!trimDrag) return
const handleMouseMove = (e: MouseEvent) => {
const dx = e.clientX - trimDrag.startX
const dtSec = dx / pps
const clip = clips.find((c) => c.id === trimDrag.clipId)
if (!clip) return
const origTrim = trimDrag.originalTrim
const origDur = origTrim.original_duration ?? trimDrag.originalDuration
let newStart = origTrim.start_time
let newEnd = origTrim.end_time
if (trimDrag.direction === "left") {
newStart = Math.max(0, Math.min(origTrim.start_time + dtSec, newEnd - MIN_TRIM_DURATION))
} else {
newEnd = Math.max(origTrim.start_time + MIN_TRIM_DURATION, Math.min(origTrim.end_time + dtSec, origDur))
}
const newDuration = Math.round((newEnd - newStart) * 10) / 10
setTrimPreview({
clipId: trimDrag.clipId,
startTime: Math.round(newStart * 10) / 10,
endTime: Math.round(newEnd * 10) / 10,
duration: newDuration,
x: e.clientX,
y: e.clientY,
})
}
const handleMouseUp = () => {
if (trimPreview && trimPreview.clipId === trimDrag.clipId && onClipTrim) {
const newTrim: TrimConfig = {
start_time: trimPreview.startTime,
end_time: trimPreview.endTime,
original_duration: trimDrag.originalTrim.original_duration ?? trimDrag.originalDuration,
}
onClipTrim(trimDrag.clipId, newTrim, trimPreview.duration)
}
setTrimDrag(null)
setTrimPreview(null)
}
document.addEventListener("mousemove", handleMouseMove)
document.addEventListener("mouseup", handleMouseUp)
return () => {
document.removeEventListener("mousemove", handleMouseMove)
document.removeEventListener("mouseup", handleMouseUp)
}
}, [trimDrag, trimPreview, clips, pps, onClipTrim])
return {
trimDrag,
trimPreview,
handleTrimHandleMouseDown,
}
}
@@ -1,152 +0,0 @@
/**
* 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
@@ -1,42 +0,0 @@
/**
* 配音试听 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,
}
}
@@ -1,126 +0,0 @@
/**
* 水印配置 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
@@ -1,71 +0,0 @@
/**
* 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 */
}
@@ -1,21 +0,0 @@
/**
* 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 "口播+混剪"
}
@@ -250,7 +250,10 @@ const ProductLibrary: React.FC = () => {
value={filterProject}
onChange={setFilterProject}
style={{ width: 140 }}
options={[{ value: "all", label: "全部项目" }, ...projectOptions]}
options={[
{ value: "all", label: "全部项目" },
...projectOptions,
]}
/>
<Select
value={filterReviewStatus}
@@ -10,20 +10,9 @@ 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"
@@ -47,31 +36,6 @@ 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"
@@ -80,7 +44,6 @@ 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"
@@ -89,11 +52,6 @@ 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", () => {
@@ -52,15 +52,6 @@ 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__)
@@ -116,16 +107,47 @@ class RenderResult:
def _resolve_layer_role(clip_type: str, config: dict[str, Any]) -> str:
"""根据 clip_type 和 config.role 确定图层角色(向后兼容别名)
"""根据 clip_type 和 config.role 确定图层角色。
实际实现移至 packages.domain.render_layer_utils.resolve_layer_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"
"""
return _resolve_layer_role_pure(clip_type, config)
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"
# ── 图层默认 z_index ─────────────────────────────────────────────────────────
_LAYER_Z_INDEX: dict[str, int] = _IMPORTED_LAYER_Z_INDEX
_LAYER_Z_INDEX: dict[str, int] = {
"background": -1,
"broll": 0,
"main": 0,
"overlay": 1,
"corner_voice": 1,
"audio": 2,
}
# 图层默认 PiP 位置(相对输出画布的偏移)
_PIP_SCALE = 0.25 # PiP 占主画面的比例
@@ -467,9 +489,29 @@ class UnifiedRenderService:
def _estimate_total_duration(self, layers: list[RenderLayer]) -> float:
"""估算视频总时长(用于字幕等需要)。
实际实现移至 packages.domain.render_layer_utils.estimate_total_duration。
取主图层(main/broll/background)的总时长,转场重叠按 transition_duration 估算
"""
return _estimate_total_duration_pure(layers, self.transition_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)
def _maybe_generate_ass(self, video_duration: float) -> Path | None:
"""根据 plan.config 生成 ASS 字幕文件。
@@ -1827,11 +1869,10 @@ class UnifiedRenderService:
@staticmethod
def _clip_effective_duration(clip: ResolvedClip) -> float:
"""计算 clip 的有效时长(原速 trim 后时长)
实际实现移至 packages.domain.render_layer_utils.clip_effective_duration。
"""
return _clip_effective_duration_pure(clip.duration, clip.actual_duration)
"""计算 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
# ── 画中画(PiP)相关方法 ──────────────────────────────────────────────────
@@ -1928,20 +1969,17 @@ class UnifiedRenderService:
@staticmethod
def _clip_speed(clip: ResolvedClip) -> float:
"""获取 clip 的播放速度,无效值回退到 1.0
实际实现移至 packages.domain.render_layer_utils.clip_playback_speed。
"""
return _clip_playback_speed_pure(getattr(clip, "playback_speed", 1.0))
"""获取 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)
@staticmethod
def _clip_adjusted_duration(clip: ResolvedClip) -> float:
"""计算调速后的 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),
)
"""计算调速后的 clip 实际时长(用于拼接计算)."""
base = UnifiedRenderService._clip_effective_duration(clip)
speed = UnifiedRenderService._clip_speed(clip)
if abs(speed - 1.0) < 1e-6:
return base
return base / speed
+354 -22
View File
@@ -19,21 +19,74 @@ 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:
"""
轻量级视频素材分析器
@@ -396,37 +449,316 @@ 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()
return classify_from_analysis(color, motion, 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
def calculate_quality_score(self) -> QualityScore:
"""
计算视频质量综合评分 (0-100)
评分逻辑在 asset_quality_scoring.calculate_quality_score 纯函数中,
此处只负责采集数据后委托计算。
评分维度:
1. 分辨率得分 (25分)
2. 帧率得分 (20分)
3. 码率得分 (20分)
4. 清晰度得分 (20分)
5. 稳定性得分 (15分)
4. 清晰度得分 (20分) - Laplacian 方差
5. 稳定性得分 (15分) - 帧间位移方差
"""
info = self.get_video_info()
frames = self.extract_frames()
return calculate_quality_score(info, 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
def classify_asset_real(video_path: str) -> tuple[str, float]:
@@ -1,459 +0,0 @@
"""素材质量与分类评分 — 纯逻辑模块.
从 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,
)
+179 -9
View File
@@ -17,6 +17,7 @@ import logging
import os
import tempfile
import time
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
@@ -24,15 +25,6 @@ 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
@@ -96,6 +88,36 @@ 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(),
}
# ── 日志持久化辅助 ────────────────────────────────────────────────────────────
@@ -126,6 +148,36 @@ 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:
"""从数据库读取模板的片段配置列表。
@@ -154,6 +206,124 @@ 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],
@@ -1,327 +0,0 @@
"""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(),
}
-372
View File
@@ -1,372 +0,0 @@
"""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-100None表示未知)
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
-347
View File
@@ -1,347 +0,0 @@
"""剪辑计划生成 — 纯逻辑工具函数.
从 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 clipsN = asset_count,至少1个)
- PIP: 1 个 main + (N-1) 个 overlayN = asset_count
- VOICE_OVER: N 个 main clips + 标记需要配音
- VOICE_PIP: 1 个 background + 1 个 corner_voice + (N-2) 个 b_roll
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 clipsB-roll
for _ in range(n):
clips.append(
EditPlanClip.create(
plan_id=plan_id,
clip_type=ClipType.MAIN.value,
order=order,
duration=DEFAULT_CLIP_DURATION,
config={"role": "b_roll"},
)
)
order += 1
elif editing_mode == EditingMode.VOICE_PIP.value:
# 1 个 background
clips.append(
EditPlanClip.create(
plan_id=plan_id,
clip_type="background",
order=order,
duration=DEFAULT_CLIP_DURATION,
)
)
order += 1
# 1 个 corner_voice(至少有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
-241
View File
@@ -1,241 +0,0 @@
"""渲染图层工具函数 — 纯函数集合.
从 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_durationactual=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
+2 -3
View File
@@ -6,7 +6,6 @@ API 层和 Worker 层都从此模块导入,避免 API 直接依赖 Worker 代
from __future__ import annotations
import copy
import json
import logging
import random
@@ -88,7 +87,7 @@ def _fallback_recommend_clips(
order += 1
# 生成推荐 config
config = copy.deepcopy(DEFAULT_EDIT_PLAN_CONFIG)
config = DEFAULT_EDIT_PLAN_CONFIG.copy()
config["title"]["text"] = f"精选视频 — {len(asset_ids)} 个片段"
config["title"]["ai_auto"] = True
@@ -168,7 +167,7 @@ def _parse_recommend_response(
for i, clip in enumerate(clips):
clip["order"] = i
config = copy.deepcopy(DEFAULT_EDIT_PLAN_CONFIG)
config = DEFAULT_EDIT_PLAN_CONFIG.copy()
title = data.get("title", "")
if title:
config["title"]["text"] = str(title)
-3
View File
@@ -45,9 +45,6 @@ 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
+86 -9
View File
@@ -56,18 +56,95 @@ 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 (full scan) ==="
echo "=== [2/6] Code quality checks ==="
SCAN_MODE="full"
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
CHANGED_PY_FILES=""
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 安全扫描(仅告警) ---
-667
View File
@@ -1,667 +0,0 @@
"""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
-755
View File
@@ -1,755 +0,0 @@
"""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_outfiltered_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
+2 -1
View File
@@ -11,7 +11,8 @@ class TestMergeBgmConfigBothEmpty:
def test_both_empty(self):
result = merge_bgm_config({}, {})
assert result == {}
# 返回新字典(值已通过 == 验证,is not {} 无实际意义(每次{}每次新建对象)
# 确保返回的是新字典,不是同一个引用
assert result is not {}
def test_user_none_returns_template_copy(self):
"""用户传 None 视为空配置,返回模板副本。"""
+1 -1
View File
@@ -245,7 +245,7 @@ class TestClassificationJobState:
assert job.confidence == 1.0
class TestClassificationJobStatusMissingAliases:
class TestClassificationJobStatusMissing:
"""ClassificationJobStatus._missing_ 兼容行为测试"""
def test_done_maps_to_completed(self):
-538
View File
@@ -1,538 +0,0 @@
"""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
+12 -45
View File
@@ -8,9 +8,13 @@ from unittest.mock import MagicMock
import numpy as np
import pytest
# 模块级mock有副作用的依赖(纯算法测试不需要db/celery/cv2
# 注意:必须在 import dedup 前全部 mock 完,避免链式导入触发db连接
# ⚠️ 只 mock 具体叶子模块,绝不 mock 整个父包,否则会污染其他测试文件的导入
def _mock_module(**attrs):
"""Create a mock module with __spec__ to avoid AttributeError: __spec__."""
"""创建带 __spec__ 的 mock 模块,避免导入系统 AttributeError: __spec__"""
m = MagicMock()
m.__spec__ = None
for k, v in attrs.items():
@@ -18,60 +22,37 @@ def _mock_module(**attrs):
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
# cv2(视频处理依赖,纯算法测试不需要)
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
# sqlalchemydedup 导入了 Session 类型)
_mock_sqla = MagicMock()
_mock_sqla.__path__ = []
_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
sys.modules["sqlalchemy"] = _mock_sqla
sys.modules["sqlalchemy.orm"] = _mock_sqla_orm
sys.modules["sqlalchemy.engine"] = _mock_module()
sys.modules["sqlalchemy.ext"] = _mock_module()
sys.modules["sqlalchemy.ext.declarative"] = _mock_module()
# worker_app 子模块(只 mock 具体需要的,不 mock 整个 worker_app 包)
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 - 只 mock 真正触发副作用的模块,不 mock 整个父包
# session 模块是触发数据库连接的元凶(ensure_database_exists),必须 mock 掉
sys.modules["packages.adapters.sqlalchemy_impl.session"] = _mock_module(
Base=MagicMock(),
build_engine=MagicMock(),
@@ -83,26 +64,12 @@ sys.modules["packages.adapters.sqlalchemy_impl.generated_video_repository"] = _m
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 汉明距离计算测试."""
+190 -261
View File
@@ -1,6 +1,6 @@
"""Duplication 领域模型单元测试。"""
from datetime import datetime, timezone
"""
Duplication 查重记录领域模型单元测试
"""
import pytest
@@ -8,281 +8,255 @@ 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="vid123",
matched_video_name="test.mp4",
matched_video_id="vid_123",
matched_video_name="测试视频",
matched_start=5.0,
matched_end=15.0,
similarity=85.5,
)
assert seg.id is not None
assert len(seg.id) == 32 # uuid4 hex
assert len(seg.id) == 32
assert seg.source_start == 10.0
assert seg.source_end == 20.0
assert seg.matched_video_id == "vid123"
assert seg.matched_video_name == "test.mp4"
assert seg.matched_video_id == "vid_123"
assert seg.matched_video_name == "测试视频"
assert seg.matched_start == 5.0
assert seg.matched_end == 15.0
assert seg.similarity == 85.5
def test_create_negative_source_start(self):
def test_invalid_source_negative_start(self):
with pytest.raises(ValueError, match="invalid source segment range"):
DuplicateSegment.create(
source_start=-1.0,
source_end=10.0,
matched_video_id="v",
matched_video_name="n",
matched_start=0.0,
matched_end=5.0,
similarity=50.0,
matched_video_id="v1",
matched_video_name="n1",
matched_start=0,
matched_end=10,
similarity=50,
)
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="v",
matched_video_name="n",
matched_start=0.0,
matched_end=5.0,
similarity=50.0,
)
def test_create_source_end_less_than_start(self):
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="v",
matched_video_name="n",
matched_start=0.0,
matched_end=5.0,
similarity=50.0,
matched_video_id="v1",
matched_video_name="n1",
matched_start=0,
matched_end=10,
similarity=50,
)
def test_create_negative_matched_start(self):
def test_invalid_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,
)
def test_invalid_matched_negative_start(self):
with pytest.raises(ValueError, match="invalid matched segment range"):
DuplicateSegment.create(
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,
source_start=0,
source_end=10,
matched_video_id="v1",
matched_video_name="n1",
matched_start=-5,
matched_end=10,
similarity=50,
)
def test_create_matched_end_equals_start(self):
def test_invalid_matched_end_before_start(self):
with pytest.raises(ValueError, match="invalid matched segment range"):
DuplicateSegment.create(
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,
source_start=0,
source_end=10,
matched_video_id="v1",
matched_video_name="n1",
matched_start=15,
matched_end=10,
similarity=50,
)
def test_create_similarity_negative(self):
def test_invalid_similarity_negative(self):
with pytest.raises(ValueError, match="similarity must be between 0 and 100"):
DuplicateSegment.create(
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,
source_start=0,
source_end=10,
matched_video_id="v1",
matched_video_name="n1",
matched_start=0,
matched_end=10,
similarity=-1,
)
def test_create_similarity_over_100(self):
def test_invalid_similarity_over_100(self):
with pytest.raises(ValueError, match="similarity must be between 0 and 100"):
DuplicateSegment.create(
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,
source_start=0,
source_end=10,
matched_video_id="v1",
matched_video_name="n1",
matched_start=0,
matched_end=10,
similarity=101,
)
def test_create_similarity_zero(self):
def test_similarity_boundary_zero(self):
seg = DuplicateSegment.create(
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,
source_start=0,
source_end=10,
matched_video_id="v1",
matched_video_name="n1",
matched_start=0,
matched_end=10,
similarity=0,
)
assert seg.similarity == 0.0
assert seg.similarity == 0
def test_create_similarity_100(self):
def test_similarity_boundary_100(self):
seg = DuplicateSegment.create(
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,
source_start=0,
source_end=10,
matched_video_id="v1",
matched_video_name="n1",
matched_start=0,
matched_end=10,
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
assert seg.similarity == 100
class TestDuplicationRecordCreate:
def test_create_success_defaults(self):
"""DuplicationRecord.create 测试"""
def test_create_minimal(self):
record = DuplicationRecord.create(
user_id="user123",
filename="my_video.mp4",
filename="test.mp4",
file_size=1024000,
storage_key="videos/vid1.mp4",
storage_key="oss://bucket/test.mp4",
)
assert record.id is not None
assert len(record.id) == 32
assert record.user_id == "user123"
assert record.filename == "my_video.mp4"
assert record.filename == "test.mp4"
assert record.file_size == 1024000
assert record.storage_key == "videos/vid1.mp4"
assert record.duration_seconds == 0.0
assert record.storage_key == "oss://bucket/test.mp4"
assert record.status == "pending"
assert record.duplicate_rate is None
assert record.duplicate_count == 0
assert record.segments == []
assert record.error_message == ""
assert isinstance(record.created_at, datetime)
assert isinstance(record.updated_at, datetime)
assert record.duration_seconds == 0.0
assert record.created_at is not None
assert record.updated_at is not None
def test_create_with_duration(self):
record = DuplicationRecord.create(
user_id="u1",
filename="v.mp4",
file_size=100,
storage_key="k",
filename="video.mp4",
file_size=5000,
storage_key="key",
duration_seconds=120.5,
)
assert record.duration_seconds == 120.5
def test_create_strips_user_id(self):
def test_create_strips_whitespace(self):
record = DuplicationRecord.create(
user_id=" user_trimmed ",
filename="v.mp4",
user_id=" user456 ",
filename=" my video.mp4 ",
file_size=100,
storage_key="k",
storage_key="key",
)
assert record.user_id == "user_trimmed"
assert record.user_id == "user456"
assert record.filename == "my video.mp4"
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):
def test_empty_user_id_raises(self):
with pytest.raises(ValueError, match="user_id cannot be empty"):
DuplicationRecord.create(
user_id=" ",
filename="v.mp4",
filename="test.mp4",
file_size=100,
storage_key="k",
storage_key="key",
)
def test_create_empty_filename(self):
def test_empty_filename_raises(self):
with pytest.raises(ValueError, match="filename cannot be empty"):
DuplicationRecord.create(
user_id="u1",
filename="",
filename=" ",
file_size=100,
storage_key="k",
storage_key="key",
)
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):
def test_zero_file_size_raises(self):
with pytest.raises(ValueError, match="file_size must be positive"):
DuplicationRecord.create(
user_id="u1",
filename="v.mp4",
filename="test.mp4",
file_size=0,
storage_key="k",
storage_key="key",
)
def test_create_negative_file_size(self):
def test_negative_file_size_raises(self):
with pytest.raises(ValueError, match="file_size must be positive"):
DuplicationRecord.create(
user_id="u1",
filename="v.mp4",
filename="test.mp4",
file_size=-100,
storage_key="k",
storage_key="key",
)
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:
"""生命周期状态转换测试"""
class TestMarkProcessing:
def test_mark_processing_from_pending(self):
record = DuplicationRecord.create("u", "f", 100, "k")
before = record.updated_at
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
record.mark_processing()
assert record.status == "processing"
assert record.updated_at >= before
assert record.updated_at >= old_updated
def test_mark_processing_updates_timestamp(self):
record = DuplicationRecord.create("u", "f", 100, "k")
old_time = record.updated_at
# 确保时间戳会变(datetime.now 精度问题,直接赋值模拟)
def test_mark_completed(self):
record = DuplicationRecord.create(user_id="u1", filename="t.mp4", file_size=100, storage_key="k")
record.mark_processing()
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])
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 == "completed"
assert record.duplicate_rate == 75.5
assert record.duplicate_count == 3
assert record.duplicate_rate == 25.5
assert record.duplicate_count == 1
assert len(record.segments) == 1
assert record.segments[0].matched_video_id == "v"
assert record.error_message == ""
def test_mark_completed_zero_rate(self):
record = DuplicationRecord.create("u", "f", 100, "k")
record = DuplicationRecord.create(user_id="u1", filename="t.mp4", file_size=100, storage_key="k")
record.mark_completed(duplicate_rate=0.0, duplicate_count=0, segments=[])
assert record.status == "completed"
assert record.duplicate_rate == 0.0
@@ -290,133 +264,88 @@ class TestMarkCompleted:
assert record.segments == []
def test_mark_completed_100_rate(self):
record = DuplicationRecord.create("u", "f", 100, "k")
record = DuplicationRecord.create(user_id="u1", filename="t.mp4", file_size=100, storage_key="k")
record.mark_completed(duplicate_rate=100.0, duplicate_count=5, segments=[])
assert record.duplicate_rate == 100.0
def test_mark_completed_negative_rate(self):
record = DuplicationRecord.create("u", "f", 100, "k")
def test_mark_completed_invalid_rate_negative(self):
record = DuplicationRecord.create(user_id="u1", filename="t.mp4", file_size=100, storage_key="k")
with pytest.raises(ValueError, match="duplicate_rate must be between 0 and 100"):
record.mark_completed(duplicate_rate=-1.0, duplicate_count=0, segments=[])
record.mark_completed(duplicate_rate=-1, duplicate_count=0, segments=[])
def test_mark_completed_over_100_rate(self):
record = DuplicationRecord.create("u", "f", 100, "k")
def test_mark_completed_invalid_rate_over_100(self):
record = DuplicationRecord.create(user_id="u1", filename="t.mp4", file_size=100, storage_key="k")
with pytest.raises(ValueError, match="duplicate_rate must be between 0 and 100"):
record.mark_completed(duplicate_rate=101.0, duplicate_count=0, segments=[])
record.mark_completed(duplicate_rate=101, 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("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 = DuplicationRecord.create(user_id="u1", filename="t.mp4", file_size=100, storage_key="k")
record.mark_processing()
record.mark_failed("something went wrong")
record.mark_failed("网络超时")
assert record.status == "failed"
assert record.error_message == "something went wrong"
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 == "文件损坏"
class TestCanRetry:
class TestDuplicationRecordRetry:
"""重试逻辑测试"""
def test_can_retry_failed(self):
record = DuplicationRecord.create("u", "f", 100, "k")
record = DuplicationRecord.create(user_id="u1", filename="t.mp4", file_size=100, storage_key="k")
record.mark_failed("error")
assert record.can_retry() is True
def test_cannot_retry_pending(self):
record = DuplicationRecord.create("u", "f", 100, "k")
record = DuplicationRecord.create(user_id="u1", filename="t.mp4", file_size=100, storage_key="k")
assert record.can_retry() is False
def test_cannot_retry_processing(self):
record = DuplicationRecord.create("u", "f", 100, "k")
record = DuplicationRecord.create(user_id="u1", filename="t.mp4", file_size=100, storage_key="k")
record.mark_processing()
assert record.can_retry() is False
def test_cannot_retry_completed(self):
record = DuplicationRecord.create("u", "f", 100, "k")
record.mark_completed(50.0, 1, [])
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=[])
assert record.can_retry() is False
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 状态
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)
record.status = "failed"
record.duplicate_rate = 50.0
record.duplicate_count = 3
record.error_message = "old error"
record.error_message = "something wrong"
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_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),
]
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")
record.reset_for_retry()
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__")
assert record.status == "pending"
assert record.duplicate_count == 0
@@ -1,105 +0,0 @@
"""剪辑模式枚举 & 模板版本 单元测试。"""
from __future__ import annotations
from domain.editing_mode import EditingMode
from domain.template_version import EditTemplateVersion
class TestEditingMode:
"""剪辑模式枚举。"""
def test_one_take_value(self):
assert EditingMode.ONE_TAKE == "one_take"
def test_pip_value(self):
assert EditingMode.PIP == "pip"
def test_voice_over_value(self):
assert EditingMode.VOICE_OVER == "voice_over"
def test_voice_pip_value(self):
assert EditingMode.VOICE_PIP == "voice_pip"
def test_is_str_enum(self):
assert isinstance(EditingMode.ONE_TAKE, str)
assert EditingMode.ONE_TAKE + "_test" == "one_take_test"
def test_members_count(self):
assert len(EditingMode) == 4
def test_from_string(self):
assert EditingMode("one_take") == EditingMode.ONE_TAKE
assert EditingMode("pip") == EditingMode.PIP
assert EditingMode("voice_over") == EditingMode.VOICE_OVER
assert EditingMode("voice_pip") == EditingMode.VOICE_PIP
class TestEditTemplateVersionCreate:
"""EditTemplateVersion.create 工厂方法。"""
def test_create_minimal(self):
v = EditTemplateVersion.create(template_id="tpl-1", version=1)
assert v.template_id == "tpl-1"
assert v.version == 1
assert v.id # 自动生成
assert v.name == ""
assert v.editing_mode == "one_take"
assert v.config == {}
assert v.clip_configs == []
assert v.change_note == ""
assert v.published_by == ""
def test_create_with_name(self):
v = EditTemplateVersion.create(template_id="tpl-1", version=2, name="v2")
assert v.name == "v2"
assert v.version == 2
def test_create_with_editing_mode(self):
v = EditTemplateVersion.create(template_id="tpl-1", version=1, editing_mode="pip")
assert v.editing_mode == "pip"
def test_create_with_config(self):
config = {"duration": 30, "resolution": "1080p"}
v = EditTemplateVersion.create(template_id="tpl-1", version=1, config=config)
assert v.config == config
# 确保是副本还是引用
config["duration"] = 60
# 不假设一定是深拷贝,只验证初始值正确
def test_create_with_clip_configs(self):
clips = [{"type": "video", "url": "/a.mp4"}, {"type": "text", "text": "hi"}]
v = EditTemplateVersion.create(template_id="tpl-1", version=1, clip_configs=clips)
assert len(v.clip_configs) == 2
assert v.clip_configs[0]["type"] == "video"
def test_create_with_change_note(self):
v = EditTemplateVersion.create(template_id="tpl-1", version=1, change_note="Initial version")
assert v.change_note == "Initial version"
def test_create_with_published_by(self):
v = EditTemplateVersion.create(template_id="tpl-1", version=1, published_by="user-1")
assert v.published_by == "user-1"
def test_create_none_config_defaults_to_empty(self):
v = EditTemplateVersion.create(template_id="tpl-1", version=1, config=None)
assert v.config == {}
def test_create_none_clip_configs_defaults_to_empty(self):
v = EditTemplateVersion.create(template_id="tpl-1", version=1, clip_configs=None)
assert v.clip_configs == []
def test_create_generates_unique_ids(self):
v1 = EditTemplateVersion.create(template_id="tpl-1", version=1)
v2 = EditTemplateVersion.create(template_id="tpl-1", version=2)
assert v1.id != v2.id
def test_create_id_is_hex(self):
v = EditTemplateVersion.create(template_id="tpl-1", version=1)
int(v.id, 16) # 合法 hex
def test_created_at_is_set(self):
v = EditTemplateVersion.create(template_id="tpl-1", version=1)
assert v.created_at is not None
# 应该是 UTC 时间
assert v.created_at.tzinfo is not None
-648
View File
@@ -1,648 +0,0 @@
"""Unit tests for generation_plan_builder.py — pure logic utilities.
覆盖:
- VirtualPlan / VirtualClip 数据类
- extract_intro_outro_from_clip_configs
- apply_template_clip_effects
- build_clips_by_mode (4种模式)
- build_error_info
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
from unittest.mock import patch
import pytest
from worker_app.tasks.generation_plan_builder import (
VirtualClip,
VirtualPlan,
apply_template_clip_effects,
build_clips_by_mode,
build_error_info,
extract_intro_outro_from_clip_configs,
)
# ── 辅助:模拟 clip_config 对象 ──────────────────────────────────────────────
@dataclass
class MockClipType:
"""模拟 Enum 类型的 clip_type。"""
value: str
@dataclass
class MockTransition:
"""模拟 Enum 类型的 transition_effect。"""
value: str
@dataclass
class MockClipConfig:
"""模拟 TemplateClipConfig 对象。"""
clip_type: Any
transition_effect: Any = "cut"
default_duration: float = 3.0
text_template: str = ""
config: dict | None = None
def _make_config(
clip_type: str = "main",
transition: str = "cut",
duration: float = 3.0,
text: str = "",
config: dict | None = None,
use_enum: bool = True,
) -> MockClipConfig:
"""创建一个模拟 clip_config。"""
ct = MockClipType(clip_type) if use_enum else clip_type
tr = MockTransition(transition) if use_enum and transition != "cut" else transition
return MockClipConfig(
clip_type=ct,
transition_effect=tr,
default_duration=duration,
text_template=text,
config=config or {},
)
# ── VirtualPlan / VirtualClip ───────────────────────────────────────────────
class TestVirtualPlan:
def test_default_values(self):
plan = VirtualPlan(id="plan_001")
assert plan.id == "plan_001"
assert plan.name == ""
assert plan.config == {}
def test_full_init(self):
plan = VirtualPlan(id="p1", name="My Plan", config={"key": "value"})
assert plan.id == "p1"
assert plan.name == "My Plan"
assert plan.config == {"key": "value"}
def test_mutable_config(self):
plan = VirtualPlan(id="p1")
plan.config["new_key"] = "new_val"
assert plan.config == {"new_key": "new_val"}
class TestVirtualClip:
def test_default_values(self):
clip = VirtualClip(id="c001")
assert clip.id == "c001"
assert clip.plan_id == ""
assert clip.clip_type == "main"
assert clip.order == 0
assert clip.asset_id == ""
assert clip.duration == 0.0
assert clip.transition_effect == "cut"
assert clip.transition_duration == 0.0
assert clip.playback_speed == 1.0
assert clip.status == "ready"
assert clip.config == {}
def test_full_init(self):
clip = VirtualClip(
id="c001",
plan_id="p1",
clip_type="overlay",
order=1,
asset_id="asset_001",
duration=5.5,
transition_effect="fade",
transition_duration=0.5,
playback_speed=1.5,
config={"role": "b_roll"},
)
assert clip.clip_type == "overlay"
assert clip.duration == 5.5
assert clip.playback_speed == 1.5
def test_mutable_config(self):
clip = VirtualClip(id="c001")
clip.config["effect"] = "vintage"
assert clip.config == {"effect": "vintage"}
# ── extract_intro_outro_from_clip_configs ───────────────────────────────────
class TestExtractIntroOutro:
def test_empty_configs(self):
result = extract_intro_outro_from_clip_configs([])
assert result == {}
def test_no_intro_no_outro(self):
configs = [_make_config("main"), _make_config("showcase")]
result = extract_intro_outro_from_clip_configs(configs)
assert result == {}
def test_intro_only_basic(self):
configs = [_make_config("intro", text="Hello", duration=2.5)]
result = extract_intro_outro_from_clip_configs(configs)
assert result["has_intro"] is True
assert result["intro_type"] == "text"
assert result["intro_duration"] == 2.5
assert result["intro_text"] == "Hello"
assert "has_outro" not in result
def test_outro_only_basic(self):
configs = [_make_config("outro", text="Bye", duration=3.0)]
result = extract_intro_outro_from_clip_configs(configs)
assert result["has_outro"] is True
assert result["outro_type"] == "text"
assert result["outro_duration"] == 3.0
assert result["outro_text"] == "Bye"
def test_both_intro_and_outro(self):
configs = [
_make_config("intro", text="Start"),
_make_config("main"),
_make_config("outro", text="End"),
]
result = extract_intro_outro_from_clip_configs(configs)
assert result["has_intro"] is True
assert result["intro_text"] == "Start"
assert result["has_outro"] is True
assert result["outro_text"] == "End"
def test_intro_extra_config_pass_through(self):
configs = [
_make_config(
"intro",
config={
"intro_text_color": "#ffffff",
"intro_bg_color": "#000000",
"intro_font_size": 32,
"intro_video_url": "https://example.com/intro.mp4",
"intro_video_path": "/tmp/intro.mp4",
"random_key": "should_not_appear",
},
)
]
result = extract_intro_outro_from_clip_configs(configs)
assert result["intro_text_color"] == "#ffffff"
assert result["intro_bg_color"] == "#000000"
assert result["intro_font_size"] == 32
assert result["intro_video_url"] == "https://example.com/intro.mp4"
assert "random_key" not in result
def test_outro_extra_config_pass_through(self):
configs = [
_make_config(
"outro",
config={
"outro_text_color": "#ff0000",
"outro_bg_color": "#00ff00",
"outro_font_size": 24,
"outro_follow_text": "关注我们",
},
)
]
result = extract_intro_outro_from_clip_configs(configs)
assert result["outro_text_color"] == "#ff0000"
assert result["outro_follow_text"] == "关注我们"
def test_intro_type_from_config(self):
configs = [_make_config("intro", config={"intro_type": "video"})]
result = extract_intro_outro_from_clip_configs(configs)
assert result["intro_type"] == "video"
def test_default_duration_when_zero(self):
configs = [_make_config("intro", duration=0.0)]
result = extract_intro_outro_from_clip_configs(configs)
assert result["intro_duration"] == 3.0
def test_empty_text_not_included(self):
configs = [_make_config("intro", text="")]
result = extract_intro_outro_from_clip_configs(configs)
assert "intro_text" not in result
def test_with_string_clip_type_no_enum(self):
configs = [_make_config("intro", text="Hi", use_enum=False)]
result = extract_intro_outro_from_clip_configs(configs)
assert result["has_intro"] is True
assert result["intro_text"] == "Hi"
def test_first_intro_used_when_multiple(self):
configs = [
_make_config("intro", text="First"),
_make_config("intro", text="Second"),
]
result = extract_intro_outro_from_clip_configs(configs)
assert result["intro_text"] == "First"
def test_none_config_handled(self):
cfg = _make_config("intro")
cfg.config = None
result = extract_intro_outro_from_clip_configs([cfg])
assert result["has_intro"] is True
# ── apply_template_clip_effects ─────────────────────────────────────────────
class TestApplyTemplateClipEffects:
def test_empty_clips(self):
clips: list[VirtualClip] = []
configs = [_make_config("main", transition="fade")]
apply_template_clip_effects(clips, configs, "one_take")
assert clips == []
def test_empty_configs(self):
clips = [VirtualClip(id="c1", clip_type="main")]
apply_template_clip_effects(clips, [], "one_take")
assert clips[0].transition_effect == "cut"
def test_no_main_configs(self):
clips = [VirtualClip(id="c1", clip_type="main")]
configs = [_make_config("intro"), _make_config("outro")]
apply_template_clip_effects(clips, configs, "one_take")
assert clips[0].transition_effect == "cut"
def test_transition_effect_applied(self):
clips = [VirtualClip(id="c1", clip_type="main")]
configs = [_make_config("main", transition="fade")]
apply_template_clip_effects(clips, configs, "one_take")
assert clips[0].transition_effect == "fade"
def test_transition_duration_applied(self):
clips = [VirtualClip(id="c1", clip_type="main")]
configs = [_make_config("main", transition="fade", config={"transition_duration": 0.8})]
apply_template_clip_effects(clips, configs, "one_take")
assert clips[0].transition_duration == 0.8
def test_transition_duration_invalid_ignored(self):
clips = [VirtualClip(id="c1", clip_type="main")]
configs = [_make_config("main", transition="fade", config={"transition_duration": "abc"})]
apply_template_clip_effects(clips, configs, "one_take")
assert clips[0].transition_duration == 0.0
def test_cut_transition_not_applied(self):
clips = [VirtualClip(id="c1", clip_type="main", transition_effect="dissolve")]
configs = [_make_config("main", transition="cut")]
apply_template_clip_effects(clips, configs, "one_take")
assert clips[0].transition_effect == "dissolve" # 保留原值
def test_color_grade_applied(self):
clips = [VirtualClip(id="c1", clip_type="main")]
configs = [_make_config("main", config={"color_grade": "vintage"})]
apply_template_clip_effects(clips, configs, "one_take")
assert clips[0].config["color_grade"] == "vintage"
def test_multiple_effect_keys_applied(self):
clips = [VirtualClip(id="c1", clip_type="main")]
configs = [
_make_config(
"main",
config={
"color_grade": "warm",
"playback_speed": 1.5,
"reverse": True,
"chroma_key": {"color": "green"},
"filter": "黑白",
},
)
]
apply_template_clip_effects(clips, configs, "one_take")
assert clips[0].config["color_grade"] == "warm"
assert clips[0].config["playback_speed"] == 1.5
assert clips[0].config["reverse"] is True
assert clips[0].config["chroma_key"] == {"color": "green"}
def test_playback_speed_top_level_updated(self):
clips = [VirtualClip(id="c1", clip_type="main")]
configs = [_make_config("main", config={"playback_speed": 2.0})]
apply_template_clip_effects(clips, configs, "one_take")
assert clips[0].playback_speed == 2.0
assert clips[0].config["playback_speed"] == 2.0
def test_speed_fallback_sets_playback_speed(self):
clips = [VirtualClip(id="c1", clip_type="main")]
configs = [_make_config("main", config={"speed": 0.8})]
apply_template_clip_effects(clips, configs, "one_take")
assert clips[0].playback_speed == 0.8
def test_playback_speed_takes_priority_over_speed(self):
clips = [VirtualClip(id="c1", clip_type="main")]
configs = [_make_config("main", config={"speed": 0.5, "playback_speed": 2.0})]
apply_template_clip_effects(clips, configs, "one_take")
assert clips[0].playback_speed == 2.0
def test_existing_config_preserved(self):
clips = [VirtualClip(id="c1", clip_type="main", config={"role": "b_roll", "original": "value"})]
configs = [_make_config("main", config={"color_grade": "cool"})]
apply_template_clip_effects(clips, configs, "one_take")
assert clips[0].config["role"] == "b_roll"
assert clips[0].config["original"] == "value"
assert clips[0].config["color_grade"] == "cool"
def test_corner_voice_skipped(self):
clips = [
VirtualClip(id="c1", clip_type="main"),
VirtualClip(id="c2", clip_type="corner_voice"),
]
configs = [_make_config("main", transition="fade")]
apply_template_clip_effects(clips, configs, "voice_pip")
assert clips[0].transition_effect == "fade"
assert clips[1].transition_effect == "cut" # 不应用效果
def test_cyclic_matching_more_clips_than_configs(self):
clips = [
VirtualClip(id="c1", clip_type="main"),
VirtualClip(id="c2", clip_type="main"),
VirtualClip(id="c3", clip_type="main"),
]
configs = [
_make_config("main", transition="fade"),
_make_config("main", transition="dissolve"),
]
apply_template_clip_effects(clips, configs, "one_take")
assert clips[0].transition_effect == "fade"
assert clips[1].transition_effect == "dissolve"
assert clips[2].transition_effect == "dissolve" # 循环用最后一个
def test_pip_mode_main_and_overlay_both_effected(self):
clips = [
VirtualClip(id="c1", clip_type="main"),
VirtualClip(id="c2", clip_type="overlay"),
]
configs = [_make_config("main", transition="fade")]
apply_template_clip_effects(clips, configs, "pip")
assert clips[0].transition_effect == "fade"
assert clips[1].transition_effect == "fade"
def test_showcase_and_b_roll_count_as_template_source(self):
clips = [VirtualClip(id="c1", clip_type="main")]
configs = [
_make_config("showcase", transition="zoom_in"),
_make_config("b_roll", transition="slide_left"),
]
apply_template_clip_effects(clips, configs, "one_take")
assert clips[0].transition_effect == "zoom_in" # 用第一个匹配的
def test_string_transition_no_enum(self):
clips = [VirtualClip(id="c1", clip_type="main")]
cfg = _make_config("main")
cfg.transition_effect = "wipe" # 直接字符串
apply_template_clip_effects(clips, [cfg], "one_take")
assert clips[0].transition_effect == "wipe"
def test_zero_speed_ignored(self):
clips = [VirtualClip(id="c1", clip_type="main")]
configs = [_make_config("main", config={"playback_speed": 0})]
apply_template_clip_effects(clips, configs, "one_take")
assert clips[0].playback_speed == 1.0 # 保持默认
def test_negative_speed_ignored(self):
clips = [VirtualClip(id="c1", clip_type="main")]
configs = [_make_config("main", config={"playback_speed": -1.0})]
apply_template_clip_effects(clips, configs, "one_take")
assert clips[0].playback_speed == 1.0
# ── build_clips_by_mode ────────────────────────────────────────────────────
class TestBuildClipsByMode:
def test_one_take_single_asset(self):
clips = build_clips_by_mode(
plan_id="p1",
asset_infos=[{"asset_id": "a1", "duration": 10.0}],
mode="one_take",
)
assert len(clips) == 1
assert clips[0].clip_type == "main"
assert clips[0].asset_id == "a1"
assert clips[0].duration == 10.0
assert clips[0].order == 0
assert clips[0].plan_id == "p1"
def test_one_take_multiple_assets(self):
clips = build_clips_by_mode(
plan_id="p1",
asset_infos=[
{"asset_id": "a1", "duration": 5.0},
{"asset_id": "a2", "duration": 10.0},
{"asset_id": "a3", "duration": 7.0},
],
mode="one_take",
)
assert len(clips) == 3
assert all(c.clip_type == "main" for c in clips)
assert [c.order for c in clips] == [0, 1, 2]
assert [c.duration for c in clips] == [5.0, 10.0, 7.0]
def test_pip_mode_main_and_overlay(self):
clips = build_clips_by_mode(
plan_id="p1",
asset_infos=[
{"asset_id": "a1", "duration": 10.0},
{"asset_id": "a2", "duration": 5.0},
{"asset_id": "a3", "duration": 3.0},
],
mode="pip",
)
assert len(clips) == 3
assert clips[0].clip_type == "main"
assert clips[1].clip_type == "overlay"
assert clips[2].clip_type == "overlay"
def test_pip_single_asset_is_main(self):
clips = build_clips_by_mode(
plan_id="p1",
asset_infos=[{"asset_id": "a1", "duration": 10.0}],
mode="pip",
)
assert len(clips) == 1
assert clips[0].clip_type == "main"
def test_voice_over_all_main_with_role(self):
clips = build_clips_by_mode(
plan_id="p1",
asset_infos=[
{"asset_id": "a1", "duration": 5.0},
{"asset_id": "a2", "duration": 8.0},
],
mode="voice_over",
)
assert len(clips) == 2
assert all(c.clip_type == "main" for c in clips)
assert all(c.config.get("role") == "b_roll" for c in clips)
def test_voice_pip_three_layers(self):
clips = build_clips_by_mode(
plan_id="p1",
asset_infos=[
{"asset_id": "a1", "duration": 10.0},
{"asset_id": "a2", "duration": 5.0},
{"asset_id": "a3", "duration": 3.0},
{"asset_id": "a4", "duration": 4.0},
],
mode="voice_pip",
)
assert len(clips) == 4
assert clips[0].clip_type == "background"
assert clips[1].clip_type == "corner_voice"
assert clips[2].clip_type == "b_roll"
assert clips[3].clip_type == "b_roll"
def test_voice_pip_two_assets(self):
clips = build_clips_by_mode(
plan_id="p1",
asset_infos=[
{"asset_id": "a1", "duration": 10.0},
{"asset_id": "a2", "duration": 5.0},
],
mode="voice_pip",
)
assert len(clips) == 2
assert clips[0].clip_type == "background"
assert clips[1].clip_type == "corner_voice"
def test_voice_pip_single_asset(self):
clips = build_clips_by_mode(
plan_id="p1",
asset_infos=[{"asset_id": "a1", "duration": 10.0}],
mode="voice_pip",
)
assert len(clips) == 1
assert clips[0].clip_type == "background"
def test_default_mode_is_one_take(self):
clips = build_clips_by_mode(
plan_id="p1",
asset_infos=[{"asset_id": "a1", "duration": 10.0}],
mode="unknown_mode",
)
assert len(clips) == 1
assert clips[0].clip_type == "main"
def test_empty_assets(self):
clips = build_clips_by_mode(
plan_id="p1",
asset_infos=[],
mode="one_take",
)
assert clips == []
def test_default_asset_id_when_missing(self):
clips = build_clips_by_mode(
plan_id="p1",
asset_infos=[{"duration": 5.0}],
mode="one_take",
)
assert clips[0].asset_id == "asset_000"
def test_default_duration_when_missing(self):
clips = build_clips_by_mode(
plan_id="p1",
asset_infos=[{"asset_id": "a1"}],
mode="one_take",
)
assert clips[0].duration == 0.0
def test_clip_ids_sequential(self):
clips = build_clips_by_mode(
plan_id="p1",
asset_infos=[{"asset_id": f"a{i}"} for i in range(5)],
mode="one_take",
)
assert [c.id for c in clips] == ["vc_000", "vc_001", "vc_002", "vc_003", "vc_004"]
def test_plan_id_propagated(self):
clips = build_clips_by_mode(
plan_id="my_plan_123",
asset_infos=[{"asset_id": "a1"}, {"asset_id": "a2"}],
mode="pip",
)
assert all(c.plan_id == "my_plan_123" for c in clips)
# ── build_error_info ───────────────────────────────────────────────────────
class TestBuildErrorInfo:
def test_basic_structure(self):
try:
raise ValueError("test error")
except ValueError as e:
info = build_error_info(e, stage="render")
assert info["error_type"] == "ValueError"
assert info["message"] == "test error"
assert info["stage"] == "render"
assert "stack_trace" in info
assert "failed_at" in info
assert "ValueError" in info["stack_trace"]
assert "test error" in info["stack_trace"]
def test_default_stage(self):
try:
raise RuntimeError("oops")
except RuntimeError as e:
info = build_error_info(e)
assert info["stage"] == "render"
def test_custom_stage(self):
try:
raise TypeError("bad type")
except TypeError as e:
info = build_error_info(e, stage="download")
assert info["stage"] == "download"
def test_failed_at_is_iso_format(self):
try:
raise ValueError("x")
except ValueError as e:
info = build_error_info(e)
# ISO 格式检查:包含 T 和 +
assert "T" in info["failed_at"]
@patch("worker_app.tasks.generation_plan_builder.traceback.format_exc")
def test_long_stack_trace_truncated(self, mock_format):
# 构造30行堆栈
lines = [f' File "file{i}.py", line {i}, in func{i}' for i in range(28)]
lines.append("ValueError: deep error")
mock_format.return_value = "\n".join(lines)
try:
raise ValueError("deep")
except ValueError as e:
info = build_error_info(e)
assert "truncated" in info["stack_trace"]
assert "total 29 lines" in info["stack_trace"]
# 确认只保留了前20行
assert "func19" in info["stack_trace"]
assert "func20" not in info["stack_trace"]
@patch("worker_app.tasks.generation_plan_builder.traceback.format_exc")
def test_short_stack_trace_not_truncated(self, mock_format):
# 构造5行堆栈(小于20
mock_format.return_value = (
"Traceback (most recent call last):\n"
' File "test.py", line 10, in foo\n'
' raise ValueError("simple")\n'
"ValueError: simple\n"
)
try:
raise ValueError("simple")
except ValueError as e:
info = build_error_info(e)
assert "truncated" not in info["stack_trace"]
assert "ValueError: simple" in info["stack_trace"]
+1 -1
View File
@@ -208,7 +208,7 @@ class TestGenerationTaskStateTransitions:
def test_transition_to_invalid_string_raises(self):
task = GenerationTask.create(project_id="p1", asset_library_id="l1")
with pytest.raises(ValueError, match="非法状态转换"):
with pytest.raises(ValueError, match="无效状态"):
task.transition_to("invalid_status")
+1 -1
View File
@@ -403,7 +403,7 @@ class TestGenerationTaskTransitions:
def test_invalid_status_string(self, new_task):
"""测试无效状态字符串"""
with pytest.raises(ValueError, match="非法状态转换"):
with pytest.raises(ValueError, match="无效状态"):
new_task.transition_to("invalid_status")
+1 -1
View File
@@ -356,7 +356,7 @@ class TestTransitionTo:
def test_invalid_string_raises(self) -> None:
"""无效的状态字符串抛出 ValueError。"""
task = _make_task()
with pytest.raises(ValueError, match="非法状态转换"):
with pytest.raises(ValueError, match="无效状态"):
task.transition_to("invalid_status")
def test_enum_status(self) -> None:
-670
View File
@@ -1,670 +0,0 @@
"""plan_generator_utils 纯逻辑单测 — 第90波.
测试素材分配、clip_type映射、默认clip生成、配置转clip等纯函数。
不依赖 DB,使用领域对象直接构造。
"""
import pytest
from packages.domain.edit_plan_clip import EditPlanClip
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
# ── 辅助函数 ──────────────────────────────────────────────────────────
def _make_main_clip(plan_id: str = "plan1", order: int = 0) -> EditPlanClip:
"""创建一个 MAIN 类型的 clip."""
return EditPlanClip.create(
plan_id=plan_id,
clip_type=ClipType.MAIN.value,
order=order,
duration=5.0,
)
def _make_clips(n: int, clip_type: str = "main") -> list[EditPlanClip]:
"""创建 n 个指定类型的 clip."""
return [
EditPlanClip.create(
plan_id="plan1",
clip_type=clip_type,
order=i,
duration=5.0,
)
for i in range(n)
]
def _collect_asset_ids(clips: list[EditPlanClip]) -> list[str]:
"""按顺序收集 clips 的 asset_id(空的跳过)."""
return [c.asset_id for c in clips if c.asset_id]
# ── distribute_assets: ONE_TAKE ─────────────────────────────────────
class TestDistributeOneTake:
"""ONE_TAKE 模式素材分配."""
def test_equal_count(self):
"""素材数 == clip 数:一一对应."""
clips = _make_clips(3)
distribute_assets(clips, ["a1", "a2", "a3"], EditingMode.ONE_TAKE.value)
assert clips[0].asset_id == "a1"
assert clips[1].asset_id == "a2"
assert clips[2].asset_id == "a3"
def test_more_assets_than_clips(self):
"""素材多于 clip:多余的不用."""
clips = _make_clips(2)
distribute_assets(clips, ["a1", "a2", "a3"], EditingMode.ONE_TAKE.value)
assert clips[0].asset_id == "a1"
assert clips[1].asset_id == "a2"
def test_fewer_assets_than_clips(self):
"""素材少于 clip:后面的 clip 没素材."""
clips = _make_clips(5)
distribute_assets(clips, ["a1", "a2"], EditingMode.ONE_TAKE.value)
assert clips[0].asset_id == "a1"
assert clips[1].asset_id == "a2"
assert clips[2].asset_id == ""
assert clips[3].asset_id == ""
assert clips[4].asset_id == ""
def test_empty_assets(self):
"""空素材列表:无分配."""
clips = _make_clips(3)
distribute_assets(clips, [], EditingMode.ONE_TAKE.value)
for c in clips:
assert c.asset_id == ""
def test_empty_clips(self):
"""空 clip 列表:不报错."""
distribute_assets([], ["a1"], EditingMode.ONE_TAKE.value)
def test_only_main_clips_get_assigned(self):
"""只分配给 MAIN 类型 clip,其他类型不受影响."""
clips = _make_clips(2) + _make_clips(2, "intro") + _make_clips(2, "outro")
distribute_assets(clips, ["a1", "a2", "a3", "a4"], EditingMode.ONE_TAKE.value)
mains = [c for c in clips if c.clip_type == "main"]
others = [c for c in clips if c.clip_type != "main"]
assert mains[0].asset_id == "a1"
assert mains[1].asset_id == "a2"
for c in others:
assert c.asset_id == ""
# ── distribute_assets: PIP ──────────────────────────────────────────
class TestDistributePip:
"""PIP 模式素材分配."""
def test_basic_pip_distribution(self):
"""第1个素材给 main,其余给 overlay."""
clips = _make_clips(1) + _make_clips(3, "overlay")
distribute_assets(clips, ["a1", "a2", "a3", "a4"], EditingMode.PIP.value)
mains = [c for c in clips if c.clip_type == "main"]
overlays = [c for c in clips if c.clip_type == "overlay"]
assert mains[0].asset_id == "a1"
assert overlays[0].asset_id == "a2"
assert overlays[1].asset_id == "a3"
assert overlays[2].asset_id == "a4"
def test_single_asset_only_main(self):
"""只有1个素材:只分配给 main,overlay 没素材."""
clips = _make_clips(1) + _make_clips(2, "overlay")
distribute_assets(clips, ["a1"], EditingMode.PIP.value)
mains = [c for c in clips if c.clip_type == "main"]
overlays = [c for c in clips if c.clip_type == "overlay"]
assert mains[0].asset_id == "a1"
assert overlays[0].asset_id == ""
assert overlays[1].asset_id == ""
def test_more_overlays_than_assets(self):
"""overlay 多于剩余素材:后面的 overlay 没素材."""
clips = _make_clips(1) + _make_clips(5, "overlay")
distribute_assets(clips, ["a1", "a2", "a3"], EditingMode.PIP.value)
overlays = [c for c in clips if c.clip_type == "overlay"]
assert overlays[0].asset_id == "a2"
assert overlays[1].asset_id == "a3"
assert overlays[2].asset_id == ""
assert overlays[3].asset_id == ""
assert overlays[4].asset_id == ""
def test_no_main_clip(self):
"""没有 main clip:第1个素材没人拿,overlay 从第2个素材开始."""
clips = _make_clips(3, "overlay")
distribute_assets(clips, ["a1", "a2", "a3"], EditingMode.PIP.value)
overlays = [c for c in clips if c.clip_type == "overlay"]
# PIP 逻辑:先给 main 分配第1个素材(没有 main 则跳过),
# 剩余从第2个开始分配给 overlay
assert overlays[0].asset_id == "a2"
assert overlays[1].asset_id == "a3"
assert overlays[2].asset_id == ""
# ── distribute_assets: VOICE_OVER ───────────────────────────────────
class TestDistributeVoiceOver:
"""VOICE_OVER 模式素材分配."""
def test_voice_over_same_as_one_take(self):
"""VOICE_OVER 和 ONE_TAKE 分配策略相同:按顺序给 main."""
clips = _make_clips(3)
assets = ["a1", "a2", "a3"]
distribute_assets(clips, assets, EditingMode.VOICE_OVER.value)
assert clips[0].asset_id == "a1"
assert clips[1].asset_id == "a2"
assert clips[2].asset_id == "a3"
def test_voice_over_fewer_assets(self):
"""素材不足时,后面的 main clip 没素材."""
clips = _make_clips(5)
distribute_assets(clips, ["a1"], EditingMode.VOICE_OVER.value)
assert clips[0].asset_id == "a1"
assert clips[1].asset_id == ""
# ── distribute_assets: VOICE_PIP ────────────────────────────────────
class TestDistributeVoicePip:
"""VOICE_PIP 模式素材分配."""
def test_three_assets_full_distribution(self):
"""3个素材:background + corner_voice + b_roll 各一个."""
clips = (
_make_clips(1, "background")
+ _make_clips(1, "corner_voice")
+ _make_clips(1, "b_roll")
)
distribute_assets(clips, ["a1", "a2", "a3"], EditingMode.VOICE_PIP.value)
bgs = [c for c in clips if c.clip_type == "background"]
voices = [c for c in clips if c.clip_type == "corner_voice"]
brolls = [c for c in clips if c.clip_type == "b_roll"]
assert bgs[0].asset_id == "a1"
assert voices[0].asset_id == "a2"
assert brolls[0].asset_id == "a3"
def test_single_asset_only_background(self):
"""1个素材:只分配给 background."""
clips = (
_make_clips(1, "background")
+ _make_clips(1, "corner_voice")
+ _make_clips(2, "b_roll")
)
distribute_assets(clips, ["a1"], EditingMode.VOICE_PIP.value)
assert clips[0].asset_id == "a1"
assert clips[1].asset_id == ""
assert clips[2].asset_id == ""
assert clips[3].asset_id == ""
def test_two_assets_bg_and_voice(self):
"""2个素材:background + corner_voice."""
clips = (
_make_clips(1, "background")
+ _make_clips(1, "corner_voice")
+ _make_clips(2, "b_roll")
)
distribute_assets(clips, ["a1", "a2"], EditingMode.VOICE_PIP.value)
bgs = [c for c in clips if c.clip_type == "background"]
voices = [c for c in clips if c.clip_type == "corner_voice"]
brolls = [c for c in clips if c.clip_type == "b_roll"]
assert bgs[0].asset_id == "a1"
assert voices[0].asset_id == "a2"
assert brolls[0].asset_id == ""
def test_many_broll_clips(self):
"""多个 b_roll clip:按顺序分配剩余素材."""
clips = (
_make_clips(1, "background")
+ _make_clips(1, "corner_voice")
+ _make_clips(5, "b_roll")
)
distribute_assets(
clips,
["a1", "a2", "a3", "a4", "a5"],
EditingMode.VOICE_PIP.value,
)
brolls = [c for c in clips if c.clip_type == "b_roll"]
assert brolls[0].asset_id == "a3"
assert brolls[1].asset_id == "a4"
assert brolls[2].asset_id == "a5"
assert brolls[3].asset_id == ""
assert brolls[4].asset_id == ""
def test_missing_some_layer_clips(self):
"""缺少某些层的 clip 不影响其他层."""
# 没有 corner_voice,素材应该按顺序:bg 拿 a1b_roll 从 a2 开始
clips = _make_clips(1, "background") + _make_clips(3, "b_roll")
distribute_assets(clips, ["a1", "a2", "a3"], EditingMode.VOICE_PIP.value)
bgs = [c for c in clips if c.clip_type == "background"]
brolls = [c for c in clips if c.clip_type == "b_roll"]
assert bgs[0].asset_id == "a1"
# 没有 corner_voiceb_roll 从第2个素材开始
assert brolls[0].asset_id == "a2"
assert brolls[1].asset_id == "a3"
# ── distribute_assets: 边缘情况 ─────────────────────────────────────
class TestDistributeEdgeCases:
"""素材分配边缘情况."""
def test_unknown_mode_falls_back_to_one_take(self):
"""未知模式退化为 ONE_TAKE."""
clips = _make_clips(3)
distribute_assets(clips, ["a1", "a2", "a3"], "unknown_mode")
assert clips[0].asset_id == "a1"
assert clips[1].asset_id == "a2"
assert clips[2].asset_id == "a3"
def test_both_empty(self):
"""两边都空:不报错."""
distribute_assets([], [], EditingMode.ONE_TAKE.value)
# ── map_clip_types_for_mode ─────────────────────────────────────────
class TestMapClipTypesForMode:
"""clip_type 按模式映射."""
def test_one_take_unchanged(self):
"""ONE_TAKE 模式:main 保持 main."""
clips = _make_clips(5)
map_clip_types_for_mode(clips, EditingMode.ONE_TAKE.value)
for c in clips:
assert c.clip_type == "main"
def test_voice_over_unchanged(self):
"""VOICE_OVER 模式:main 保持 main."""
clips = _make_clips(5)
map_clip_types_for_mode(clips, EditingMode.VOICE_OVER.value)
for c in clips:
assert c.clip_type == "main"
def test_pip_first_main_stays_rest_become_overlay(self):
"""PIP 模式:第1个 main 保持,其余变 overlay."""
clips = _make_clips(5)
map_clip_types_for_mode(clips, EditingMode.PIP.value)
assert clips[0].clip_type == "main"
assert clips[1].clip_type == "overlay"
assert clips[2].clip_type == "overlay"
assert clips[3].clip_type == "overlay"
assert clips[4].clip_type == "overlay"
def test_pip_single_main_unchanged(self):
"""PIP 模式只有1个 main:保持 main."""
clips = _make_clips(1)
map_clip_types_for_mode(clips, EditingMode.PIP.value)
assert clips[0].clip_type == "main"
def test_voice_pip_three_types(self):
"""VOICE_PIP 模式:background + corner_voice + b_roll."""
clips = _make_clips(5)
map_clip_types_for_mode(clips, EditingMode.VOICE_PIP.value)
assert clips[0].clip_type == "background"
assert clips[1].clip_type == "corner_voice"
assert clips[2].clip_type == "b_roll"
assert clips[3].clip_type == "b_roll"
assert clips[4].clip_type == "b_roll"
def test_voice_pip_one_main(self):
"""VOICE_PIP 只有1个 main:变成 background."""
clips = _make_clips(1)
map_clip_types_for_mode(clips, EditingMode.VOICE_PIP.value)
assert clips[0].clip_type == "background"
def test_voice_pip_two_mains(self):
"""VOICE_PIP 2个 mainbackground + corner_voice."""
clips = _make_clips(2)
map_clip_types_for_mode(clips, EditingMode.VOICE_PIP.value)
assert clips[0].clip_type == "background"
assert clips[1].clip_type == "corner_voice"
def test_non_main_clips_unchanged(self):
"""非 MAIN 类型 clip 不受影响."""
clips = (
_make_clips(1, "intro")
+ _make_clips(3) # main
+ _make_clips(1, "outro")
)
map_clip_types_for_mode(clips, EditingMode.PIP.value)
assert clips[0].clip_type == "intro"
assert clips[1].clip_type == "main" # 第1个 main
assert clips[2].clip_type == "overlay" # 第2个 main → overlay
assert clips[3].clip_type == "overlay" # 第3个 main → overlay
assert clips[4].clip_type == "outro"
def test_no_main_clips_noop(self):
"""没有 main clip:什么都不做."""
clips = _make_clips(3, "intro")
original_types = [c.clip_type for c in clips]
map_clip_types_for_mode(clips, EditingMode.PIP.value)
assert [c.clip_type for c in clips] == original_types
def test_empty_clips_noop(self):
"""空列表:不报错."""
map_clip_types_for_mode([], EditingMode.PIP.value)
# ── generate_default_clips ──────────────────────────────────────────
class TestGenerateDefaultClips:
"""默认 clip 生成."""
def test_one_take_normal(self):
"""ONE_TAKEN 个 main clip."""
clips = generate_default_clips("plan1", EditingMode.ONE_TAKE.value, 5)
assert len(clips) == 5
for c in clips:
assert c.clip_type == "main"
assert c.plan_id == "plan1"
assert c.duration == DEFAULT_CLIP_DURATION
# order 递增
for i in range(5):
assert clips[i].order == i
def test_pip_structure(self):
"""PIP1个 main + (N-1)个 overlay."""
clips = generate_default_clips("plan1", EditingMode.PIP.value, 4)
assert len(clips) == 4
assert clips[0].clip_type == "main"
assert clips[1].clip_type == "overlay"
assert clips[2].clip_type == "overlay"
assert clips[3].clip_type == "overlay"
assert clips[0].order == 0
assert clips[3].order == 3
def test_pip_single_asset(self):
"""PIP 只有1个素材:1个 main,没有 overlay."""
clips = generate_default_clips("plan1", EditingMode.PIP.value, 1)
assert len(clips) == 1
assert clips[0].clip_type == "main"
def test_voice_over_structure(self):
"""VOICE_OVERN 个 main clip,带 b_roll 标记."""
clips = generate_default_clips("plan1", EditingMode.VOICE_OVER.value, 3)
assert len(clips) == 3
for c in clips:
assert c.clip_type == "main"
assert c.config.get("role") == "b_roll"
def test_voice_pip_three_layers(self):
"""VOICE_PIPbackground + corner_voice + b_roll."""
clips = generate_default_clips("plan1", EditingMode.VOICE_PIP.value, 5)
assert len(clips) == 5
assert clips[0].clip_type == "background"
assert clips[1].clip_type == "corner_voice"
assert clips[2].clip_type == "b_roll"
assert clips[3].clip_type == "b_roll"
assert clips[4].clip_type == "b_roll"
def test_voice_pip_single_asset(self):
"""VOICE_PIP 1个素材:只有 background."""
clips = generate_default_clips("plan1", EditingMode.VOICE_PIP.value, 1)
assert len(clips) == 1
assert clips[0].clip_type == "background"
def test_voice_pip_two_assets(self):
"""VOICE_PIP 2个素材:background + corner_voice."""
clips = generate_default_clips("plan1", EditingMode.VOICE_PIP.value, 2)
assert len(clips) == 2
assert clips[0].clip_type == "background"
assert clips[1].clip_type == "corner_voice"
def test_zero_assets_at_least_one(self):
"""0 个素材:至少生成 1 个 clip."""
for mode in [
EditingMode.ONE_TAKE.value,
EditingMode.PIP.value,
EditingMode.VOICE_OVER.value,
EditingMode.VOICE_PIP.value,
]:
clips = generate_default_clips("plan1", mode, 0)
assert len(clips) >= 1
def test_unknown_mode_falls_back(self):
"""未知模式退化为 ONE_TAKE 风格."""
clips = generate_default_clips("plan1", "unknown", 3)
assert len(clips) == 3
for c in clips:
assert c.clip_type == "main"
def test_order_is_sequential(self):
"""所有模式下 order 都是从 0 开始连续递增."""
for mode in [
EditingMode.ONE_TAKE.value,
EditingMode.PIP.value,
EditingMode.VOICE_OVER.value,
EditingMode.VOICE_PIP.value,
]:
clips = generate_default_clips("plan1", mode, 5)
for i, c in enumerate(clips):
assert c.order == i
# ── create_clips_from_configs ───────────────────────────────────────
class TestCreateClipsFromConfigs:
"""从模板配置创建 clips."""
def test_basic_creation(self):
"""基本创建:按 order 排序,属性正确传递."""
configs = [
TemplateClipConfig(
id="cfg1",
template_id="tpl1",
clip_type=ClipType.MAIN,
order=1,
min_duration=3.0,
max_duration=7.0,
transition_effect="fade",
),
TemplateClipConfig(
id="cfg2",
template_id="tpl1",
clip_type=ClipType.INTRO,
order=0,
min_duration=2.0,
max_duration=4.0,
transition_effect="cut",
),
]
clips = create_clips_from_configs("plan1", configs)
assert len(clips) == 2
# 按 order 排序:intro(order=0) 在前,main(order=1) 在后
assert clips[0].clip_type == "intro"
assert clips[1].clip_type == "main"
assert clips[0].order == 0
assert clips[1].order == 1
assert clips[0].template_clip_config_id == "cfg2"
assert clips[1].template_clip_config_id == "cfg1"
def test_duration_average_of_min_max(self):
"""min_duration 和 max_duration 都有时,取平均值."""
configs = [
TemplateClipConfig(
id="cfg1",
template_id="tpl1",
clip_type=ClipType.MAIN,
order=0,
min_duration=4.0,
max_duration=6.0,
),
]
clips = create_clips_from_configs("plan1", configs)
assert clips[0].duration == 5.0 # (4+6)/2
def test_duration_only_min(self):
"""只有 min_duration 时,用 min_duration."""
configs = [
TemplateClipConfig(
id="cfg1",
template_id="tpl1",
clip_type=ClipType.MAIN,
order=0,
min_duration=3.5,
max_duration=0,
),
]
clips = create_clips_from_configs("plan1", configs)
assert clips[0].duration == 3.5
def test_duration_only_max(self):
"""只有 max_duration 时,用 max_duration."""
configs = [
TemplateClipConfig(
id="cfg1",
template_id="tpl1",
clip_type=ClipType.MAIN,
order=0,
min_duration=0,
max_duration=8.0,
),
]
clips = create_clips_from_configs("plan1", configs)
assert clips[0].duration == 8.0
def test_duration_default_when_both_zero(self):
"""都为 0 时用默认时长."""
configs = [
TemplateClipConfig(
id="cfg1",
template_id="tpl1",
clip_type=ClipType.MAIN,
order=0,
min_duration=0,
max_duration=0,
),
]
clips = create_clips_from_configs("plan1", configs)
assert clips[0].duration == DEFAULT_CLIP_DURATION
def test_empty_configs_returns_empty(self):
"""空配置列表返回空列表."""
clips = create_clips_from_configs("plan1", [])
assert clips == []
def test_plan_id_passed_through(self):
"""plan_id 正确传递给所有 clip."""
configs = [
TemplateClipConfig(
id=f"cfg{i}",
template_id="tpl1",
clip_type=ClipType.MAIN,
order=i,
min_duration=3,
max_duration=5,
)
for i in range(3)
]
clips = create_clips_from_configs("my_plan", configs)
for c in clips:
assert c.plan_id == "my_plan"
def test_playback_speed_from_config(self):
"""playback_speed 从 config.playback_speed 读取."""
configs = [
TemplateClipConfig(
id="cfg1",
template_id="tpl1",
clip_type=ClipType.MAIN,
order=0,
min_duration=3,
max_duration=5,
config={"playback_speed": 1.5},
),
]
clips = create_clips_from_configs("plan1", configs)
assert clips[0].playback_speed == 1.5
def test_playback_speed_fallback_to_speed_ratio(self):
"""playback_speed 不存在时回退到 speed_ratio."""
configs = [
TemplateClipConfig(
id="cfg1",
template_id="tpl1",
clip_type=ClipType.MAIN,
order=0,
min_duration=3,
max_duration=5,
config={"speed_ratio": 0.8},
),
]
clips = create_clips_from_configs("plan1", configs)
assert clips[0].playback_speed == 0.8
def test_playback_speed_default_1(self):
"""没有 speed 配置时默认为 1.0."""
configs = [
TemplateClipConfig(
id="cfg1",
template_id="tpl1",
clip_type=ClipType.MAIN,
order=0,
min_duration=3,
max_duration=5,
config={},
),
]
clips = create_clips_from_configs("plan1", configs)
assert clips[0].playback_speed == 1.0
def test_playback_speed_none_falls_back(self):
"""playback_speed 为 None 时回退到 1.0."""
configs = [
TemplateClipConfig(
id="cfg1",
template_id="tpl1",
clip_type=ClipType.MAIN,
order=0,
min_duration=3,
max_duration=5,
config={"playback_speed": None},
),
]
clips = create_clips_from_configs("plan1", configs)
assert clips[0].playback_speed == 1.0
def test_transition_default_cut(self):
"""transition_effect 为空时默认为 cut."""
configs = [
TemplateClipConfig(
id="cfg1",
template_id="tpl1",
clip_type=ClipType.MAIN,
order=0,
min_duration=3,
max_duration=5,
transition_effect=None,
),
]
clips = create_clips_from_configs("plan1", configs)
assert clips[0].transition_effect == "cut"
# ── 常量导出 ────────────────────────────────────────────────────────
class TestConstants:
"""常量导出验证."""
def test_default_duration_value(self):
"""默认片段时长应为 5 秒."""
assert DEFAULT_CLIP_DURATION == 5.0
-361
View File
@@ -1,361 +0,0 @@
"""render_layer_utils 模块单元测试."""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any
import pytest
from packages.domain.render_layer_utils import (
LAYER_Z_INDEX,
MAIN_LAYER_ROLES,
PIP_DEFAULT_SCALE,
can_pass_through,
clip_adjusted_duration,
clip_effective_duration,
clip_playback_speed,
estimate_total_duration,
get_layer_z_index,
resolve_layer_role,
)
# ── 辅助数据类 ──────────────────────────────────────────────────────────────
@dataclass
class FakeClip:
duration: float = 0.0
actual_duration: float = 0.0
playback_speed: Any = 1.0
@dataclass
class FakeLayer:
role: str = "main"
clips: list[FakeClip] = field(default_factory=list)
# ── 常量验证 ────────────────────────────────────────────────────────────────
class TestConstants:
def test_layer_z_index_has_expected_keys(self):
assert set(LAYER_Z_INDEX.keys()) == {
"background",
"broll",
"main",
"overlay",
"corner_voice",
"audio",
}
def test_layer_z_index_ordering(self):
assert LAYER_Z_INDEX["background"] < LAYER_Z_INDEX["main"]
assert LAYER_Z_INDEX["main"] == LAYER_Z_INDEX["broll"]
assert LAYER_Z_INDEX["overlay"] > LAYER_Z_INDEX["main"]
assert LAYER_Z_INDEX["corner_voice"] > LAYER_Z_INDEX["main"]
assert LAYER_Z_INDEX["audio"] > LAYER_Z_INDEX["overlay"]
def test_pip_default_scale_positive(self):
assert 0 < PIP_DEFAULT_SCALE < 1
def test_main_layer_roles(self):
assert "main" in MAIN_LAYER_ROLES
assert "broll" in MAIN_LAYER_ROLES
assert "background" in MAIN_LAYER_ROLES
assert "overlay" not in MAIN_LAYER_ROLES
# ── resolve_layer_role ──────────────────────────────────────────────────────
class TestResolveLayerRole:
def test_intro_maps_to_main(self):
assert resolve_layer_role("intro") == "main"
def test_outro_maps_to_main(self):
assert resolve_layer_role("outro") == "main"
def test_overlay_maps_to_overlay(self):
assert resolve_layer_role("overlay") == "overlay"
def test_corner_voice_maps_to_corner_voice(self):
assert resolve_layer_role("corner_voice") == "corner_voice"
def test_background_maps_to_background(self):
assert resolve_layer_role("background") == "background"
def test_b_roll_maps_to_broll(self):
assert resolve_layer_role("b_roll") == "broll"
def test_main_defaults_to_main(self):
assert resolve_layer_role("main") == "main"
def test_main_with_b_roll_role(self):
assert resolve_layer_role("main", {"role": "b_roll"}) == "broll"
def test_main_with_audio_role(self):
assert resolve_layer_role("main", {"role": "audio"}) == "audio"
def test_main_with_other_role_stays_main(self):
assert resolve_layer_role("main", {"role": "overlay"}) == "main"
def test_none_config(self):
assert resolve_layer_role("main", None) == "main"
def test_empty_config(self):
assert resolve_layer_role("main", {}) == "main"
def test_unknown_type_defaults_to_main(self):
assert resolve_layer_role("unknown_type") == "main"
# ── get_layer_z_index ──────────────────────────────────────────────────────
class TestGetLayerZIndex:
def test_known_roles(self):
for role, expected in LAYER_Z_INDEX.items():
assert get_layer_z_index(role) == expected
def test_unknown_role_returns_zero(self):
assert get_layer_z_index("nonexistent") == 0
def test_empty_string_returns_zero(self):
assert get_layer_z_index("") == 0
# ── clip_effective_duration ────────────────────────────────────────────────
class TestClipEffectiveDuration:
def test_explicit_duration_no_actual(self):
assert clip_effective_duration(5.0) == 5.0
def test_explicit_duration_with_shorter_actual(self):
assert clip_effective_duration(5.0, 3.0) == 3.0
def test_explicit_duration_with_longer_actual(self):
assert clip_effective_duration(5.0, 10.0) == 5.0
def test_zero_duration_uses_actual(self):
assert clip_effective_duration(0, 8.0) == 8.0
def test_negative_duration_uses_actual(self):
assert clip_effective_duration(-1.0, 8.0) == 8.0
def test_zero_duration_zero_actual(self):
assert clip_effective_duration(0, 0) == 0.0
def test_no_args_returns_zero(self):
assert clip_effective_duration(0) == 0.0
def test_equal_duration_and_actual(self):
assert clip_effective_duration(5.0, 5.0) == 5.0
# ── clip_playback_speed ────────────────────────────────────────────────────
class TestClipPlaybackSpeed:
def test_normal_speed(self):
assert clip_playback_speed(1.0) == 1.0
def test_fast_speed(self):
assert clip_playback_speed(2.0) == 2.0
def test_slow_speed(self):
assert clip_playback_speed(0.5) == 0.5
def test_zero_speed_defaults_to_one(self):
assert clip_playback_speed(0) == 1.0
def test_negative_speed_defaults_to_one(self):
assert clip_playback_speed(-1.0) == 1.0
def test_none_defaults_to_one(self):
assert clip_playback_speed(None) == 1.0
def test_string_defaults_to_one(self):
assert clip_playback_speed("fast") == 1.0
def test_int_speed(self):
assert clip_playback_speed(2) == 2.0
# ── clip_adjusted_duration ─────────────────────────────────────────────────
class TestClipAdjustedDuration:
def test_normal_speed_same_as_effective(self):
assert clip_adjusted_duration(5.0, 10.0, 1.0) == 5.0
def test_double_speed_half_duration(self):
assert clip_adjusted_duration(10.0, 10.0, 2.0) == pytest.approx(5.0)
def test_half_speed_double_duration(self):
assert clip_adjusted_duration(5.0, 10.0, 0.5) == pytest.approx(10.0)
def test_invalid_speed_uses_default(self):
assert clip_adjusted_duration(5.0, 10.0, 0) == 5.0
def test_zero_duration(self):
assert clip_adjusted_duration(0, 0, 1.0) == 0.0
def test_actual_duration_only(self):
assert clip_adjusted_duration(0, 8.0, 1.0) == 8.0
def test_actual_duration_only_with_speed(self):
assert clip_adjusted_duration(0, 8.0, 2.0) == pytest.approx(4.0)
def test_very_close_to_normal_speed(self):
# 1.0000001 应该被认为接近 1.0,不做除法
result = clip_adjusted_duration(5.0, 10.0, 1.0 + 1e-10)
assert result == 5.0
# ── estimate_total_duration ────────────────────────────────────────────────
class TestEstimateTotalDuration:
def test_empty_layers(self):
assert estimate_total_duration([]) == 0.0
def test_no_main_layer(self):
layers = [FakeLayer(role="overlay", clips=[FakeClip(duration=5.0)])]
assert estimate_total_duration(layers) == 0.0
def test_single_clip_main_layer(self):
layers = [FakeLayer(role="main", clips=[FakeClip(duration=5.0)])]
assert estimate_total_duration(layers) == pytest.approx(5.0)
def test_multiple_clips_no_transition(self):
layers = [
FakeLayer(
role="main",
clips=[
FakeClip(duration=3.0),
FakeClip(duration=2.0),
FakeClip(duration=5.0),
],
)
]
assert estimate_total_duration(layers) == pytest.approx(10.0)
def test_multiple_clips_with_transition(self):
layers = [
FakeLayer(
role="main",
clips=[
FakeClip(duration=3.0),
FakeClip(duration=2.0),
FakeClip(duration=5.0),
],
)
]
# 3 + 2 + 5 - 2 * 0.5 = 9.0
assert estimate_total_duration(layers, transition_duration=0.5) == pytest.approx(9.0)
def test_prefers_main_over_broll(self):
layers = [
FakeLayer(role="broll", clips=[FakeClip(duration=10.0)]),
FakeLayer(role="main", clips=[FakeClip(duration=5.0)]),
]
assert estimate_total_duration(layers) == pytest.approx(5.0)
def test_prefers_broll_over_background(self):
layers = [
FakeLayer(role="background", clips=[FakeClip(duration=10.0)]),
FakeLayer(role="broll", clips=[FakeClip(duration=5.0)]),
]
assert estimate_total_duration(layers) == pytest.approx(5.0)
def test_main_layer_empty_clips(self):
layers = [FakeLayer(role="main", clips=[])]
assert estimate_total_duration(layers) == 0.0
def test_minimum_duration(self):
layers = [
FakeLayer(
role="main",
clips=[
FakeClip(duration=0.01),
FakeClip(duration=0.01),
],
)
]
result = estimate_total_duration(layers, transition_duration=0.5)
assert result >= 0.1
def test_with_playback_speed(self):
layers = [
FakeLayer(
role="main",
clips=[
FakeClip(duration=10.0, playback_speed=2.0),
FakeClip(duration=10.0, playback_speed=0.5),
],
)
]
# 5 + 20 = 25
assert estimate_total_duration(layers) == pytest.approx(25.0)
# ── can_pass_through ──────────────────────────────────────────────────────
class TestCanPassThrough:
def test_single_main_clip_no_effects(self):
layers = [FakeLayer(role="main", clips=[FakeClip(duration=5.0)])]
assert can_pass_through(layers) is True
def test_single_broll_clip(self):
layers = [FakeLayer(role="broll", clips=[FakeClip(duration=5.0)])]
assert can_pass_through(layers) is True
def test_single_background_clip(self):
layers = [FakeLayer(role="background", clips=[FakeClip(duration=5.0)])]
assert can_pass_through(layers) is True
def test_multiple_layers(self):
layers = [
FakeLayer(role="main", clips=[FakeClip(duration=5.0)]),
FakeLayer(role="overlay", clips=[FakeClip(duration=3.0)]),
]
assert can_pass_through(layers) is False
def test_overlay_layer(self):
layers = [FakeLayer(role="overlay", clips=[FakeClip(duration=5.0)])]
assert can_pass_through(layers) is False
def test_multiple_clips_in_layer(self):
layers = [
FakeLayer(
role="main",
clips=[
FakeClip(duration=3.0),
FakeClip(duration=2.0),
],
)
]
assert can_pass_through(layers) is False
def test_with_stickers(self):
layers = [FakeLayer(role="main", clips=[FakeClip(duration=5.0)])]
assert can_pass_through(layers, has_stickers=True) is False
def test_with_watermark(self):
layers = [FakeLayer(role="main", clips=[FakeClip(duration=5.0)])]
assert can_pass_through(layers, has_watermark=True) is False
def test_with_stickers_and_watermark(self):
layers = [FakeLayer(role="main", clips=[FakeClip(duration=5.0)])]
assert can_pass_through(layers, has_stickers=True, has_watermark=True) is False
def test_empty_layer_list(self):
assert can_pass_through([]) is False
def test_audio_layer_only(self):
layers = [FakeLayer(role="audio", clips=[FakeClip(duration=5.0)])]
assert can_pass_through(layers) is False
-227
View File
@@ -1,227 +0,0 @@
"""ReverseEngine 纯逻辑单测 — 配置解析 + 滤镜构建 + 安全限制.
全纯函数测试,不依赖 FFmpeg。
"""
from __future__ import annotations
from unittest.mock import patch
import pytest
from video_processing.reverse_engine import ReverseConfig, ReverseEngine
# ── ReverseConfig 解析 ────────────────────────────────────────────────────
class TestReverseConfigFromDict:
"""ReverseConfig.from_dict 配置解析测试."""
def test_none_returns_disabled(self):
"""None 输入返回 disabled 默认配置."""
config = ReverseConfig.from_dict(None)
assert config.enabled is False
assert config.reverse_video is True
assert config.reverse_audio is True
def test_empty_dict_returns_disabled(self):
"""空 dict 返回 disabled."""
config = ReverseConfig.from_dict({})
assert config.enabled is False
def test_enabled_false_returns_disabled(self):
"""显式 enabled=False."""
config = ReverseConfig.from_dict({"enabled": False})
assert config.enabled is False
def test_enabled_default_flags_default_video_audio(self):
"""只启用时默认视频音频都倒放."""
config = ReverseConfig.from_dict({"enabled": True})
assert config.enabled is True
assert config.reverse_video is True
assert config.reverse_audio is True
def test_video_only(self):
"""只倒放视频."""
config = ReverseConfig.from_dict(
{
"enabled": True,
"reverse_video": True,
"reverse_audio": False,
}
)
assert config.enabled is True
assert config.reverse_video is True
assert config.reverse_audio is False
def test_audio_only(self):
"""只倒放音频."""
config = ReverseConfig.from_dict(
{
"enabled": True,
"reverse_video": False,
"reverse_audio": True,
}
)
assert config.enabled is True
assert config.reverse_video is False
assert config.reverse_audio is True
def test_both_disabled_but_enabled_flag_true(self):
"""enabled=True 但两个子选项都关了(边缘情况)."""
config = ReverseConfig.from_dict(
{
"enabled": True,
"reverse_video": False,
"reverse_audio": False,
}
)
assert config.enabled is True
assert config.reverse_video is False
assert config.reverse_audio is False
def test_invalid_type_falls_back(self):
"""非 dict 类型回退到默认 disabled."""
config = ReverseConfig.from_dict("reverse=true")
assert config.enabled is False
def test_attribute_error_falls_back(self):
"""属性错误时回退到默认."""
class WeirdObj:
def get(self, key, default=None):
raise AttributeError("nope")
config = ReverseConfig.from_dict(WeirdObj())
assert config.enabled is False
def test_type_error_falls_back(self):
"""类型错误时回退到默认."""
config = ReverseConfig.from_dict([1, 2, 3])
assert config.enabled is False
# ── ReverseEngine 视频滤镜 ──────────────────────────────────────────────────
class TestReverseEngineBuildVideoFilter:
"""ReverseEngine.build_video_filter 视频滤镜测试."""
def test_disabled_returns_empty(self):
"""disabled 返回空字符串."""
config = ReverseConfig(enabled=False)
assert ReverseEngine.build_video_filter(config) == ""
def test_enabled_returns_reverse(self):
"""启用返回 reverse 滤镜."""
config = ReverseConfig(enabled=True, reverse_video=True)
assert ReverseEngine.build_video_filter(config) == "reverse"
def test_video_disabled_returns_empty(self):
"""reverse_video=False 返回空."""
config = ReverseConfig(enabled=True, reverse_video=False, reverse_audio=True)
assert ReverseEngine.build_video_filter(config) == ""
def test_short_duration_ok(self):
"""短时长正常返回 reverse."""
config = ReverseConfig(enabled=True)
result = ReverseEngine.build_video_filter(config, duration=10.0)
assert result == "reverse"
def test_exactly_max_duration_ok(self):
"""恰好等于安全上限正常."""
config = ReverseConfig(enabled=True)
result = ReverseEngine.build_video_filter(config, duration=ReverseEngine.MAX_SAFE_DURATION)
assert result == "reverse"
def test_over_max_duration_skipped(self):
"""超过安全时长跳过倒放."""
config = ReverseConfig(enabled=True)
result = ReverseEngine.build_video_filter(config, duration=200.0)
assert result == ""
def test_zero_duration_ok(self):
"""零时长正常倒放."""
config = ReverseConfig(enabled=True)
result = ReverseEngine.build_video_filter(config, duration=0.0)
assert result == "reverse"
# ── ReverseEngine 音频滤镜 ──────────────────────────────────────────────
class TestReverseEngineBuildAudioFilter:
"""ReverseEngine.build_audio_filter 音频滤镜测试."""
def test_disabled_returns_empty(self):
"""disabled 返回空字符串."""
config = ReverseConfig(enabled=False)
assert ReverseEngine.build_audio_filter(config) == ""
def test_enabled_returns_areverse(self):
"""启用返回 areverse 滤镜."""
config = ReverseConfig(enabled=True, reverse_audio=True)
assert ReverseEngine.build_audio_filter(config) == "areverse"
def test_audio_disabled_returns_empty(self):
"""reverse_audio=False 返回空."""
config = ReverseConfig(enabled=True, reverse_video=True, reverse_audio=False)
assert ReverseEngine.build_audio_filter(config) == ""
def test_short_duration_ok(self):
"""短时长正常返回 areverse."""
config = ReverseConfig(enabled=True)
result = ReverseEngine.build_audio_filter(config, duration=30.0)
assert result == "areverse"
def test_over_max_duration_skipped(self):
"""超过安全时长跳过音频倒放."""
config = ReverseConfig(enabled=True)
result = ReverseEngine.build_audio_filter(config, duration=150.0)
assert result == ""
def test_exactly_max_duration_ok(self):
"""恰好等于安全上限正常."""
config = ReverseConfig(enabled=True)
result = ReverseEngine.build_audio_filter(config, duration=ReverseEngine.MAX_SAFE_DURATION)
assert result == "areverse"
# ── 组合场景 ────────────────────────────────────────────────────────────
class TestReverseEngineCombined:
"""组合场景测试."""
def test_both_video_audio_reverse(self):
"""视频音频都倒放."""
config = ReverseConfig(enabled=True)
vf = ReverseEngine.build_video_filter(config)
af = ReverseEngine.build_audio_filter(config)
assert vf == "reverse"
assert af == "areverse"
def test_neither_video_nor_audio(self):
"""都不倒放."""
config = ReverseConfig(enabled=True, reverse_video=False, reverse_audio=False)
assert ReverseEngine.build_video_filter(config) == ""
assert ReverseEngine.build_audio_filter(config) == ""
def test_long_video_both_skipped(self):
"""超长视频两个都跳过."""
config = ReverseConfig(enabled=True)
duration = ReverseEngine.MAX_SAFE_DURATION + 1
assert ReverseEngine.build_video_filter(config, duration=duration) == ""
assert ReverseEngine.build_audio_filter(config, duration=duration) == ""
def test_from_dict_full_config_flow(self):
"""从 dict 解析到滤镜构建的完整流程."""
data = {"enabled": True, "reverse_video": True, "reverse_audio": False}
config = ReverseConfig.from_dict(data)
assert ReverseEngine.build_video_filter(config, duration=10) == "reverse"
assert ReverseEngine.build_audio_filter(config, duration=10) == ""
def test_from_dict_disabled_flow(self):
"""disabled 配置完整流程."""
config = ReverseConfig.from_dict({"enabled": False})
assert ReverseEngine.build_video_filter(config) == ""
assert ReverseEngine.build_audio_filter(config) == ""
-403
View File
@@ -1,403 +0,0 @@
"""SpeedEngine 纯逻辑单测 — 配置解析 + 调速滤镜 + 时长计算.
全纯函数测试,不依赖 FFmpeg 或外部服务。
"""
from __future__ import annotations
import pytest
from video_processing.speed_engine import (
MAX_SPEED,
MIN_SPEED,
SpeedConfig,
SpeedEngine,
)
# ── SpeedConfig 解析 ──────────────────────────────────────────────────────
class TestSpeedConfigParse:
"""SpeedConfig.from_dict / parse 解析测试."""
def test_none_data_returns_default(self):
"""None 输入返回默认配置."""
config = SpeedConfig.parse(None)
assert config.speed == 1.0
assert config.pitch_correct is True
def test_empty_dict_returns_default(self):
"""空 dict 返回默认配置."""
config = SpeedConfig.parse({})
assert config.speed == 1.0
assert config.pitch_correct is True
def test_valid_speed_and_pitch(self):
"""正常速度和音调配置."""
config = SpeedConfig.parse({"speed": 2.0, "pitch_correct": False})
assert config.speed == 2.0
assert config.pitch_correct is False
def test_speed_as_int(self):
"""整数 speed 自动转 float."""
config = SpeedConfig.parse({"speed": 2})
assert config.speed == 2.0
assert isinstance(config.speed, float)
def test_invalid_speed_type_falls_back(self):
"""speed 类型错误回退到默认."""
config = SpeedConfig.parse({"speed": "fast"})
assert config.speed == 1.0
def test_invalid_pitch_correct_type_falls_back(self):
"""pitch_correct 非 bool 回退到 True."""
config = SpeedConfig.parse({"pitch_correct": "yes"})
assert config.pitch_correct is True
def test_non_dict_input_falls_back(self):
"""非 dict 输入回退到默认."""
config = SpeedConfig.parse("speed=2x")
assert config.speed == 1.0
assert config.pitch_correct is True
class TestSpeedConfigClamp:
"""SpeedConfig.clamp 钳制测试."""
def test_zero_speed_clamps_to_default(self):
"""speed=0 钳制到默认 1.0."""
config = SpeedConfig(speed=0.0)
config.clamp()
assert config.speed == 1.0
def test_negative_speed_clamps_to_default(self):
"""负速度钳制到默认 1.0."""
config = SpeedConfig(speed=-1.0)
config.clamp()
assert config.speed == 1.0
def test_below_min_clamps_to_min(self):
"""低于最小速度钳制到 MIN_SPEED."""
config = SpeedConfig(speed=0.1)
config.clamp()
assert config.speed == MIN_SPEED
def test_at_min_stays(self):
"""恰好在最小值保持不变."""
config = SpeedConfig(speed=MIN_SPEED)
config.clamp()
assert config.speed == MIN_SPEED
def test_above_max_clamps_to_max(self):
"""超过最大速度钳制到 MAX_SPEED."""
config = SpeedConfig(speed=5.0)
config.clamp()
assert config.speed == MAX_SPEED
def test_at_max_stays(self):
"""恰好在最大值保持不变."""
config = SpeedConfig(speed=MAX_SPEED)
config.clamp()
assert config.speed == MAX_SPEED
def test_normal_speed_stays(self):
"""正常范围内速度保持不变."""
config = SpeedConfig(speed=1.5)
config.clamp()
assert config.speed == 1.5
def test_parse_auto_clamps(self):
"""parse 自动执行 clamp."""
config = SpeedConfig.parse({"speed": 10.0})
assert config.speed == MAX_SPEED
class TestSpeedConfigIsOriginal:
"""SpeedConfig.is_original 属性测试."""
def test_default_is_original(self):
"""默认配置为原速."""
assert SpeedConfig().is_original is True
def test_exactly_one_is_original(self):
"""speed=1.0 为原速."""
assert SpeedConfig(speed=1.0).is_original is True
def test_very_close_is_original(self):
"""浮点精度接近 1.0 视为原速."""
assert SpeedConfig(speed=1.0000001).is_original is True
def test_different_speed_not_original(self):
"""非 1.0 速度不是原速."""
assert SpeedConfig(speed=2.0).is_original is False
assert SpeedConfig(speed=0.5).is_original is False
# ── SpeedEngine 滤镜构建 ──────────────────────────────────────────────────
class TestSpeedEngineBuildVideoFilter:
"""SpeedEngine.build_video_filter 视频滤镜测试."""
def setup_method(self):
self.engine = SpeedEngine()
def test_original_speed_returns_empty(self):
"""原速返回空字符串(无滤镜)."""
config = SpeedConfig(speed=1.0)
assert self.engine.build_video_filter(config) == ""
def test_double_speed(self):
"""2倍速 setpts=PTS/2."""
config = SpeedConfig(speed=2.0)
result = self.engine.build_video_filter(config)
assert result == "setpts=PTS/2.0000"
def test_half_speed(self):
"""0.5倍速 setpts=PTS/0.5."""
config = SpeedConfig(speed=0.5)
result = self.engine.build_video_filter(config)
assert result == "setpts=PTS/0.5000"
def test_quarter_speed(self):
"""0.25倍速."""
config = SpeedConfig(speed=0.25)
result = self.engine.build_video_filter(config)
assert "setpts=PTS/0.25" in result
def test_quad_speed(self):
"""4倍速."""
config = SpeedConfig(speed=4.0)
result = self.engine.build_video_filter(config)
assert "setpts=PTS/4.0" in result
def test_custom_speed_precision(self):
"""自定义速度保留4位小数."""
config = SpeedConfig(speed=1.333)
result = self.engine.build_video_filter(config)
assert result == "setpts=PTS/1.3330"
class TestSpeedEngineBuildAudioFilter:
"""SpeedEngine.build_audio_filter 音频滤镜测试."""
def setup_method(self):
self.engine = SpeedEngine()
def test_original_speed_returns_empty(self):
"""原速返回空字符串."""
config = SpeedConfig(speed=1.0)
assert self.engine.build_audio_filter(config) == ""
def test_within_single_stage_range(self):
"""单级 atempo 范围内返回一级."""
config = SpeedConfig(speed=1.5)
result = self.engine.build_audio_filter(config)
assert result == "atempo=1.5000"
def test_at_max_single_stage(self):
"""恰好 2.0 单级."""
config = SpeedConfig(speed=2.0)
result = self.engine.build_audio_filter(config)
assert result == "atempo=2.0000"
def test_at_min_single_stage(self):
"""恰好 0.5 单级."""
config = SpeedConfig(speed=0.5)
result = self.engine.build_audio_filter(config)
assert result == "atempo=0.5000"
def test_quad_speed_two_stages(self):
"""4倍速 = atempo=2.0,atempo=2.0."""
config = SpeedConfig(speed=4.0)
result = self.engine.build_audio_filter(config)
stages = result.split(",")
assert len(stages) == 2
assert stages[0] == "atempo=2.0000"
assert stages[1] == "atempo=2.0000"
def test_quarter_speed_two_stages(self):
"""0.25倍速 = atempo=0.5,atempo=0.5."""
config = SpeedConfig(speed=0.25)
result = self.engine.build_audio_filter(config)
stages = result.split(",")
assert len(stages) == 2
assert stages[0] == "atempo=0.5000"
assert stages[1] == "atempo=0.5000"
def test_triple_speed_two_stages(self):
"""3倍速 = atempo=2.0,atempo=1.5 (2.0 * 1.5 = 3.0)."""
config = SpeedConfig(speed=3.0)
result = self.engine.build_audio_filter(config)
stages = result.split(",")
assert len(stages) == 2
# 乘积应为 3.0
values = [float(s.split("=")[1]) for s in stages]
product = 1.0
for v in values:
product *= v
assert abs(product - 3.0) < 0.01
def test_low_speed_two_stages(self):
"""0.3倍速多级串联,乘积为 0.3."""
config = SpeedConfig(speed=0.3)
result = self.engine.build_audio_filter(config)
stages = result.split(",")
assert len(stages) >= 2
values = [float(s.split("=")[1]) for s in stages]
product = 1.0
for v in values:
product *= v
assert abs(product - 0.3) < 0.01
def test_all_stages_within_valid_range(self):
"""所有 atempo 级都在 [0.5, 2.0] 范围内."""
for speed in [0.25, 0.3, 0.5, 0.8, 1.5, 2.0, 3.0, 4.0]:
config = SpeedConfig(speed=speed)
result = self.engine.build_audio_filter(config)
if not result:
continue
stages = result.split(",")
for stage in stages:
val = float(stage.split("=")[1])
assert 0.5 <= val <= 2.0, f"speed={speed}, stage={val} out of range"
class TestSpeedEngineSplitAtempoStages:
"""SpeedEngine._split_atempo_stages 拆分算法测试."""
def test_single_stage_within_range(self):
"""范围内单级."""
stages = SpeedEngine._split_atempo_stages(1.5)
assert stages == [1.5]
def test_exactly_max_single_stage(self):
"""恰好 2.0 单级."""
stages = SpeedEngine._split_atempo_stages(2.0)
assert stages == [2.0]
def test_exactly_min_single_stage(self):
"""恰好 0.5 单级."""
stages = SpeedEngine._split_atempo_stages(0.5)
assert stages == [0.5]
def test_four_x_two_stages(self):
"""4.0 拆为两级 2.0."""
stages = SpeedEngine._split_atempo_stages(4.0)
assert stages == [2.0, 2.0]
def test_quarter_x_two_stages(self):
"""0.25 拆为两级 0.5."""
stages = SpeedEngine._split_atempo_stages(0.25)
assert stages == [0.5, 0.5]
def test_product_matches_original_speed(self):
"""拆分后乘积应等于原速度."""
for speed in [0.25, 0.3, 0.5, 0.8, 1.0, 1.5, 2.0, 2.5, 3.0, 4.0]:
stages = SpeedEngine._split_atempo_stages(speed)
product = 1.0
for s in stages:
product *= s
assert abs(product - speed) < 0.001, f"speed={speed}, product={product}"
class TestSpeedEngineAdjustDuration:
"""SpeedEngine.adjust_duration 时长计算测试."""
def setup_method(self):
self.engine = SpeedEngine()
def test_original_speed_same_duration(self):
"""原速时长不变."""
config = SpeedConfig(speed=1.0)
assert self.engine.adjust_duration(10.0, config) == 10.0
def test_double_speed_half_duration(self):
"""2倍速时长减半."""
config = SpeedConfig(speed=2.0)
assert self.engine.adjust_duration(10.0, config) == 5.0
def test_half_speed_double_duration(self):
"""0.5倍速时长加倍."""
config = SpeedConfig(speed=0.5)
assert self.engine.adjust_duration(10.0, config) == 20.0
def test_zero_duration_stays_zero(self):
"""零时长保持零."""
config = SpeedConfig(speed=2.0)
assert self.engine.adjust_duration(0.0, config) == 0.0
def test_negative_duration_stays(self):
"""负时长直接返回(不做调速)."""
config = SpeedConfig(speed=2.0)
assert self.engine.adjust_duration(-5.0, config) == -5.0
def test_quad_speed_quarter_duration(self):
"""4倍速时长为1/4."""
config = SpeedConfig(speed=4.0)
assert self.engine.adjust_duration(20.0, config) == 5.0
class TestSpeedEngineBuildClipSpeedFilter:
"""SpeedEngine.build_clip_speed_filter 便捷方法测试."""
def setup_method(self):
self.engine = SpeedEngine()
def test_default_speed_returns_empty_filters(self):
"""默认速度返回空滤镜."""
vf, af, config = self.engine.build_clip_speed_filter(1.0)
assert vf == ""
assert af == ""
assert config.is_original is True
def test_double_speed_filters(self):
"""2倍速返回对应滤镜."""
vf, af, config = self.engine.build_clip_speed_filter(2.0)
assert vf == "setpts=PTS/2.0000"
assert af == "atempo=2.0000"
assert config.speed == 2.0
def test_speed_gets_clamped(self):
"""超范围速度自动钳制."""
vf, af, config = self.engine.build_clip_speed_filter(10.0)
assert config.speed == MAX_SPEED
assert "setpts" in vf
def test_pitch_correct_false_still_has_audio_filter(self):
"""pitch_correct=False 也返回音频滤镜(只是方式不同,当前实现仍用atempo)."""
vf, af, config = self.engine.build_clip_speed_filter(2.0, pitch_correct=False)
assert config.pitch_correct is False
# 当前实现 pitch_correct 不影响滤镜输出(atempo 本身保持音调)
assert "atempo" in af
class TestSpeedEngineResolveClipSpeed:
"""SpeedEngine.resolve_clip_speed 速度解析测试."""
def test_no_clip_config_uses_global(self):
"""无 clip config 使用全局速度."""
assert SpeedEngine.resolve_clip_speed(None, 2.0) == 2.0
def test_empty_config_uses_global(self):
"""空 config 使用全局速度."""
assert SpeedEngine.resolve_clip_speed({}, 1.5) == 1.5
def test_zero_speed_uses_global(self):
"""playback_speed=0 使用全局."""
assert SpeedEngine.resolve_clip_speed({"playback_speed": 0}, 2.0) == 2.0
def test_valid_clip_speed(self):
"""有效 clip 速度优先."""
assert SpeedEngine.resolve_clip_speed({"playback_speed": 1.5}, 1.0) == 1.5
def test_invalid_speed_type_uses_global(self):
"""速度类型错误回退到全局."""
assert SpeedEngine.resolve_clip_speed({"playback_speed": "fast"}, 2.0) == 2.0
def test_negative_speed_uses_global(self):
"""负速度回退到全局."""
assert SpeedEngine.resolve_clip_speed({"playback_speed": -1}, 1.0) == 1.0
def test_default_global_is_one(self):
"""默认全局速度为 1.0."""
assert SpeedEngine.resolve_clip_speed({}) == 1.0
-10
View File
@@ -244,16 +244,6 @@ class TestWrapText:
result = _wrap_text(text, 1)
assert result == ["a", "b", "c"]
def test_max_chars_zero(self):
# 边界情况
text = "abc"
result = _wrap_text(text, 0)
# 0的话,max_chars//2也是0range不会执行
# 按逻辑 len(text) > 0 成立,但 break_point 从 0 开始
# 这取决于具体实现,只要不崩溃就行
assert isinstance(result, list)
assert len(result) > 0
def test_punctuation_at_boundary(self):
# 标点刚好在 max_chars 位置
text = "一二三四五六七八九。"
-364
View File
@@ -1,364 +0,0 @@
"""VideoProcessor 纯逻辑单测 — 数据类 + 输入校验 + 解析逻辑.
通过 mock ffmpeg-python 库验证纯逻辑部分,
不实际执行 FFmpeg,确保测试轻量快速。
"""
from __future__ import annotations
import os
from dataclasses import fields
from unittest.mock import MagicMock, patch
import pytest
from video_processing.processor import VideoProcessor, VideoResult
class TestVideoResultDataclass:
"""VideoResult 数据类测试."""
def test_all_fields_exist(self):
"""所有字段都存在."""
field_names = {f.name for f in fields(VideoResult)}
expected = {
"output_path",
"thumbnail_path",
"duration",
"width",
"height",
"fps",
"file_size",
}
assert expected.issubset(field_names)
def test_default_construction(self):
"""正常构造 VideoResult."""
result = VideoResult(
output_path="/tmp/out.mp4",
thumbnail_path="/tmp/out.jpg",
duration=10.5,
width=1920,
height=1080,
fps=25.0,
file_size=1024000,
)
assert result.output_path == "/tmp/out.mp4"
assert result.thumbnail_path == "/tmp/out.jpg"
assert result.duration == 10.5
assert result.width == 1920
assert result.height == 1080
assert result.fps == 25.0
assert result.file_size == 1024000
def test_zero_values(self):
"""零值/边界值构造."""
result = VideoResult(
output_path="",
thumbnail_path="",
duration=0.0,
width=0,
height=0,
fps=0.0,
file_size=0,
)
assert result.duration == 0.0
assert result.file_size == 0
class TestVideoProcessorInit:
"""VideoProcessor 初始化测试."""
def test_default_temp_dir(self):
"""默认使用系统临时目录."""
import tempfile
vp = VideoProcessor()
assert vp.temp_dir == tempfile.gettempdir()
def test_custom_temp_dir(self):
"""自定义临时目录."""
vp = VideoProcessor(temp_dir="/my/temp")
assert vp.temp_dir == "/my/temp"
class TestVideoProcessorConcatenateValidation:
"""concatenate_videos 输入校验测试."""
def test_empty_input_raises(self):
"""空输入列表抛出 ValueError."""
vp = VideoProcessor()
with pytest.raises(ValueError, match="cannot be empty"):
vp.concatenate_videos([], "/tmp/output.mp4")
def test_none_input_raises(self):
"""None 输入抛出异常."""
vp = VideoProcessor()
with pytest.raises((ValueError, TypeError)):
vp.concatenate_videos(None, "/tmp/output.mp4") # type: ignore[arg-type]
class TestVideoProcessorGetVideoInfoParsing:
"""get_video_info 解析逻辑测试(mock ffmpeg.probe."""
def _mock_probe(self, streams=None, fmt=None):
"""创建 ffmpeg.probe 的 mock 返回值."""
return {
"streams": streams
or [{"codec_type": "video", "width": 1920, "height": 1080, "r_frame_rate": "25/1", "codec_name": "h264"}],
"format": fmt or {"duration": "10.5", "bit_rate": "5000000"},
}
def test_basic_info_parsing(self):
"""基本视频信息解析正确."""
vp = VideoProcessor()
probe_data = self._mock_probe()
with patch("video_processing.processor.ffmpeg.probe", return_value=probe_data):
info = vp.get_video_info("/tmp/test.mp4")
assert info["duration"] == 10.5
assert info["width"] == 1920
assert info["height"] == 1080
assert info["fps"] == 25.0
assert info["codec"] == "h264"
assert info["bitrate"] == 5000000
def test_fps_fraction_parsing(self):
"""分数帧率解析(如 30000/1001 = 29.97."""
vp = VideoProcessor()
probe_data = self._mock_probe(
streams=[
{
"codec_type": "video",
"width": 1920,
"height": 1080,
"r_frame_rate": "30000/1001",
"codec_name": "h264",
}
]
)
with patch("video_processing.processor.ffmpeg.probe", return_value=probe_data):
info = vp.get_video_info("/tmp/test.mp4")
assert info["fps"] == pytest.approx(29.97, abs=0.01)
def test_fps_integer_string(self):
"""整数字符串帧率(如 "60"."""
vp = VideoProcessor()
probe_data = self._mock_probe(
streams=[{"codec_type": "video", "width": 1920, "height": 1080, "r_frame_rate": "60", "codec_name": "h264"}]
)
with patch("video_processing.processor.ffmpeg.probe", return_value=probe_data):
info = vp.get_video_info("/tmp/test.mp4")
assert info["fps"] == 60.0
def test_missing_r_frame_rate(self):
"""缺少 r_frame_rate 时使用默认值."""
vp = VideoProcessor()
probe_data = self._mock_probe(
streams=[{"codec_type": "video", "width": 1920, "height": 1080, "codec_name": "h264"}]
)
with patch("video_processing.processor.ffmpeg.probe", return_value=probe_data):
info = vp.get_video_info("/tmp/test.mp4")
assert info["fps"] == 25.0
def test_no_video_stream(self):
"""没有视频流时的行为."""
vp = VideoProcessor()
probe_data = {
"streams": [{"codec_type": "audio", "codec_name": "aac"}],
"format": {"duration": "10.0", "bit_rate": "128000"},
}
with patch("video_processing.processor.ffmpeg.probe", return_value=probe_data):
with pytest.raises(StopIteration):
vp.get_video_info("/tmp/test.mp4")
def test_float_duration(self):
"""浮点时长解析."""
vp = VideoProcessor()
probe_data = self._mock_probe(fmt={"duration": "123.456", "bit_rate": "0"})
with patch("video_processing.processor.ffmpeg.probe", return_value=probe_data):
info = vp.get_video_info("/tmp/test.mp4")
assert info["duration"] == pytest.approx(123.456, abs=0.001)
def test_bitrate_zero(self):
"""码率为 0 时."""
vp = VideoProcessor()
probe_data = self._mock_probe(fmt={"duration": "10.0", "bit_rate": "0"})
with patch("video_processing.processor.ffmpeg.probe", return_value=probe_data):
info = vp.get_video_info("/tmp/test.mp4")
assert info["bitrate"] == 0
def test_ffmpeg_probe_error_raises(self):
"""ffmpeg.probe 失败时抛出 RuntimeError."""
vp = VideoProcessor()
import ffmpeg
with patch(
"video_processing.processor.ffmpeg.probe",
side_effect=ffmpeg.Error([], b"", b"No such file"),
):
with pytest.raises(RuntimeError, match="probe error"):
vp.get_video_info("/tmp/nonexistent.mp4")
class TestVideoProcessorGenerateThumbnail:
"""generate_thumbnail 测试."""
def _build_mock_chain(self):
"""构建 ffmpeg.input → .output → .overwrite_output → .run 调用链."""
mock_input_node = MagicMock()
mock_output_node = MagicMock()
mock_overwrite_node = MagicMock()
mock_input_node.output.return_value = mock_output_node
mock_output_node.overwrite_output.return_value = mock_overwrite_node
return mock_input_node, mock_output_node, mock_overwrite_node
def test_default_output_path(self):
"""默认输出路径为视频路径 + _thumb.jpg."""
vp = VideoProcessor()
mock_input_node, _mock_output, mock_overwrite = self._build_mock_chain()
with patch("video_processing.processor.ffmpeg.input", return_value=mock_input_node) as mock_ff_input:
result = vp.generate_thumbnail("/tmp/video.mp4")
assert result == "/tmp/video_thumb.jpg"
mock_ff_input.assert_called_once_with("/tmp/video.mp4", ss=1.0)
mock_input_node.output.assert_called_once()
# 验证输出路径和参数
output_args = mock_input_node.output.call_args
assert output_args[0][0] == "/tmp/video_thumb.jpg"
assert output_args[1].get("vframes") == 1
assert output_args[1].get("format") == "image2"
assert output_args[1].get("vcodec") == "mjpeg"
def test_custom_output_path(self):
"""自定义输出路径."""
vp = VideoProcessor()
mock_input_node, _mock_output, mock_overwrite = self._build_mock_chain()
with patch("video_processing.processor.ffmpeg.input", return_value=mock_input_node) as mock_ff_input:
result = vp.generate_thumbnail("/tmp/video.mp4", output_path="/custom/thumb.jpg")
assert result == "/custom/thumb.jpg"
def test_custom_timestamp(self):
"""自定义截图时间点."""
vp = VideoProcessor()
mock_input_node, _mock_output, mock_overwrite = self._build_mock_chain()
with patch("video_processing.processor.ffmpeg.input", return_value=mock_input_node) as mock_ff_input:
vp.generate_thumbnail("/tmp/video.mp4", timestamp=3.5)
# 验证 ss 参数
mock_ff_input.assert_called_once_with("/tmp/video.mp4", ss=3.5)
def test_ffmpeg_error_raises_runtime(self):
"""FFmpeg 失败时抛出 RuntimeError."""
vp = VideoProcessor()
import ffmpeg
mock_input_node, mock_output, mock_overwrite = self._build_mock_chain()
mock_overwrite.run.side_effect = ffmpeg.Error([], b"", b"Output file #0 does not contain any stream")
with patch("video_processing.processor.ffmpeg.input", return_value=mock_input_node):
with pytest.raises(RuntimeError, match="thumbnail error"):
vp.generate_thumbnail("/tmp/video.mp4")
class TestVideoProcessorConcatFileFormat:
"""concat 临时文件格式验证."""
def test_concat_file_format(self, tmp_path):
"""concat 临时文件格式符合 FFmpeg concat demuxer 规范."""
import os
vp = VideoProcessor(temp_dir=str(tmp_path))
written_content = {}
def fake_input(path, *args, **kwargs):
mock_node = MagicMock()
mock_output = MagicMock()
mock_overwrite = MagicMock()
mock_node.output.return_value = mock_output
mock_output.overwrite_output.return_value = mock_overwrite
if kwargs.get("format") == "concat":
# 读取 concat 文件内容
with open(path) as f:
written_content["concat"] = f.read()
return mock_node
mock_probe = MagicMock(
return_value={
"streams": [{"codec_type": "video", "width": 1920, "height": 1080, "r_frame_rate": "25/1"}],
"format": {"duration": "5.0", "bit_rate": "1000000"},
}
)
with (
patch("video_processing.processor.ffmpeg.input", side_effect=fake_input),
patch("video_processing.processor.ffmpeg.probe", mock_probe),
patch("video_processing.processor.os.path.getsize", return_value=1024),
):
with patch.object(VideoProcessor, "generate_thumbnail", return_value="/tmp/thumb.jpg"):
vp.concatenate_videos(
["/tmp/a.mp4", "/tmp/b.mp4", "/tmp/c.mp4"],
str(tmp_path / "output.mp4"),
)
# 验证 concat 文件格式
assert "concat" in written_content
lines = written_content["concat"].strip().split("\n")
assert len(lines) == 3
assert lines[0].startswith("file '")
assert "a.mp4'" in lines[0]
assert "b.mp4'" in lines[1]
assert "c.mp4'" in lines[2]
# 使用绝对路径
first_path = lines[0].replace("file '", "").rstrip("'")
assert os.path.isabs(first_path)
def test_concat_creates_output_directory(self, tmp_path):
"""输出目录不存在时自动创建."""
vp = VideoProcessor(temp_dir=str(tmp_path))
out_dir = tmp_path / "deep" / "output"
out_file = out_dir / "result.mp4"
mock_node = MagicMock()
mock_output = MagicMock()
mock_overwrite = MagicMock()
mock_node.output.return_value = mock_output
mock_output.overwrite_output.return_value = mock_overwrite
mock_probe = MagicMock(
return_value={
"streams": [{"codec_type": "video", "width": 1920, "height": 1080, "r_frame_rate": "25/1"}],
"format": {"duration": "5.0", "bit_rate": "1000000"},
}
)
with (
patch("video_processing.processor.ffmpeg.input", return_value=mock_node),
patch("video_processing.processor.ffmpeg.probe", mock_probe),
patch("video_processing.processor.os.path.getsize", return_value=1024),
):
with patch.object(VideoProcessor, "generate_thumbnail", return_value=str(out_dir / "thumb.jpg")):
vp.concatenate_videos(["/tmp/a.mp4"], str(out_file))
assert out_dir.exists()
assert out_dir.is_dir()
+550 -181
View File
@@ -1,234 +1,603 @@
"""视频分享领域模型单元测试 - 纯逻辑部分。"""
"""视频分享 - 领域实体 + Use cases 单元测试."""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from unittest.mock import MagicMock
import pytest
from domain.video_share import (
from packages.application.video_share.commands import (
CreateShareCommand,
UpdateShareCommand,
)
from packages.application.video_share.use_cases import (
AccessShareUseCase,
CreateShareUseCase,
GetShareByTokenUseCase,
InvalidPasswordError,
ListSharesByUserUseCase,
ListSharesByVideoUseCase,
NotFoundError,
PasswordRequiredError,
RecordShareDownloadUseCase,
RevokeShareUseCase,
ShareAccessResult,
ShareExpiredError,
UpdateShareUseCase,
VideoNotFoundError,
)
from packages.domain.generated_video import GeneratedVideo
from packages.domain.video_share import (
VideoShare,
_hash_password,
generate_share_token,
)
class TestHashPassword:
"""密码哈希函数。"""
def _make_share(
share_id: str = "share_001",
video_id: str = "vid_001",
user_id: str = "user_001",
token: str = "abc123xyz",
password: str | None = None,
expires_at: datetime | None = None,
is_active: bool = True,
) -> VideoShare:
return VideoShare(
id=share_id,
video_id=video_id,
user_id=user_id,
share_token=token,
password_hash=_hash_password(password) if password else None,
expires_at=expires_at,
view_count=0,
download_count=0,
is_active=is_active,
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc),
)
def test_empty_password_returns_empty(self):
assert _hash_password("") == ""
def test_none_password_returns_empty(self):
assert _hash_password(None) == ""
def _make_video(video_id: str = "vid_001", user_id: str = "user_001") -> GeneratedVideo:
return GeneratedVideo(
id=video_id,
project_id="proj_001",
generation_task_id="task_001",
name="测试视频",
file_url="oss://bucket/video.mp4",
file_size=1024000,
duration=30.5,
width=1080,
height=1920,
fps=30.0,
user_id=user_id,
)
def test_same_password_same_hash(self):
h1 = _hash_password("test123")
h2 = _hash_password("test123")
class TestVideoShareDomain:
def test_generate_token_length(self) -> None:
token = generate_share_token(12)
assert len(token) == 12
def test_generate_token_url_safe(self) -> None:
token = generate_share_token(16)
# 只包含字母数字,没有特殊字符
assert token.isalnum()
def test_hash_password_consistent(self) -> None:
h1 = _hash_password("mypassword")
h2 = _hash_password("mypassword")
assert h1 == h2
assert len(h1) > 0
assert len(h1) == 64 # sha256 hex
def test_different_passwords_different_hash(self):
def test_hash_password_different_for_different_passwords(self) -> None:
h1 = _hash_password("password1")
h2 = _hash_password("password2")
assert h1 != h2
def test_hash_is_hex_string(self):
h = _hash_password("test")
int(h, 16) # 合法 hex 不抛异常
assert len(h) == 64 # SHA-256 输出 64 个 hex 字符
def test_hash_empty_password(self) -> None:
assert _hash_password("") == ""
def test_hash_contains_salt(self):
"""相同密码的直接 SHA-256 与加盐后结果不同。"""
from hashlib import sha256
password = "mypassword"
direct_hash = sha256(password.encode()).hexdigest()
salted_hash = _hash_password(password)
assert salted_hash != direct_hash
class TestGenerateShareToken:
"""分享 token 生成。"""
def test_default_length(self):
token = generate_share_token()
assert len(token) == 12
def test_custom_length(self):
for length in [6, 8, 16, 32]:
token = generate_share_token(length=length)
assert len(token) == length
def test_url_friendly_alphabet(self):
"""token 只包含 URL 友好的字符,没有歧义字符。"""
token = generate_share_token(length=100)
# 不应该包含容易混淆的字符
assert "i" not in token or True # 可能有,取决于随机
assert "l" not in token or True
# 验证所有字符都在字母表里
alphabet = "abcdefghjkmnpqrstuvwxyzABCDEFGHJKMNPQRSTUVWXYZ23456789"
for char in token:
assert char in alphabet
def test_tokens_are_unique(self):
"""连续生成的 token 不重复。"""
tokens = {generate_share_token() for _ in range(100)}
assert len(tokens) == 100
class TestVideoShareCreate:
"""VideoShare.create 工厂方法。"""
def test_create_minimal(self):
share = VideoShare.create(video_id="vid-1", user_id="user-1")
assert share.video_id == "vid-1"
assert share.user_id == "user-1"
assert share.id # 自动生成
assert share.share_token # 自动生成
def test_create_share_success(self) -> None:
share = VideoShare.create(
video_id="vid_001",
user_id="user_001",
)
assert share.video_id == "vid_001"
assert share.user_id == "user_001"
assert len(share.id) == 32
assert len(share.share_token) == 12
assert share.password_hash is None
assert share.expires_at is None
assert share.is_active is True
assert share.view_count == 0
assert share.download_count == 0
assert share.is_active is True
def test_create_with_password(self):
share = VideoShare.create(video_id="vid-1", user_id="user-1", password="secret123")
assert share.password_hash is not None
assert share.password_hash != "secret123" # 已哈希
assert len(share.password_hash) > 0
def test_create_with_expiration(self):
expire_time = datetime(2026, 12, 31, tzinfo=timezone.utc)
share = VideoShare.create(video_id="vid-1", user_id="user-1", expires_at=expire_time)
assert share.expires_at == expire_time
def test_create_generates_unique_ids(self):
s1 = VideoShare.create(video_id="v", user_id="u")
s2 = VideoShare.create(video_id="v", user_id="u")
assert s1.id != s2.id
assert s1.share_token != s2.share_token
def test_create_strips_whitespace(self):
share = VideoShare.create(video_id=" vid-1 ", user_id="\tuser-1\n")
assert share.video_id == "vid-1"
assert share.user_id == "user-1"
class TestVideoSharePassword:
"""密码相关方法。"""
def test_has_password_false_when_none(self):
share = VideoShare.create(video_id="v", user_id="u")
assert share.has_password is False
def test_has_password_false_when_empty(self):
share = VideoShare.create(video_id="v", user_id="u", password="")
assert share.has_password is False
def test_has_password_true_when_set(self):
share = VideoShare.create(video_id="v", user_id="u", password="pass")
def test_create_share_with_password(self) -> None:
share = VideoShare.create(
video_id="vid_001",
user_id="user_001",
password="secret123",
)
assert share.has_password is True
def test_verify_password_correct(self):
share = VideoShare.create(video_id="v", user_id="u", password="mysecret")
assert share.verify_password("mysecret") is True
def test_verify_password_wrong(self):
share = VideoShare.create(video_id="v", user_id="u", password="mysecret")
assert share.verify_password("secret123") is True
assert share.verify_password("wrong") is False
def test_verify_password_no_password_set(self):
share = VideoShare.create(video_id="v", user_id="u")
# 没有设置密码时,任何输入都通过(免密访问)
assert share.verify_password("anything") is True
assert share.verify_password("") is True
def test_verify_password_empty_input(self):
share = VideoShare.create(video_id="v", user_id="u", password="pass")
assert share.verify_password("") is False
class TestVideoShareExpiration:
"""过期相关方法。"""
def test_not_expired_when_no_expiry(self):
share = VideoShare.create(video_id="v", user_id="u")
assert share.is_expired is False
def test_not_expired_when_future(self):
def test_create_share_with_expiry(self) -> None:
future = datetime.now(timezone.utc) + timedelta(days=7)
share = VideoShare.create(video_id="v", user_id="u", expires_at=future)
share = VideoShare.create(
video_id="vid_001",
user_id="user_001",
expires_at=future,
)
assert share.expires_at == future
assert share.is_expired is False
def test_expired_when_past(self):
share = VideoShare.create(video_id="v", user_id="u")
# 直接设置过去的过期时间(create 方法会阻止过期时间在过去)
share.expires_at = datetime.now(timezone.utc) - timedelta(days=1)
def test_create_share_past_expiry_raises(self) -> None:
past = datetime.now(timezone.utc) - timedelta(days=1)
with pytest.raises(ValueError, match="past"):
VideoShare.create(
video_id="vid_001",
user_id="user_001",
expires_at=past,
)
def test_create_share_empty_video_id_raises(self) -> None:
with pytest.raises(ValueError, match="video_id"):
VideoShare.create(video_id="", user_id="user_001")
def test_create_share_empty_user_id_raises(self) -> None:
with pytest.raises(ValueError, match="user_id"):
VideoShare.create(video_id="vid_001", user_id=" ")
def test_is_expired_false_when_no_expiry(self) -> None:
share = _make_share()
assert share.is_expired is False
def test_is_expired_true_when_past(self) -> None:
past = datetime.now(timezone.utc) - timedelta(hours=1)
share = _make_share(expires_at=past)
assert share.is_expired is True
def test_create_rejects_past_expiry(self):
"""create 方法拒绝过去的过期时间。"""
past = datetime.now(timezone.utc) - timedelta(days=1)
with pytest.raises(ValueError, match="expires_at cannot be in the past"):
VideoShare.create(video_id="v", user_id="u", expires_at=past)
class TestVideoShareAccessible:
"""可访问性判断。"""
def test_active_no_expiry_is_accessible(self):
share = VideoShare.create(video_id="v", user_id="u")
def test_is_accessible_active_not_expired(self) -> None:
share = _make_share()
assert share.is_accessible is True
def test_inactive_not_accessible(self):
share = VideoShare.create(video_id="v", user_id="u")
share.revoke()
def test_is_accessible_inactive(self) -> None:
share = _make_share(is_active=False)
assert share.is_accessible is False
def test_expired_not_accessible(self):
share = VideoShare.create(video_id="v", user_id="u")
# 直接设置过去的过期时间
share.expires_at = datetime.now(timezone.utc) - timedelta(days=1)
def test_is_accessible_expired(self) -> None:
past = datetime.now(timezone.utc) - timedelta(hours=1)
share = _make_share(expires_at=past)
assert share.is_accessible is False
def test_has_password_false_when_no_password(self) -> None:
share = _make_share()
assert share.has_password is False
class TestVideoShareCounts:
"""计数相关方法。"""
def test_has_password_true_when_password_set(self) -> None:
share = _make_share(password="pass123")
assert share.has_password is True
def test_initial_view_count_zero(self):
share = VideoShare.create(video_id="v", user_id="u")
assert share.view_count == 0
def test_verify_no_password_always_true(self) -> None:
share = _make_share() # 没有密码
assert share.verify_password("") is True
assert share.verify_password("anything") is True
def test_increment_view_count(self):
share = VideoShare.create(video_id="v", user_id="u")
def test_verify_correct_password(self) -> None:
share = _make_share(password="mysecret")
assert share.verify_password("mysecret") is True
def test_verify_wrong_password(self) -> None:
share = _make_share(password="mysecret")
assert share.verify_password("wrong") is False
def test_verify_empty_password_with_password_set(self) -> None:
share = _make_share(password="mysecret")
assert share.verify_password("") is False
def test_increment_view_count(self) -> None:
share = _make_share()
share.increment_view_count()
assert share.view_count == 1
share.increment_view_count()
share.increment_view_count()
assert share.view_count == 3
assert share.view_count == 2
def test_initial_download_count_zero(self):
share = VideoShare.create(video_id="v", user_id="u")
assert share.download_count == 0
def test_increment_download_count(self):
share = VideoShare.create(video_id="v", user_id="u")
def test_increment_download_count(self) -> None:
share = _make_share()
share.increment_download_count()
share.increment_download_count()
assert share.download_count == 2
assert share.download_count == 1
class TestVideoShareRevoke:
"""撤销分享。"""
def test_revoke_deactivates(self):
share = VideoShare.create(video_id="v", user_id="u")
assert share.is_active is True
def test_revoke_sets_inactive(self) -> None:
share = _make_share()
share.revoke()
assert share.is_active is False
assert share.is_accessible is False
def test_revoke_idempotent(self):
share = VideoShare.create(video_id="v", user_id="u")
share.revoke()
share.revoke() # 再次调用不报错
assert share.is_active is False
class TestCreateShareUseCase:
def test_create_success(self) -> None:
share_repo = MagicMock()
video_repo = MagicMock()
video_repo.get.return_value = _make_video()
share_repo.create.side_effect = lambda s: s
use_case = CreateShareUseCase(share_repo, video_repo)
cmd = CreateShareCommand(video_id="vid_001", user_id="user_001")
result = use_case.execute(cmd)
assert result.video_id == "vid_001"
assert result.user_id == "user_001"
share_repo.create.assert_called_once()
def test_create_with_password(self) -> None:
share_repo = MagicMock()
video_repo = MagicMock()
video_repo.get.return_value = _make_video()
share_repo.create.side_effect = lambda s: s
use_case = CreateShareUseCase(share_repo, video_repo)
cmd = CreateShareCommand(
video_id="vid_001",
user_id="user_001",
password="secret",
)
result = use_case.execute(cmd)
assert result.has_password is True
def test_create_with_expiry(self) -> None:
share_repo = MagicMock()
video_repo = MagicMock()
video_repo.get.return_value = _make_video()
share_repo.create.side_effect = lambda s: s
use_case = CreateShareUseCase(share_repo, video_repo)
future = datetime.now(timezone.utc) + timedelta(days=1)
cmd = CreateShareCommand(
video_id="vid_001",
user_id="user_001",
expires_at=future,
)
result = use_case.execute(cmd)
assert result.expires_at == future
def test_video_not_found_raises(self) -> None:
share_repo = MagicMock()
video_repo = MagicMock()
video_repo.get.return_value = None
use_case = CreateShareUseCase(share_repo, video_repo)
cmd = CreateShareCommand(video_id="nonexistent", user_id="user_001")
with pytest.raises(VideoNotFoundError):
use_case.execute(cmd)
def test_wrong_user_cannot_share(self) -> None:
share_repo = MagicMock()
video_repo = MagicMock()
video_repo.get.return_value = _make_video(user_id="other_user")
use_case = CreateShareUseCase(share_repo, video_repo)
cmd = CreateShareCommand(video_id="vid_001", user_id="user_001")
with pytest.raises(VideoNotFoundError):
use_case.execute(cmd)
class TestGetShareByTokenUseCase:
def test_found_active_share(self) -> None:
repo = MagicMock()
repo.get_by_token.return_value = _make_share()
use_case = GetShareByTokenUseCase(repo)
result = use_case.execute("abc123xyz")
assert result.share_token == "abc123xyz"
def test_not_found_raises(self) -> None:
repo = MagicMock()
repo.get_by_token.return_value = None
use_case = GetShareByTokenUseCase(repo)
with pytest.raises(NotFoundError):
use_case.execute("nonexistent")
def test_inactive_share_raises_expired(self) -> None:
repo = MagicMock()
repo.get_by_token.return_value = _make_share(is_active=False)
use_case = GetShareByTokenUseCase(repo)
with pytest.raises(ShareExpiredError):
use_case.execute("token")
def test_expired_share_raises(self) -> None:
repo = MagicMock()
past = datetime.now(timezone.utc) - timedelta(days=1)
repo.get_by_token.return_value = _make_share(expires_at=past)
use_case = GetShareByTokenUseCase(repo)
with pytest.raises(ShareExpiredError):
use_case.execute("token")
class TestAccessShareUseCase:
def test_access_no_password(self) -> None:
share_repo = MagicMock()
video_repo = MagicMock()
share_repo.get_by_token.return_value = _make_share()
video_repo.get.return_value = _make_video()
share_repo.increment_view.return_value = None
use_case = AccessShareUseCase(share_repo, video_repo)
result = use_case.execute("abc123xyz")
assert isinstance(result, ShareAccessResult)
assert result.video.id == "vid_001"
assert result.password_verified is True
assert result.share.view_count == 1 # 浏览量+1
share_repo.increment_view.assert_called_once()
def test_access_with_correct_password(self) -> None:
share_repo = MagicMock()
video_repo = MagicMock()
share_repo.get_by_token.return_value = _make_share(password="mypass")
video_repo.get.return_value = _make_video()
share_repo.increment_view.return_value = None
use_case = AccessShareUseCase(share_repo, video_repo)
result = use_case.execute("token", password="mypass")
assert result.password_verified is True
def test_access_password_required_but_not_provided(self) -> None:
share_repo = MagicMock()
video_repo = MagicMock()
share_repo.get_by_token.return_value = _make_share(password="secret")
video_repo.get.return_value = _make_video()
use_case = AccessShareUseCase(share_repo, video_repo)
with pytest.raises(PasswordRequiredError):
use_case.execute("token", password=None)
def test_access_wrong_password(self) -> None:
share_repo = MagicMock()
video_repo = MagicMock()
share_repo.get_by_token.return_value = _make_share(password="correct")
video_repo.get.return_value = _make_video()
use_case = AccessShareUseCase(share_repo, video_repo)
with pytest.raises(InvalidPasswordError):
use_case.execute("token", password="wrong")
def test_access_share_not_found(self) -> None:
share_repo = MagicMock()
video_repo = MagicMock()
share_repo.get_by_token.return_value = None
use_case = AccessShareUseCase(share_repo, video_repo)
with pytest.raises(NotFoundError):
use_case.execute("nonexistent")
def test_access_share_expired(self) -> None:
share_repo = MagicMock()
video_repo = MagicMock()
past = datetime.now(timezone.utc) - timedelta(days=1)
share_repo.get_by_token.return_value = _make_share(expires_at=past)
video_repo.get.return_value = _make_video()
use_case = AccessShareUseCase(share_repo, video_repo)
with pytest.raises(ShareExpiredError):
use_case.execute("token")
def test_access_video_not_found(self) -> None:
share_repo = MagicMock()
video_repo = MagicMock()
share_repo.get_by_token.return_value = _make_share()
video_repo.get.return_value = None
use_case = AccessShareUseCase(share_repo, video_repo)
with pytest.raises(VideoNotFoundError):
use_case.execute("token")
class TestListSharesByVideoUseCase:
def test_lists_shares(self) -> None:
repo = MagicMock()
expected = [_make_share(), _make_share(share_id="share_002", token="tok2")]
repo.list_by_video.return_value = expected
use_case = ListSharesByVideoUseCase(repo)
result = use_case.execute("vid_001", "user_001")
assert len(result) == 2
repo.list_by_video.assert_called_once_with("vid_001", "user_001")
class TestListSharesByUserUseCase:
def test_lists_with_total(self) -> None:
repo = MagicMock()
items = [_make_share(), _make_share(share_id="s2", token="t2")]
repo.list_by_user.return_value = items
repo.count_by_user.return_value = 10
use_case = ListSharesByUserUseCase(repo)
result_items, total = use_case.execute("user_001", skip=0, limit=2)
assert len(result_items) == 2
assert total == 10
repo.list_by_user.assert_called_once_with("user_001", skip=0, limit=2)
class TestUpdateShareUseCase:
def test_update_password(self) -> None:
repo = MagicMock()
share = _make_share()
repo.get_by_id.return_value = share
repo.update.side_effect = lambda s: s
use_case = UpdateShareUseCase(repo)
cmd = UpdateShareCommand(
share_id="share_001",
user_id="user_001",
password="newpass",
)
result = use_case.execute(cmd)
assert result.has_password is True
assert result.verify_password("newpass") is True
repo.update.assert_called_once()
def test_clear_password(self) -> None:
repo = MagicMock()
share = _make_share(password="oldpass")
repo.get_by_id.return_value = share
repo.update.side_effect = lambda s: s
use_case = UpdateShareUseCase(repo)
cmd = UpdateShareCommand(
share_id="share_001",
user_id="user_001",
password="", # 空字符串=清除密码
)
result = use_case.execute(cmd)
assert result.has_password is False
assert result.password_hash is None
def test_password_none_does_not_change(self) -> None:
repo = MagicMock()
share = _make_share(password="existing")
repo.get_by_id.return_value = share
repo.update.side_effect = lambda s: s
use_case = UpdateShareUseCase(repo)
cmd = UpdateShareCommand(
share_id="share_001",
user_id="user_001",
password=None, # None=不修改
)
result = use_case.execute(cmd)
assert result.verify_password("existing") is True
def test_update_expires_at(self) -> None:
repo = MagicMock()
share = _make_share()
repo.get_by_id.return_value = share
repo.update.side_effect = lambda s: s
use_case = UpdateShareUseCase(repo)
future = datetime.now(timezone.utc) + timedelta(days=3)
cmd = UpdateShareCommand(
share_id="share_001",
user_id="user_001",
expires_at=future,
)
result = use_case.execute(cmd)
assert result.expires_at == future
def test_not_found_raises(self) -> None:
repo = MagicMock()
repo.get_by_id.return_value = None
use_case = UpdateShareUseCase(repo)
cmd = UpdateShareCommand(share_id="no", user_id="u1", password="x")
with pytest.raises(NotFoundError):
use_case.execute(cmd)
def test_past_expiry_raises(self) -> None:
repo = MagicMock()
repo.get_by_id.return_value = _make_share()
use_case = UpdateShareUseCase(repo)
past = datetime.now(timezone.utc) - timedelta(days=1)
cmd = UpdateShareCommand(
share_id="share_001",
user_id="user_001",
expires_at=past,
)
with pytest.raises(ValueError, match="past"):
use_case.execute(cmd)
class TestRevokeShareUseCase:
def test_revoke_success(self) -> None:
repo = MagicMock()
repo.get_by_id.return_value = _make_share()
repo.delete.return_value = True
use_case = RevokeShareUseCase(repo)
result = use_case.execute("share_001", "user_001")
assert result is True
repo.delete.assert_called_once_with("share_001", "user_001")
def test_revoke_not_found_raises(self) -> None:
repo = MagicMock()
repo.get_by_id.return_value = None
use_case = RevokeShareUseCase(repo)
with pytest.raises(NotFoundError):
use_case.execute("nonexistent", "user_001")
class TestRecordShareDownloadUseCase:
def test_record_success(self) -> None:
repo = MagicMock()
repo.get_by_token.return_value = _make_share()
repo.increment_download.return_value = None
use_case = RecordShareDownloadUseCase(repo)
use_case.execute("token")
repo.increment_download.assert_called_once()
def test_record_with_password(self) -> None:
repo = MagicMock()
repo.get_by_token.return_value = _make_share(password="pass")
repo.increment_download.return_value = None
use_case = RecordShareDownloadUseCase(repo)
use_case.execute("token", password="pass")
repo.increment_download.assert_called_once()
def test_record_wrong_password_raises(self) -> None:
repo = MagicMock()
repo.get_by_token.return_value = _make_share(password="correct")
use_case = RecordShareDownloadUseCase(repo)
with pytest.raises(InvalidPasswordError):
use_case.execute("token", password="wrong")
def test_record_share_not_found(self) -> None:
repo = MagicMock()
repo.get_by_token.return_value = None
use_case = RecordShareDownloadUseCase(repo)
with pytest.raises(NotFoundError):
use_case.execute("nonexistent")
def test_record_expired_share(self) -> None:
repo = MagicMock()
past = datetime.now(timezone.utc) - timedelta(days=1)
repo.get_by_token.return_value = _make_share(expires_at=past)
use_case = RecordShareDownloadUseCase(repo)
with pytest.raises(ShareExpiredError):
use_case.execute("token")
-259
View File
@@ -1,259 +0,0 @@
"""VoiceExtractor 纯逻辑单测 — 命令构建 + 边界用例.
通过 mock run_ffmpeg 验证 FFmpeg 命令参数是否正确,
不实际执行 FFmpeg,确保测试轻量快速。
"""
from __future__ import annotations
import os
from unittest.mock import MagicMock, patch
import pytest
from worker_app.tasks.voice_extraction import VoiceExtractor
class TestVoiceExtractorExtractVoiceCommand:
"""extract_voice 命令构建测试."""
def test_default_params_correct_command(self):
"""默认参数下 FFmpeg 命令正确."""
extractor = VoiceExtractor()
with patch.object(VoiceExtractor, "_run_ffmpeg") as mock_run:
result = extractor.extract_voice("/tmp/input.mp4", "/tmp/output.mp3")
assert result == "/tmp/output.mp3"
mock_run.assert_called_once()
cmd = mock_run.call_args[0][0]
# 基本结构验证
assert cmd[0] == "ffmpeg"
assert "-y" in cmd
assert cmd[cmd.index("-i") + 1] == "/tmp/input.mp4"
assert "-vn" in cmd # 无视频流
assert cmd[-1] == "/tmp/output.mp3"
# 音频滤镜验证
af_idx = cmd.index("-af")
af_value = cmd[af_idx + 1]
assert "highpass=f=200" in af_value
assert "afftdn=bn=20" in af_value
assert "bandpass=f=300:width_type=h:width=3000" in af_value
assert "loudnorm" in af_value
# 编码验证
assert "libmp3lame" in cmd
assert "-q:a" in cmd
assert cmd[cmd.index("-q:a") + 1] == "2"
def test_custom_highpass(self):
"""自定义 highpass 频率."""
extractor = VoiceExtractor()
with patch.object(VoiceExtractor, "_run_ffmpeg") as mock_run:
extractor.extract_voice("/tmp/in.mp4", "/tmp/out.mp3", highpass=500)
cmd = mock_run.call_args[0][0]
af_value = cmd[cmd.index("-af") + 1]
assert "highpass=f=500" in af_value
def test_custom_bandpass_freq(self):
"""自定义 bandpass 中心频率."""
extractor = VoiceExtractor()
with patch.object(VoiceExtractor, "_run_ffmpeg") as mock_run:
extractor.extract_voice("/tmp/in.mp4", "/tmp/out.mp3", bandpass_freq=500)
cmd = mock_run.call_args[0][0]
af_value = cmd[cmd.index("-af") + 1]
assert "bandpass=f=500:" in af_value
def test_custom_bandpass_width(self):
"""自定义 bandpass 宽度."""
extractor = VoiceExtractor()
with patch.object(VoiceExtractor, "_run_ffmpeg") as mock_run:
extractor.extract_voice("/tmp/in.mp4", "/tmp/out.mp3", bandpass_width=5000)
cmd = mock_run.call_args[0][0]
af_value = cmd[cmd.index("-af") + 1]
assert "width=5000" in af_value
def test_custom_noise_reduction(self):
"""自定义降噪强度."""
extractor = VoiceExtractor()
with patch.object(VoiceExtractor, "_run_ffmpeg") as mock_run:
extractor.extract_voice("/tmp/in.mp4", "/tmp/out.mp3", noise_reduction=30)
cmd = mock_run.call_args[0][0]
af_value = cmd[cmd.index("-af") + 1]
assert "afftdn=bn=30" in af_value
def test_filter_order_is_correct(self):
"""滤镜顺序:highpass → 降噪 → bandpass → loudnorm."""
extractor = VoiceExtractor()
with patch.object(VoiceExtractor, "_run_ffmpeg") as mock_run:
extractor.extract_voice("/tmp/in.mp4", "/tmp/out.mp3")
cmd = mock_run.call_args[0][0]
af_value = cmd[cmd.index("-af") + 1]
hp_pos = af_value.index("highpass")
dn_pos = af_value.index("afftdn")
bp_pos = af_value.index("bandpass")
ln_pos = af_value.index("loudnorm")
assert hp_pos < dn_pos < bp_pos < ln_pos
def test_creates_output_directory(self, tmp_path):
"""输出目录不存在时自动创建."""
out_dir = tmp_path / "nested" / "deep"
out_file = out_dir / "voice.mp3"
extractor = VoiceExtractor()
with patch.object(VoiceExtractor, "_run_ffmpeg"):
extractor.extract_voice("/tmp/in.mp4", str(out_file))
assert out_dir.exists()
assert out_dir.is_dir()
def test_returns_output_path(self):
"""返回值为输出路径."""
extractor = VoiceExtractor()
with patch.object(VoiceExtractor, "_run_ffmpeg"):
result = extractor.extract_voice("/tmp/in.mp4", "/tmp/voice.mp3")
assert result == "/tmp/voice.mp3"
class TestVoiceExtractorExtractBackgroundCommand:
"""extract_background 命令构建测试."""
def test_default_params_correct_command(self):
"""默认参数下 FFmpeg 命令正确."""
extractor = VoiceExtractor()
with patch.object(VoiceExtractor, "_run_ffmpeg") as mock_run:
result = extractor.extract_background("/tmp/input.mp4", "/tmp/output.mp3")
assert result == "/tmp/output.mp3"
mock_run.assert_called_once()
cmd = mock_run.call_args[0][0]
# 基本结构
assert cmd[0] == "ffmpeg"
assert "-y" in cmd
assert cmd[cmd.index("-i") + 1] == "/tmp/input.mp4"
assert "-vn" in cmd
assert cmd[-1] == "/tmp/output.mp3"
# 音频滤镜
af_idx = cmd.index("-af")
af_value = cmd[af_idx + 1]
assert "lowpass=f=200" in af_value
assert "loudnorm" in af_value
# 编码
assert "libmp3lame" in cmd
def test_custom_lowpass_freq(self):
"""自定义 lowpass 频率."""
extractor = VoiceExtractor()
with patch.object(VoiceExtractor, "_run_ffmpeg") as mock_run:
extractor.extract_background("/tmp/in.mp4", "/tmp/out.mp3", lowpass=500)
cmd = mock_run.call_args[0][0]
af_value = cmd[cmd.index("-af") + 1]
assert "lowpass=f=500" in af_value
def test_filter_order_background(self):
"""背景音滤镜顺序:lowpass → loudnorm."""
extractor = VoiceExtractor()
with patch.object(VoiceExtractor, "_run_ffmpeg") as mock_run:
extractor.extract_background("/tmp/in.mp4", "/tmp/out.mp3")
cmd = mock_run.call_args[0][0]
af_value = cmd[cmd.index("-af") + 1]
lp_pos = af_value.index("lowpass")
ln_pos = af_value.index("loudnorm")
assert lp_pos < ln_pos
def test_background_creates_output_directory(self, tmp_path):
"""背景音输出目录不存在时自动创建."""
out_dir = tmp_path / "bgm" / "tracks"
out_file = out_dir / "bg.mp3"
extractor = VoiceExtractor()
with patch.object(VoiceExtractor, "_run_ffmpeg"):
extractor.extract_background("/tmp/in.mp4", str(out_file))
assert out_dir.exists()
class TestVoiceExtractorEdgeCases:
"""边界情况测试."""
def test_zero_highpass(self):
"""highpass=0 时的行为(极端低值)."""
extractor = VoiceExtractor()
with patch.object(VoiceExtractor, "_run_ffmpeg") as mock_run:
extractor.extract_voice("/tmp/in.mp4", "/tmp/out.mp3", highpass=0)
cmd = mock_run.call_args[0][0]
af_value = cmd[cmd.index("-af") + 1]
assert "highpass=f=0" in af_value
def test_zero_bandpass_freq(self):
"""bandpass_freq=0 时的极端情况."""
extractor = VoiceExtractor()
with patch.object(VoiceExtractor, "_run_ffmpeg") as mock_run:
extractor.extract_voice("/tmp/in.mp4", "/tmp/out.mp3", bandpass_freq=0)
cmd = mock_run.call_args[0][0]
af_value = cmd[cmd.index("-af") + 1]
assert "bandpass=f=0:" in af_value
def test_very_high_noise_reduction(self):
"""极高降噪强度."""
extractor = VoiceExtractor()
with patch.object(VoiceExtractor, "_run_ffmpeg") as mock_run:
extractor.extract_voice("/tmp/in.mp4", "/tmp/out.mp3", noise_reduction=100)
cmd = mock_run.call_args[0][0]
af_value = cmd[cmd.index("-af") + 1]
assert "afftdn=bn=100" in af_value
def test_negative_lowpass_allowed(self):
"""lowpass 负值(由调用方保证合法性,函数不做校验)."""
extractor = VoiceExtractor()
with patch.object(VoiceExtractor, "_run_ffmpeg") as mock_run:
extractor.extract_background("/tmp/in.mp4", "/tmp/out.mp3", lowpass=-10)
cmd = mock_run.call_args[0][0]
af_value = cmd[cmd.index("-af") + 1]
assert "lowpass=f=-10" in af_value
def test_run_ffmpeg_propagates_error(self):
"""_run_ffmpeg 抛出异常时向上传递."""
extractor = VoiceExtractor()
with patch.object(VoiceExtractor, "_run_ffmpeg", side_effect=RuntimeError("FFmpeg failed")):
with pytest.raises(RuntimeError, match="FFmpeg failed"):
extractor.extract_voice("/tmp/in.mp4", "/tmp/out.mp3")
def test_voice_extractor_is_static_method(self):
"""_run_ffmpeg 是静态方法,可在类上直接调用."""
# 验证 VoiceExtractor 可以直接实例化(无需参数)
extractor = VoiceExtractor()
assert extractor is not None
def test_multiple_extractions_same_instance(self):
"""同一个实例可多次执行提取."""
extractor = VoiceExtractor()
call_count = 0
def fake_run(cmd):
nonlocal call_count
call_count += 1
with patch.object(VoiceExtractor, "_run_ffmpeg", side_effect=fake_run):
extractor.extract_voice("/tmp/a.mp4", "/tmp/a_voice.mp3")
extractor.extract_background("/tmp/a.mp4", "/tmp/a_bg.mp3")
assert call_count == 2