diff --git a/apps/api/app/api/routes/edit_plans.py b/apps/api/app/api/routes/edit_plans.py index 0bf998d78..fa63f7e91 100755 --- a/apps/api/app/api/routes/edit_plans.py +++ b/apps/api/app/api/routes/edit_plans.py @@ -265,9 +265,11 @@ def _to_response(p: EditPlan) -> EditPlanResponse: from .edit_plans_export import router as export_router from .edit_plans_filter import router as filter_router +from .edit_plans_transitions import router as transitions_router router.include_router(export_router) router.include_router(filter_router) +router.include_router(transitions_router) # ── CRUD Routes ─────────────────────────────────────────────────────────────── diff --git a/apps/api/app/api/routes/edit_plans_transitions.py b/apps/api/app/api/routes/edit_plans_transitions.py new file mode 100755 index 000000000..a86fdbab1 --- /dev/null +++ b/apps/api/app/api/routes/edit_plans_transitions.py @@ -0,0 +1,272 @@ +"""转场特效 API. + +- GET /transition-presets 转场预设列表 +- PUT /clips/{clip_id}/transition 设置单个片段转场 +- POST /{plan_id}/transitions/batch 批量设置转场(所有片段) +""" + +from __future__ import annotations + +import logging +from typing import Any, List, Optional + +from app.auth import AuthenticatedUser, get_current_user +from app.dependencies import get_db_session, get_project_repository +from app.services import EditPlanService +from fastapi import APIRouter, Depends, HTTPException, Query, status +from pydantic import BaseModel, Field +from sqlalchemy.orm import Session + +from packages.domain.transition_presets import ( + TransitionPreset, + get_transition_preset, + list_transition_presets, +) + +from ._helpers import check_project_access + +logger = logging.getLogger(__name__) + +router = APIRouter() + + +# ── Schemas ────────────────────────────────────────────────────────────────── + + +class TransitionPresetResponse(BaseModel): + """转场预设响应""" + + id: str + name: str + category: str + description: str + tags: List[str] = Field(default_factory=list) + default_duration: float + min_duration: float + max_duration: float + + +class TransitionUpdateRequest(BaseModel): + """更新转场请求""" + + effect: str = Field(..., description="转场效果 ID") + duration: Optional[float] = Field(default=None, ge=0.0, description="转场时长(秒)") + + +class BatchTransitionRequest(BaseModel): + """批量设置转场请求""" + + effect: str = Field(..., description="转场效果 ID") + duration: Optional[float] = Field(default=None, ge=0.0, description="转场时长(秒)") + apply_to: str = Field( + default="all", + description="应用范围: all=所有片段, except_first=除第一个外, except_last=除最后一个, middle=中间片段", + ) + + +class ClipTransitionResponse(BaseModel): + """片段转场信息响应""" + + clip_id: str + effect: str + duration: float + + +class BatchTransitionResponse(BaseModel): + """批量转场响应""" + + updated_count: int + plan_id: str + + +class TransitionPresetListResponse(BaseModel): + """转场预设列表响应""" + + items: List[TransitionPresetResponse] + total: int + + +# ── Helpers ────────────────────────────────────────────────────────────────── + + +def _preset_to_response(p: TransitionPreset) -> TransitionPresetResponse: + return TransitionPresetResponse( + id=p.id, + name=p.name, + category=p.category, + description=p.description, + tags=list(p.tags), + default_duration=p.default_duration, + min_duration=p.min_duration, + max_duration=p.max_duration, + ) + + +def _validate_transition(effect: str, duration: Optional[float] = None) -> tuple[str, float]: + """验证转场效果和时长,返回 (effect, duration)""" + preset = get_transition_preset(effect) + if preset is None: + raise ValueError(f"无效的转场效果: {effect}") + + # 硬切特殊处理,时长强制为0 + if effect == "transition_none" or preset.transition == "none": + return "cut", 0.0 + + final_duration = duration if duration is not None else preset.default_duration + if final_duration < preset.min_duration: + final_duration = preset.min_duration + if final_duration > preset.max_duration: + final_duration = preset.max_duration + + return preset.transition, round(final_duration, 3) + + +# ── Routes ─────────────────────────────────────────────────────────────────── + + +@router.get("/transition-presets", response_model=TransitionPresetListResponse) +def list_presets( + category: Optional[str] = Query(default=None, description="按分类筛选"), + keyword: Optional[str] = Query(default=None, description="关键词搜索"), + current_user: AuthenticatedUser = Depends(get_current_user), +) -> TransitionPresetListResponse: + """获取转场预设列表""" + presets = list_transition_presets(category=category, keyword=keyword) + items = [_preset_to_response(p) for p in presets] + return TransitionPresetListResponse(items=items, total=len(items)) + + +@router.put("/clips/{clip_id}/transition", response_model=ClipTransitionResponse) +def update_clip_transition( + clip_id: str, + body: TransitionUpdateRequest, + db: Session = Depends(get_db_session), + current_user: AuthenticatedUser = Depends(get_current_user), + project_repository: Any = Depends(get_project_repository), +) -> ClipTransitionResponse: + """设置单个片段的转场效果""" + svc = EditPlanService(db) + clip = svc.get_clip(clip_id) + if not clip: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"片段不存在: {clip_id}", + ) + + plan = svc.get_plan(clip.plan_id) + if plan and plan.project_id: + check_project_access(plan.project_id, current_user.user.id, project_repository) + + # 验证转场参数 + try: + effect, duration = _validate_transition(body.effect, body.duration) + except ValueError as e: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e + + # 更新片段 + updated_clip = svc.update_clip( + clip_id, + transition_effect=effect, + transition_duration=duration, + ) + + logger.info( + "更新片段转场: clip_id=%s effect=%s duration=%.3f by user=%s", + clip_id, + effect, + duration, + current_user.user.id, + ) + return ClipTransitionResponse( + clip_id=clip_id, + effect=updated_clip.transition_effect, + duration=updated_clip.transition_duration, + ) + + +@router.post("/{plan_id}/transitions/batch", response_model=BatchTransitionResponse) +def batch_update_transitions( + plan_id: str, + body: BatchTransitionRequest, + db: Session = Depends(get_db_session), + current_user: AuthenticatedUser = Depends(get_current_user), + project_repository: Any = Depends(get_project_repository), +) -> BatchTransitionResponse: + """批量设置计划内所有片段的转场效果 + + apply_to 说明: + - all: 所有片段 + - except_first: 除第一个片段外(第一个片段不需要前转场) + - except_last: 除最后一个片段外 + - middle: 只设置中间片段(除首尾) + """ + svc = EditPlanService(db) + plan = svc.get_plan(plan_id) + if not plan: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"剪辑计划不存在: {plan_id}", + ) + + if plan.project_id: + check_project_access(plan.project_id, current_user.user.id, project_repository) + + # 验证转场参数 + try: + effect, duration = _validate_transition(body.effect, body.duration) + except ValueError as e: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e + + # 获取所有片段 + clips = svc.list_clips(plan_id, limit=500, skip=0) + if not clips: + return BatchTransitionResponse(updated_count=0, plan_id=plan_id) + + # 确定应用范围 + total = len(clips) + if total <= 1: + # 只有一个片段时,只有 all 模式才应用 + if body.apply_to != "all": + return BatchTransitionResponse(updated_count=0, plan_id=plan_id) + + # 按 order 排序 + clips_sorted = sorted(clips, key=lambda c: c.order) + indices_to_update = [] + + if body.apply_to == "all": + indices_to_update = list(range(total)) + elif body.apply_to == "except_first": + indices_to_update = list(range(1, total)) + elif body.apply_to == "except_last": + indices_to_update = list(range(total - 1)) + elif body.apply_to == "middle": + if total <= 2: + indices_to_update = [] + else: + indices_to_update = list(range(1, total - 1)) + else: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"无效的 apply_to: {body.apply_to}", + ) + + # 批量更新 + count = 0 + for idx in indices_to_update: + clip = clips_sorted[idx] + svc.update_clip( + clip.id, + transition_effect=effect, + transition_duration=duration, + ) + count += 1 + + logger.info( + "批量更新转场: plan_id=%s count=%d effect=%s apply_to=%s by user=%s", + plan_id, + count, + effect, + body.apply_to, + current_user.user.id, + ) + return BatchTransitionResponse(updated_count=count, plan_id=plan_id) diff --git a/packages/domain/transition_presets.py b/packages/domain/transition_presets.py new file mode 100755 index 000000000..d6ec66724 --- /dev/null +++ b/packages/domain/transition_presets.py @@ -0,0 +1,306 @@ +"""转场特效预设库. + +视频片段之间的转场效果,基于 FFmpeg xfade 滤镜实现。 +所有转场预设包含时长范围和默认参数。 +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import List, Optional + + +@dataclass(frozen=True) +class TransitionPreset: + """转场特效预设""" + + id: str + name: str + category: str # 分类:basic / fade / slide / zoom / warp / special + description: str = "" + tags: List[str] = field(default_factory=list) + # FFmpeg xfade transition 名称 + transition: str = "fade" + # 默认时长(秒) + default_duration: float = 0.5 + # 支持的时长范围 + min_duration: float = 0.1 + max_duration: float = 3.0 + # 是否需要额外参数 + has_custom_params: bool = False + + +# ── 预设库清单 ──────────────────────────────────────────────────────────────── + +TRANSITION_PRESET_LIBRARY: List[TransitionPreset] = [ + # ── 基础 basic ──────────────────────────────────────────────────── + TransitionPreset( + id="transition_none", + name="无转场", + category="basic", + description="硬切,无过渡效果", + tags=["硬切", "无"], + transition="none", + default_duration=0.0, + min_duration=0.0, + max_duration=0.0, + ), + TransitionPreset( + id="transition_random", + name="随机", + category="basic", + description="随机选择一个转场效果", + tags=["随机", "惊喜"], + transition="random", + default_duration=0.5, + ), + # ── 淡入淡出 fade ──────────────────────────────────────────────── + TransitionPreset( + id="transition_fade", + name="淡入淡出", + category="fade", + description="经典交叉淡入淡出", + tags=["经典", "柔和"], + transition="fade", + default_duration=0.5, + min_duration=0.2, + max_duration=2.0, + ), + TransitionPreset( + id="transition_fadeblack", + name="黑场过渡", + category="fade", + description="经过黑色画面过渡", + tags=["黑场", "电影感"], + transition="fadeblack", + default_duration=0.6, + min_duration=0.2, + max_duration=2.0, + ), + TransitionPreset( + id="transition_fadewhite", + name="白场过渡", + category="fade", + description="经过白色画面过渡", + tags=["白场", "梦幻"], + transition="fadewhite", + default_duration=0.6, + min_duration=0.2, + max_duration=2.0, + ), + TransitionPreset( + id="transition_fadegrays", + name="灰度过渡", + category="fade", + description="经过灰度画面过渡", + tags=["灰度", "文艺"], + transition="fadegrays", + default_duration=0.6, + min_duration=0.2, + max_duration=2.0, + ), + # ── 滑动 slide ────────────────────────────────────────────────── + TransitionPreset( + id="transition_slideleft", + name="左滑", + category="slide", + description="画面向左滑动", + tags=["滑动", "左"], + transition="slideleft", + default_duration=0.5, + min_duration=0.2, + max_duration=1.5, + ), + TransitionPreset( + id="transition_slideright", + name="右滑", + category="slide", + description="画面向右滑动", + tags=["滑动", "右"], + transition="slideright", + default_duration=0.5, + min_duration=0.2, + max_duration=1.5, + ), + TransitionPreset( + id="transition_slideup", + name="上滑", + category="slide", + description="画面向上滑动", + tags=["滑动", "上"], + transition="slideup", + default_duration=0.5, + min_duration=0.2, + max_duration=1.5, + ), + TransitionPreset( + id="transition_slidedown", + name="下滑", + category="slide", + description="画面向下滑动", + tags=["滑动", "下"], + transition="slidedown", + default_duration=0.5, + min_duration=0.2, + max_duration=1.5, + ), + # ── 缩放 zoom ─────────────────────────────────────────────────── + TransitionPreset( + id="transition_zoomin", + name="放大进入", + category="zoom", + description="下一段画面从中心放大出现", + tags=["放大", "冲击"], + transition="zoomin", + default_duration=0.5, + min_duration=0.2, + max_duration=1.5, + ), + TransitionPreset( + id="transition_zoomout", + name="缩小退出", + category="zoom", + description="当前画面缩小退出", + tags=["缩小", "拉远"], + transition="zoomout", + default_duration=0.5, + min_duration=0.2, + max_duration=1.5, + ), + # ── 擦除 warp ─────────────────────────────────────────────────── + TransitionPreset( + id="transition_dissolve", + name="溶解", + category="warp", + description="像素溶解效果", + tags=["溶解", "像素"], + transition="dissolve", + default_duration=0.8, + min_duration=0.3, + max_duration=2.0, + ), + TransitionPreset( + id="transition_wipeleft", + name="左擦除", + category="warp", + description="从右向左擦除", + tags=["擦除", "左"], + transition="wipeleft", + default_duration=0.5, + min_duration=0.2, + max_duration=1.5, + ), + TransitionPreset( + id="transition_wiperight", + name="右擦除", + category="warp", + description="从左向右擦除", + tags=["擦除", "右"], + transition="wiperight", + default_duration=0.5, + min_duration=0.2, + max_duration=1.5, + ), + TransitionPreset( + id="transition_wipeup", + name="上擦除", + category="warp", + description="从下向上擦除", + tags=["擦除", "上"], + transition="wipeup", + default_duration=0.5, + min_duration=0.2, + max_duration=1.5, + ), + TransitionPreset( + id="transition_wipedown", + name="下擦除", + category="warp", + description="从上向下擦除", + tags=["擦除", "下"], + transition="wipedown", + default_duration=0.5, + min_duration=0.2, + max_duration=1.5, + ), + TransitionPreset( + id="transition_circlecrop", + name="圆形展开", + category="warp", + description="圆形从中心展开", + tags=["圆形", "展开"], + transition="circlecrop", + default_duration=0.6, + min_duration=0.2, + max_duration=1.5, + ), + # ── 特效 special ─────────────────────────────────────────────── + TransitionPreset( + id="transition_hblur", + name="水平模糊", + category="special", + description="水平方向模糊过渡", + tags=["模糊", "水平"], + transition="hblur", + default_duration=0.5, + min_duration=0.2, + max_duration=1.5, + ), + TransitionPreset( + id="transition_wipeblur", + name="模糊擦除", + category="special", + description="带模糊效果的擦除", + tags=["模糊", "擦除"], + transition="wipeblur", + default_duration=0.6, + min_duration=0.2, + max_duration=1.5, + ), +] + + +# ── 工具函数 ────────────────────────────────────────────────────────────────── + + +def get_transition_preset(preset_id: str) -> Optional[TransitionPreset]: + """根据 ID 获取转场预设""" + for p in TRANSITION_PRESET_LIBRARY: + if p.id == preset_id: + return p + return None + + +def list_transition_presets( + *, + category: Optional[str] = None, + keyword: Optional[str] = None, +) -> List[TransitionPreset]: + """筛选转场预设列表 + + Args: + category: 按分类筛选 + keyword: 关键词搜索 + + Returns: + 筛选后的预设列表 + """ + results = TRANSITION_PRESET_LIBRARY + + if category: + results = [p for p in results if p.category == category] + + if keyword: + kw = keyword.lower() + results = [ + p + for p in results + if kw in p.name.lower() or kw in p.description.lower() or any(kw in t.lower() for t in p.tags) + ] + + return results + + +def get_default_transition() -> TransitionPreset: + """获取默认转场(无转场/硬切)""" + return get_transition_preset("transition_none") # type: ignore diff --git a/tests/unit/test_edit_plan_transitions_api.py b/tests/unit/test_edit_plan_transitions_api.py new file mode 100644 index 000000000..67ef57839 --- /dev/null +++ b/tests/unit/test_edit_plan_transitions_api.py @@ -0,0 +1,494 @@ +""" +转场特效 API 单元测试 + +覆盖: +- GET /transition-presets - 转场预设列表 +- PUT /clips/{clip_id}/transition - 设置单个片段转场 +- POST /{plan_id}/transitions/batch - 批量设置转场 +""" + +from __future__ import annotations + +import os +import sys +from datetime import datetime +from pathlib import Path +from typing import Optional +from unittest.mock import MagicMock + +os.environ.setdefault("JWT_SECRET_KEY", "unit-test-secret-key-for-testing") +os.environ.setdefault("DATABASE_URL", "sqlite:///test.db") + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "api")) + +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from packages.domain.config_schemas import normalize_plan_config +from packages.domain.edit_plan import EditPlan, EditPlanStatus +from packages.domain.edit_plan_clip import EditPlanClip, EditPlanClipStatus +from packages.domain.transition_presets import TRANSITION_PRESET_LIBRARY + +# --------------------------------------------------------------------------- +# Stub Repository +# --------------------------------------------------------------------------- + + +class StubEditPlanRepository: + def __init__(self, plans: dict[str, EditPlan] | None = None): + self._plans = plans or {} + + def list_all(self, *, status=None, skip=0, limit=50): + items = list(self._plans.values()) + if status is not None: + items = [p for p in items if p.status == status] + items.sort(key=lambda p: p.created_at, reverse=True) + return items[skip : skip + limit] + + def list_by_template(self, template_id, *, status=None, skip=0, limit=50): + items = [p for p in self._plans.values() if p.template_id == template_id] + if status is not None: + items = [p for p in items if p.status == status] + items.sort(key=lambda p: p.created_at, reverse=True) + return items[skip : skip + limit] + + def get(self, plan_id: str) -> Optional[EditPlan]: + return self._plans.get(plan_id) + + def create(self, plan: EditPlan) -> EditPlan: + self._plans[plan.id] = plan + return plan + + def update(self, plan: EditPlan) -> EditPlan: + self._plans[plan.id] = plan + return plan + + def delete(self, plan_id: str) -> bool: + if plan_id in self._plans: + del self._plans[plan_id] + return True + return False + + def count(self, *, status=None, template_id=None): + items = list(self._plans.values()) + if status is not None: + items = [p for p in items if p.status == status] + if template_id is not None: + items = [p for p in items if p.template_id == template_id] + return len(items) + + +class StubEditPlanClipRepository: + def __init__(self, clips: dict[str, EditPlanClip] | None = None): + self._clips = clips or {} + self._counter = 0 + + def _next_id(self) -> str: + self._counter += 1 + return f"clip-new{self._counter}" + + def list_by_plan(self, plan_id, *, status=None, skip=0, limit=100): + items = [c for c in self._clips.values() if c.plan_id == plan_id] + if status is not None: + items = [c for c in items if c.status == status] + items.sort(key=lambda c: c.order) + return items[skip : skip + limit] + + def count(self, plan_id, *, status=None): + items = [c for c in self._clips.values() if c.plan_id == plan_id] + if status is not None: + items = [c for c in items if c.status == status] + return len(items) + + def get(self, clip_id: str) -> Optional[EditPlanClip]: + return self._clips.get(clip_id) + + def create(self, clip: EditPlanClip) -> EditPlanClip: + if not clip.id: + clip.id = self._next_id() + self._clips[clip.id] = clip + return clip + + def update(self, clip: EditPlanClip) -> EditPlanClip: + self._clips[clip.id] = clip + return clip + + def delete(self, clip_id: str) -> bool: + if clip_id in self._clips: + del self._clips[clip_id] + return True + return False + + def delete_by_plan(self, plan_id: str) -> int: + to_delete = [cid for cid, c in self._clips.items() if c.plan_id == plan_id] + for cid in to_delete: + del self._clips[cid] + return len(to_delete) + + +# --------------------------------------------------------------------------- +# Test Fixtures +# --------------------------------------------------------------------------- + + +def _make_sample_plan(plan_id="plan-001"): + return EditPlan( + id=plan_id, + template_id="tpl-001", + name="测试计划", + status=EditPlanStatus.EDITING, + total_duration=30.0, + config=normalize_plan_config({}), + project_id="", + created_by_user_id="user-001", + created_at=datetime(2026, 7, 16, 10, 0, 0), + updated_at=datetime(2026, 7, 16, 10, 0, 0), + ) + + +def _make_clip(clip_id, plan_id="plan-001", order=0, transition_effect="cut", transition_duration=0.0): + return EditPlanClip( + id=clip_id, + plan_id=plan_id, + clip_type="video", + order=order, + asset_id="asset-001", + text_content="", + start_time=0.0, + duration=10.0, + transition_effect=transition_effect, + transition_duration=transition_duration, + playback_speed=1.0, + status=EditPlanClipStatus.READY, + config={}, + created_at=datetime(2026, 7, 16, 10, 0, 0), + updated_at=datetime(2026, 7, 16, 10, 0, 0), + ) + + +def _create_test_app(): + import app.api.routes.edit_plans_transitions as transitions_module + import app.services.edit_plan_service as service_module + from app.api.routes.edit_plans import router + + plan = _make_sample_plan() + clips = { + "clip-001": _make_clip("clip-001", order=0), + "clip-002": _make_clip("clip-002", order=1), + "clip-003": _make_clip("clip-003", order=2), + "clip-004": _make_clip("clip-004", order=3), + } + stub_plan_repo = StubEditPlanRepository({plan.id: plan}) + stub_clip_repo = StubEditPlanClipRepository(clips) + + original_plan_repo = service_module.SQLAlchemyEditPlanRepository + original_clip_repo = service_module.SQLAlchemyEditPlanClipRepository + original_gen_repo = service_module.SQLAlchemyGenerationTaskRepository + service_module.SQLAlchemyEditPlanRepository = lambda db: stub_plan_repo + service_module.SQLAlchemyEditPlanClipRepository = lambda db: stub_clip_repo + service_module.SQLAlchemyGenerationTaskRepository = lambda db: MagicMock() + + app = FastAPI() + app.include_router(router, prefix="/api/v1/edit-plans") + + # Mock 认证 + def _mock_auth(): + mock = MagicMock() + mock.user.id = "user-001" + return mock + + # Mock 项目访问检查 + import app.api.routes._helpers as helpers_module + + original_check = helpers_module.check_project_access + helpers_module.check_project_access = lambda *a, **kw: None + + # 覆盖依赖 + from app.api.routes import edit_plans as main_module + + app.dependency_overrides[main_module.get_current_user] = _mock_auth + app.dependency_overrides[main_module.get_db_session] = lambda: MagicMock() + app.dependency_overrides[main_module.get_project_repository] = lambda: MagicMock() + + app.dependency_overrides[transitions_module.get_current_user] = _mock_auth + app.dependency_overrides[transitions_module.get_db_session] = lambda: MagicMock() + app.dependency_overrides[transitions_module.get_project_repository] = lambda: MagicMock() + + def cleanup(): + service_module.SQLAlchemyEditPlanRepository = original_plan_repo + service_module.SQLAlchemyEditPlanClipRepository = original_clip_repo + service_module.SQLAlchemyGenerationTaskRepository = original_gen_repo + helpers_module.check_project_access = original_check + + return app, stub_plan_repo, stub_clip_repo, cleanup + + +@pytest.fixture +def transition_client(): + app, plan_repo, clip_repo, cleanup = _create_test_app() + yield TestClient(app), plan_repo, clip_repo + cleanup() + + +# --------------------------------------------------------------------------- +# Transition Presets 测试 +# --------------------------------------------------------------------------- + + +class TestTransitionPresets: + def test_list_all_presets(self, transition_client): + c, _, _ = transition_client + resp = c.get("/api/v1/edit-plans/transition-presets") + assert resp.status_code == 200 + data = resp.json() + assert data["total"] == len(TRANSITION_PRESET_LIBRARY) + assert data["total"] > 10 + first = data["items"][0] + assert "id" in first + assert "name" in first + assert "category" in first + assert "default_duration" in first + assert "min_duration" in first + assert "max_duration" in first + + def test_filter_by_category_fade(self, transition_client): + c, _, _ = transition_client + resp = c.get("/api/v1/edit-plans/transition-presets?category=fade") + assert resp.status_code == 200 + data = resp.json() + assert data["total"] >= 3 + for item in data["items"]: + assert item["category"] == "fade" + + def test_filter_by_category_slide(self, transition_client): + c, _, _ = transition_client + resp = c.get("/api/v1/edit-plans/transition-presets?category=slide") + assert resp.status_code == 200 + data = resp.json() + assert data["total"] >= 4 + for item in data["items"]: + assert item["category"] == "slide" + + def test_filter_by_keyword(self, transition_client): + c, _, _ = transition_client + resp = c.get("/api/v1/edit-plans/transition-presets?keyword=模糊") + assert resp.status_code == 200 + data = resp.json() + assert data["total"] > 0 + names = [item["name"] for item in data["items"]] + assert any("模糊" in n for n in names) + + def test_filter_empty_result(self, transition_client): + c, _, _ = transition_client + resp = c.get("/api/v1/edit-plans/transition-presets?keyword=不存在的转场") + assert resp.status_code == 200 + data = resp.json() + assert data["total"] == 0 + + def test_contains_none_transition(self, transition_client): + c, _, _ = transition_client + resp = c.get("/api/v1/edit-plans/transition-presets?category=basic") + assert resp.status_code == 200 + data = resp.json() + ids = [item["id"] for item in data["items"]] + assert "transition_none" in ids + + +# --------------------------------------------------------------------------- +# PUT /clips/{clip_id}/transition 测试 +# --------------------------------------------------------------------------- + + +class TestUpdateClipTransition: + def test_set_fade_transition(self, transition_client): + c, _, clip_repo = transition_client + resp = c.put( + "/api/v1/edit-plans/clips/clip-001/transition", + json={"effect": "transition_fade", "duration": 0.8}, + ) + assert resp.status_code == 200 + data = resp.json() + assert data["clip_id"] == "clip-001" + assert data["effect"] == "fade" + assert data["duration"] == 0.8 + + clip = clip_repo.get("clip-001") + assert clip.transition_effect == "fade" + assert clip.transition_duration == 0.8 + + def test_set_none_transition(self, transition_client): + c, _, clip_repo = transition_client + resp = c.put( + "/api/v1/edit-plans/clips/clip-001/transition", + json={"effect": "transition_none"}, + ) + assert resp.status_code == 200 + data = resp.json() + assert data["effect"] == "cut" + assert data["duration"] == 0.0 + + def test_use_default_duration(self, transition_client): + c, _, _ = transition_client + # 不传 duration,使用预设默认值 + resp = c.put( + "/api/v1/edit-plans/clips/clip-001/transition", + json={"effect": "transition_fade"}, + ) + assert resp.status_code == 200 + data = resp.json() + assert data["effect"] == "fade" + assert data["duration"] > 0 # 使用默认值 + + def test_invalid_effect(self, transition_client): + c, _, _ = transition_client + resp = c.put( + "/api/v1/edit-plans/clips/clip-001/transition", + json={"effect": "invalid_effect"}, + ) + assert resp.status_code == 400 + assert "无效的转场效果" in resp.json()["detail"] + + def test_clip_not_found(self, transition_client): + c, _, _ = transition_client + resp = c.put( + "/api/v1/edit-plans/clips/clip-nonexist/transition", + json={"effect": "transition_fade"}, + ) + assert resp.status_code == 404 + + def test_negative_duration_422(self, transition_client): + c, _, _ = transition_client + resp = c.put( + "/api/v1/edit-plans/clips/clip-001/transition", + json={"effect": "transition_fade", "duration": -0.5}, + ) + assert resp.status_code == 422 + + def test_duration_clamped_to_max(self, transition_client): + c, _, clip_repo = transition_client + # 传一个超过最大值的时长,应该被钳制 + resp = c.put( + "/api/v1/edit-plans/clips/clip-001/transition", + json={"effect": "transition_fade", "duration": 10.0}, + ) + assert resp.status_code == 200 + data = resp.json() + # fade 最大 2.0s + assert data["duration"] <= 2.0 + + +# --------------------------------------------------------------------------- +# POST /{plan_id}/transitions/batch 测试 +# --------------------------------------------------------------------------- + + +class TestBatchUpdateTransitions: + def test_batch_all(self, transition_client): + c, _, clip_repo = transition_client + resp = c.post( + "/api/v1/edit-plans/plan-001/transitions/batch", + json={"effect": "transition_fade", "duration": 0.5, "apply_to": "all"}, + ) + assert resp.status_code == 200 + data = resp.json() + assert data["updated_count"] == 4 # 4个片段 + + for cid in ["clip-001", "clip-002", "clip-003", "clip-004"]: + clip = clip_repo.get(cid) + assert clip.transition_effect == "fade" + assert clip.transition_duration == 0.5 + + def test_batch_except_first(self, transition_client): + c, _, clip_repo = transition_client + resp = c.post( + "/api/v1/edit-plans/plan-001/transitions/batch", + json={"effect": "transition_fade", "apply_to": "except_first"}, + ) + assert resp.status_code == 200 + data = resp.json() + assert data["updated_count"] == 3 + + # 第一个不变 + assert clip_repo.get("clip-001").transition_effect == "cut" + # 其余三个被更新 + for cid in ["clip-002", "clip-003", "clip-004"]: + assert clip_repo.get(cid).transition_effect == "fade" + + def test_batch_except_last(self, transition_client): + c, _, clip_repo = transition_client + resp = c.post( + "/api/v1/edit-plans/plan-001/transitions/batch", + json={"effect": "transition_slideleft", "apply_to": "except_last"}, + ) + assert resp.status_code == 200 + data = resp.json() + assert data["updated_count"] == 3 + + # 最后一个不变 + assert clip_repo.get("clip-004").transition_effect == "cut" + # 前三个被更新 + for cid in ["clip-001", "clip-002", "clip-003"]: + assert clip_repo.get(cid).transition_effect == "slideleft" + + def test_batch_middle(self, transition_client): + c, _, clip_repo = transition_client + resp = c.post( + "/api/v1/edit-plans/plan-001/transitions/batch", + json={"effect": "transition_dissolve", "apply_to": "middle"}, + ) + assert resp.status_code == 200 + data = resp.json() + assert data["updated_count"] == 2 # 4个片段,中间2个 + + # 首尾不变 + assert clip_repo.get("clip-001").transition_effect == "cut" + assert clip_repo.get("clip-004").transition_effect == "cut" + # 中间被更新 + assert clip_repo.get("clip-002").transition_effect == "dissolve" + assert clip_repo.get("clip-003").transition_effect == "dissolve" + + def test_batch_invalid_effect(self, transition_client): + c, _, _ = transition_client + resp = c.post( + "/api/v1/edit-plans/plan-001/transitions/batch", + json={"effect": "invalid"}, + ) + assert resp.status_code == 400 + + def test_batch_plan_not_found(self, transition_client): + c, _, _ = transition_client + resp = c.post( + "/api/v1/edit-plans/plan-nonexist/transitions/batch", + json={"effect": "transition_fade"}, + ) + assert resp.status_code == 404 + + def test_batch_invalid_apply_to(self, transition_client): + c, _, _ = transition_client + resp = c.post( + "/api/v1/edit-plans/plan-001/transitions/batch", + json={"effect": "transition_fade", "apply_to": "invalid"}, + ) + assert resp.status_code == 400 + + def test_batch_none_transition(self, transition_client): + c, _, clip_repo = transition_client + # 先设一个转场 + c.post( + "/api/v1/edit-plans/plan-001/transitions/batch", + json={"effect": "transition_fade", "apply_to": "all"}, + ) + # 再全部设为无 + resp = c.post( + "/api/v1/edit-plans/plan-001/transitions/batch", + json={"effect": "transition_none", "apply_to": "all"}, + ) + assert resp.status_code == 200 + assert resp.json()["updated_count"] == 4 + + for cid in ["clip-001", "clip-002", "clip-003", "clip-004"]: + clip = clip_repo.get(cid) + assert clip.transition_effect == "cut" + assert clip.transition_duration == 0.0