diff --git a/alembic/versions/048_cleanup_result_count.py b/alembic/versions/048_cleanup_result_count.py new file mode 100755 index 000000000..7986c7887 --- /dev/null +++ b/alembic/versions/048_cleanup_result_count.py @@ -0,0 +1,37 @@ +"""Phase 3 - 清理 EditPlan 表冗余字段 + +Revision ID: 048 +Revises: 047 +Create Date: 2026-07-21 + +Changes: +1. 删除 edit_plans.result_count 字段(剪辑计划独立功能遗留,模板草稿不用, + 生成结果数由 generation_tasks.result_count 承载) +""" + +import sqlalchemy as sa + +from alembic import op + +revision = "048_cleanup_result_count" +down_revision = "047_template_versioning" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # 删除 result_count 字段(剪辑计划独立功能遗留字段) + op.drop_column("edit_plans", "result_count") + + +def downgrade() -> None: + # 回滚:恢复 result_count 字段,默认值 0 + op.add_column( + "edit_plans", + sa.Column( + "result_count", + sa.Integer, + nullable=False, + server_default="0", + ), + ) diff --git a/apps/api/app/api/router.py b/apps/api/app/api/router.py index cc0118b68..a06c0a86c 100755 --- a/apps/api/app/api/router.py +++ b/apps/api/app/api/router.py @@ -5,7 +5,6 @@ from app.api.routes.auth import router as auth_router from app.api.routes.chunked_upload import router as chunked_upload_router from app.api.routes.classification_jobs import router as classification_jobs_router from app.api.routes.duplication import router as duplication_router -from app.api.routes.edit_plans import router as edit_plans_router from app.api.routes.feature_flags import router as feature_flags_router from app.api.routes.generation_tasks import router as generation_tasks_router from app.api.routes.health import router as health_check_router @@ -125,11 +124,6 @@ api_router.include_router( prefix="/templates/{template_id}/editor", tags=["TemplateEditor"], ) -api_router.include_router( - edit_plans_router, - prefix="/edit-plans", - tags=["EditPlan"], -) api_router.include_router( tts_router, prefix="/tts", diff --git a/apps/api/app/api/routes/_helpers.py b/apps/api/app/api/routes/_helpers.py index a0fc2c409..a17a2a6d3 100755 --- a/apps/api/app/api/routes/_helpers.py +++ b/apps/api/app/api/routes/_helpers.py @@ -139,26 +139,3 @@ def format_utc_datetime(dt: datetime | None) -> str: if dt.tzinfo is None: return dt.isoformat() + "Z" return dt.astimezone(timezone.utc).isoformat().replace("+00:00", "Z") - - -# ── Deprecated API 标记 ────────────────────────────────────────────────────── - - -import logging as _logging - -from fastapi import Request as _Request - -_deprecated_logger = _logging.getLogger(__name__) - - -def deprecated_edit_plans_api(request: _Request) -> None: - """标记 /edit-plans/* 系列 API 为废弃,打 warning 日志。 - - Phase 2 模板编辑器收敛后,所有剪辑计划 API 迁移到 /templates/{id}/editor/*。 - 旧路径保留 2 个版本周期兼容,之后会下线。 - """ - _deprecated_logger.warning( - "Deprecated API called: %s %s. Use /templates/{template_id}/editor/* instead.", - request.method, - request.url.path, - ) diff --git a/apps/api/app/api/routes/edit_plans.py b/apps/api/app/api/routes/edit_plans.py deleted file mode 100644 index 6374984cb..000000000 --- a/apps/api/app/api/routes/edit_plans.py +++ /dev/null @@ -1,1144 +0,0 @@ -"""剪辑计划管理 API — Phase 8 模板编排引擎. - -RESTful CRUD for EditPlan: -- GET /api/v1/edit-plans 列表(分页 + 状态/模板筛选) -- GET /api/v1/edit-plans/{id} 详情 -- POST /api/v1/edit-plans 创建 -- PUT /api/v1/edit-plans/{id} 更新(含状态机流转) -- DELETE /api/v1/edit-plans/{id} 删除 - -拆分模块(各自独立 router,由本文件 include_router 聚合): -- edit_plans_generation.py 生成相关(generate / generation-status / generations) -- edit_plans_ai.py AI 推荐 & 封面(ai-recommend / generate-cover) -- edit_plans_timeline.py 时间线 & 模板生成(timeline / generate-from-template) - -业务逻辑委托给 EditPlanService 服务层。 -""" - -from __future__ import annotations - -import logging -from datetime import datetime -from typing import Any, List, Optional - -from app.auth import AuthenticatedUser, get_current_user -from app.core.storage import get_storage_service -from app.dependencies import ( - get_asset_library_repository, - get_asset_repository, - get_db_session, - get_project_repository, -) -from app.schemas.generation_task import GenerationTaskResponse -from app.services import EditPlanService, EditTemplateService -from fastapi import APIRouter, Depends, HTTPException, Query, Response, status -from pydantic import BaseModel, Field -from sqlalchemy.orm import Session - -from packages.domain.config_schemas import normalize_plan_config -from packages.domain.edit_plan import EditPlan, EditPlanStatus - -from ._helpers import auto_select_video_assets, check_project_access, deprecated_edit_plans_api - -logger = logging.getLogger(__name__) - -router = APIRouter( - dependencies=[Depends(deprecated_edit_plans_api)], -) - - -# ── Pydantic Schemas ───────────────────────────────────────────────────────── - - -class EditPlanCreateRequest(BaseModel): - """创建剪辑计划请求体""" - - template_id: str = Field(..., min_length=1, max_length=32, description="关联模板 ID") - name: str = Field(..., min_length=1, max_length=200, description="计划名称") - config: dict[str, Any] = Field(default_factory=dict, description="计划配置 (JSON)") - total_duration: float = Field(default=0.0, ge=0.0, description="总时长 (秒)") - project_id: str = Field(default="", description="所属项目 ID") - asset_ids: List[str] = Field(default_factory=list, description="素材 ID 列表,创建时自动分配给片段") - - -class EditPlanUpdateRequest(BaseModel): - """更新剪辑计划请求体""" - - name: Optional[str] = Field(default=None, min_length=1, max_length=200, description="计划名称") - config: Optional[dict[str, Any]] = Field(default=None, description="计划配置 (JSON)") - total_duration: Optional[float] = Field(default=None, ge=0.0, description="总时长 (秒)") - status: Optional[str] = Field( - default=None, - description="目标状态 (通过状态机流转): editing / rendering / completed / failed / draft", - ) - - -class CopyPlanRequest(BaseModel): - """复制剪辑计划请求体""" - - name: Optional[str] = Field( - default=None, min_length=1, max_length=200, description="新计划名称,不传则为「原名 - 副本」" - ) - project_id: Optional[str] = Field(default=None, description="目标项目 ID,不传则复用源计划的项目") - - -class EditPlanResponse(BaseModel): - """剪辑计划响应体""" - - id: str - template_id: str - name: str - status: str - total_duration: float - result_count: int = 0 - project_id: str = "" - created_by_user_id: str = "" - config: dict[str, Any] - created_at: datetime - updated_at: datetime - - model_config = {"from_attributes": True} - - -class EditPlanListResponse(BaseModel): - """剪辑计划列表响应体""" - - items: List[EditPlanResponse] - total: int - page: int - page_size: int - - -class ClipStatusItem(BaseModel): - """片段生成状态""" - - clip_id: str - clip_type: str - order: int - status: str - asset_id: str - text_content: str - duration: float - - -class EditPlanGenerationStatusResponse(BaseModel): - """剪辑计划生成进度响应体""" - - plan_id: str - plan_status: str - generation_task_id: Optional[str] = None - generation_task_status: Optional[str] = None - progress: float = 0.0 - video_url: str = "" - error_message: str = "" - clips: List[ClipStatusItem] - - -class EditPlanGenerateResponse(BaseModel): - """剪辑计划触发生成响应体""" - - plan_id: str - plan_status: str - generation_task_id: str - clip_count: int - - -class EditPlanGenerationsResponse(BaseModel): - """剪辑计划关联的生成记录列表响应体""" - - items: List[GenerationTaskResponse] - total: int - - -# ── AI 推荐片段方案 Schemas(任务 3.09) ────────────────────────────────────── - - -class AIRecommendRequest(BaseModel): - """AI 推荐片段方案请求体""" - - asset_ids: List[str] = Field(default_factory=list, description="素材 ID 列表") - editing_mode: str = Field(default="one_take", description="剪辑模式: one_take / pip / voice_over / voice_pip") - target_duration: float = Field(default=30.0, ge=1.0, le=600.0, description="目标时长(秒)") - - -class AIRecommendClipItem(BaseModel): - """AI 推荐的单个片段""" - - clip_type: str = Field(..., description="片段类型: intro / showcase / title / subtitle / cta / outro") - order: int = Field(..., ge=0, description="片段顺序") - text_content: str = Field(default="", description="文字内容") - duration: float = Field(..., ge=0.0, description="片段时长(秒)") - transition_effect: str = Field(default="cut", description="转场效果") - transition_duration: float = Field(default=0.0, ge=0.0, description="转场时长(秒),0 表示使用默认值") - asset_id: str = Field(default="", description="关联素材 ID") - start_time: float = Field(default=0.0, ge=0.0, description="素材截取起始时间(秒)") - config: dict[str, Any] = Field(default_factory=dict, description="片段额外配置") - - -class AIRecommendResponse(BaseModel): - """AI 推荐片段方案响应体""" - - plan_id: str = Field(..., description="剪辑计划 ID") - clips: List[AIRecommendClipItem] = Field(..., description="推荐的片段列表") - config: dict[str, Any] = Field(..., description="推荐的 plan config(cover/title/subtitle/bgm)") - total_duration: float = Field(..., ge=0.0, description="推荐方案总时长(秒)") - confidence: float = Field(..., ge=0.0, le=1.0, description="AI 推荐置信度 (0~1)") - - -# ── AI 封面生成 Schemas(任务 3.09) ───────────────────────────────────────── - - -class GenerateCoverRequest(BaseModel): - """AI 封面生成请求体""" - - asset_ids: List[str] = Field(default_factory=list, description="素材 ID 列表(确定视频来源)") - cover_type: str = Field( - default="ai_frame", - description="封面类型: ai_frame / manual / upload / ai_regenerate", - ) - frame_time: Optional[float] = Field( - default=None, - ge=0.0, - description="手动选帧时间点(秒),仅 cover_type=manual 时有效", - ) - - -class GenerateCoverResponse(BaseModel): - """AI 封面生成响应体""" - - plan_id: str = Field(..., description="剪辑计划 ID") - cover: dict[str, Any] = Field(..., description="封面数据(type / image_url / frame_time 等)") - - -# ── 基于模板生成剪辑计划 Schemas ───────────────────────────────────────────── - - -class GenerateFromTemplateRequest(BaseModel): - """基于模板生成剪辑计划请求体""" - - template_id: str = Field(..., description="剪辑模板 ID") - asset_ids: List[str] = Field(default_factory=list, description="素材 ID 列表") - project_id: str = Field(default="", description="所属项目 ID") - name: str = Field(default="", description="计划名称(为空则自动取模板名)") - - -class _PlanClipItem(BaseModel): - """片段响应体""" - - id: str - clip_type: str - order: int - asset_id: str - text_content: str - start_time: float - duration: float - transition_effect: str - transition_duration: float - playback_speed: float = 1.0 - status: str - config: Optional[dict[str, Any]] = None - created_at: datetime - updated_at: datetime - - model_config = {"from_attributes": True} - - -class GenerateFromTemplateResponse(BaseModel): - """基于模板生成剪辑计划响应体""" - - plan: EditPlanResponse - clips: List[_PlanClipItem] - - -# ── Helpers ─────────────────────────────────────────────────────────────────── - - -def _to_response(p: EditPlan) -> EditPlanResponse: - # 对 config 中的 rendered_url 做签名转换(私有bucket裸URL会403) - config = dict(p.config) if p.config else {} - raw_rendered_url = config.get("rendered_url", "") - if raw_rendered_url: - try: - storage = get_storage_service() - config["rendered_url"] = storage.get_download_url(raw_rendered_url, expires_seconds=86400) - except Exception as e: - logger.warning("剪辑计划rendered_url签名失败,返回原始URL: plan_id=%s error=%s", p.id, e) - - return EditPlanResponse( - id=p.id, - template_id=p.template_id, - name=p.name, - status=p.status.value if hasattr(p.status, "value") else p.status, - total_duration=p.total_duration, - result_count=getattr(p, "result_count", 0), - project_id=p.project_id or "", - created_by_user_id=p.created_by_user_id or "", - config=config, - created_at=p.created_at, - updated_at=p.updated_at, - ) - - -# ── Include sub-routers (拆分模块) ──────────────────────────────────────────── -# 注意:含静态路径的子路由需放在 CRUD 路由之前,避免被 /{plan_id} 抢先匹配 - -from .edit_plans_adjustments import router as adjustments_router -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(adjustments_router) -router.include_router(filter_router) -router.include_router(transitions_router) - - -# ── CRUD Routes ─────────────────────────────────────────────────────────────── - - -@router.get("", response_model=EditPlanListResponse, deprecated=True) -def list_plans( - page: int = Query(default=1, ge=1, description="页码"), - page_size: int = Query(default=20, ge=1, le=100, description="每页数量"), - template_id: Optional[str] = Query(default=None, description="按模板 ID 筛选"), - project_id: Optional[str] = Query(default=None, description="按项目 ID 筛选"), - status_filter: Optional[str] = Query( - default=None, - alias="status", - description="按状态筛选: draft / editing / rendering / completed / failed", - ), - db: Session = Depends(get_db_session), - current_user: AuthenticatedUser = Depends(get_current_user), - project_repository: Any = Depends(get_project_repository), -) -> EditPlanListResponse: - """获取剪辑计划列表(支持分页、按模板/状态/项目筛选)""" - svc = EditPlanService(db) - - # 空串 project_id 视为未传(避免 DB 中匹配到空串记录) - if project_id is not None and not project_id.strip(): - project_id = None - - # 解析状态筛选 - status_enum: Optional[EditPlanStatus] = None - if status_filter: - try: - status_enum = EditPlanStatus(status_filter) - except ValueError as _e: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="无效的筛选条件,请选择正确的状态", - ) from _e - - # 项目鉴权:如果指定了 project_id,校验用户是否有权访问 - if project_id: - check_project_access(project_id, current_user.user.id, project_repository) - - skip = (page - 1) * page_size - plans = svc.list_plans( - template_id=template_id, - project_id=project_id, - status=status_enum, - skip=skip, - limit=page_size, - ) - total = svc.count_plans( - template_id=template_id, - project_id=project_id, - status=status_enum, - ) - - return EditPlanListResponse( - items=[_to_response(p) for p in plans], - total=total, - page=page, - page_size=page_size, - ) - - -@router.get("/{plan_id}", response_model=EditPlanResponse, deprecated=True) -def get_plan( - plan_id: str, - db: Session = Depends(get_db_session), - current_user: AuthenticatedUser = Depends(get_current_user), - project_repository: Any = Depends(get_project_repository), -) -> EditPlanResponse: - """获取单个剪辑计划详情""" - svc = EditPlanService(db) - try: - plan = svc.get_plan_or_raise(plan_id) - except ValueError as exc: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=str(exc), - ) from exc - # 项目鉴权 - if plan.project_id: - check_project_access(plan.project_id, current_user.user.id, project_repository) - return _to_response(plan) - - -@router.post("", response_model=EditPlanResponse, status_code=status.HTTP_201_CREATED, deprecated=True) -def create_plan( - body: EditPlanCreateRequest, - db: Session = Depends(get_db_session), - current_user: AuthenticatedUser = Depends(get_current_user), - project_repository: Any = Depends(get_project_repository), - asset_repository: Any = Depends(get_asset_repository), - asset_library_repository: Any = Depends(get_asset_library_repository), -) -> EditPlanResponse: - """创建剪辑计划 - - 基于模板自动生成片段结构: - - 模板存在时:从模板的 clip_configs 生成初始 clips - - 模板不存在时:降级为空计划(保持向后兼容) - - 用户传入的 config 与模板 config 合并(用户配置优先级更高) - - total_duration 自动根据 clips 总时长计算 - """ - from app.services import PlanGeneratorService - - # 空串 project_id 统一为 "" - project_id = (body.project_id or "").strip() - # 项目鉴权 - if project_id: - check_project_access(project_id, current_user.user.id, project_repository) - - # 标准化用户传入的 config - normalized_config = normalize_plan_config(body.config or {}) - - template_svc = EditTemplateService(db) - svc = EditPlanService(db) - - # 尝试从模板生成(模板不存在时降级为空计划) - template = None - clips = [] - try: - template = template_svc.get_template_or_raise(body.template_id) - except ValueError: - # 模板不存在,降级为普通空计划 - logger.info("模板不存在,创建空计划: template_id=%s", body.template_id) - plan = svc.create_plan( - template_id=body.template_id, - name=body.name, - config=normalized_config, - project_id=project_id, - created_by_user_id=current_user.user.id, - total_duration=body.total_duration if body.total_duration > 0 else 0.0, - ) - logger.info( - "创建空剪辑计划: id=%s name=%s by user=%s", - plan.id, - plan.name, - current_user.user.id, - ) - return _to_response(plan) - - # 模板存在,从模板生成计划+片段 - clip_configs = template_svc.list_clip_configs(body.template_id, skip=0, limit=200) - - # 自动选素材:未传 asset_ids 但有 project_id 时,从项目视频素材库选 ready 的视频素材 - resolved_asset_ids = list(body.asset_ids) - if not resolved_asset_ids and project_id: - auto_assets = auto_select_video_assets( - project_id=project_id, - asset_library_repo=asset_library_repository, - asset_repo=asset_repository, - logger=logger, - ) - if auto_assets: - resolved_asset_ids = auto_assets - logger.info( - "自动选素材: project_id=%s count=%d", - project_id, - len(auto_assets), - ) - - generator = PlanGeneratorService(db) - try: - result = generator.generate_from_template( - template=template, - clip_configs=clip_configs, - asset_ids=resolved_asset_ids, - project_id=project_id, - created_by_user_id=current_user.user.id, - name=body.name, - ) - except ValueError as exc: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=str(exc), - ) from exc - - plan = result["plan"] - clips = result["clips"] - - # 如果用户传入了自定义 config,合并覆盖模板配置 - if body.config: - base_config = template.config or {} - merged_config = {**base_config, **normalized_config} - # 重新标准化确保默认值填充正确 - merged_config = normalize_plan_config(merged_config) - plan = svc.update_plan( - plan.id, - config=merged_config, - total_duration=body.total_duration if body.total_duration > 0 else None, - ) - - # 把 asset_ids 写入 plan.config,供生成时兜底分配使用 - if resolved_asset_ids: - current_config = plan.config or {} - if current_config.get("asset_ids") != resolved_asset_ids: - current_config["asset_ids"] = resolved_asset_ids - plan = svc.update_plan(plan.id, config=normalize_plan_config(current_config)) - - logger.info( - "创建剪辑计划: id=%s name=%s clips=%d by user=%s", - plan.id, - plan.name, - len(clips), - current_user.user.id, - ) - return _to_response(plan) - - -@router.put("/{plan_id}", response_model=EditPlanResponse, deprecated=True) -def update_plan( - plan_id: str, - body: EditPlanUpdateRequest, - db: Session = Depends(get_db_session), - current_user: AuthenticatedUser = Depends(get_current_user), - project_repository: Any = Depends(get_project_repository), -) -> EditPlanResponse: - """更新剪辑计划(支持状态机流转)""" - svc = EditPlanService(db) - # 项目鉴权 - existing = svc.get_plan(plan_id) - if existing is None: - raise HTTPException(status_code=404, detail=f"剪辑计划不存在: {plan_id}") - if existing.project_id: - check_project_access(existing.project_id, current_user.user.id, project_repository) - - # 基础字段更新 - try: - if body.name is not None or body.config is not None or body.total_duration is not None: - # 标准化 config(如果提供了) - config_to_update = normalize_plan_config(body.config) if body.config is not None else None - svc.update_plan( - plan_id, - name=body.name, - config=config_to_update, - total_duration=body.total_duration, - ) - - # 状态机流转 - if body.status is not None: - try: - target_status = EditPlanStatus(body.status) - except ValueError as _e: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="无效的状态值,请选择正确的状态", - ) from _e - svc.transition_status(plan_id, target_status) - except ValueError as exc: - err_msg = str(exc) - if "不存在" in err_msg: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=err_msg, - ) from exc - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=err_msg, - ) from exc - - # 返回最新状态 - result = svc.get_plan_or_raise(plan_id) - logger.info("更新剪辑计划: id=%s by user=%s", plan_id, current_user.user.id) - return _to_response(result) - - -@router.delete( - "/{plan_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None, response_class=Response, deprecated=True -) -def delete_plan( - plan_id: str, - db: Session = Depends(get_db_session), - current_user: AuthenticatedUser = Depends(get_current_user), - project_repository: Any = Depends(get_project_repository), -) -> None: - """删除剪辑计划""" - svc = EditPlanService(db) - # 项目鉴权 - existing = svc.get_plan(plan_id) - if existing and existing.project_id: - check_project_access(existing.project_id, current_user.user.id, project_repository) - deleted = svc.delete_plan(plan_id) - if not deleted: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=f"剪辑计划不存在: {plan_id}", - ) - logger.info( - "删除剪辑计划: id=%s by user=%s", - plan_id, - current_user.user.id, - ) - - -@router.post("/{plan_id}/copy", response_model=EditPlanResponse, status_code=status.HTTP_201_CREATED, deprecated=True) -def copy_plan( - plan_id: str, - body: CopyPlanRequest, - db: Session = Depends(get_db_session), - current_user: AuthenticatedUser = Depends(get_current_user), - project_repository: Any = Depends(get_project_repository), -) -> EditPlanResponse: - """复制剪辑计划(含所有片段配置) - - 新计划状态为 editing,不含生成任务和结果记录。 - """ - svc = EditPlanService(db) - - # 源计划鉴权 - existing = svc.get_plan(plan_id) - if existing is None: - raise HTTPException(status_code=404, detail=f"剪辑计划不存在: {plan_id}") - if existing.project_id: - check_project_access(existing.project_id, current_user.user.id, project_repository) - - # 目标项目鉴权(如果指定了不同的项目) - target_project_id = body.project_id if body.project_id is not None else existing.project_id - if target_project_id and target_project_id != existing.project_id: - check_project_access(target_project_id, current_user.user.id, project_repository) - - try: - new_plan = svc.copy_plan( - plan_id, - new_name=body.name, - project_id=target_project_id, - ) - except ValueError as e: - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e - - logger.info( - "复制剪辑计划: source=%s target=%s by user=%s", - plan_id, - new_plan.id, - current_user.user.id, - ) - return _to_response(new_plan) - - -# ── 字幕管理 ──────────────────────────────────────────────────────────────── - - -class SubtitleCreateRequest(BaseModel): - """添加字幕请求体""" - - start: float = Field(..., ge=0, description="开始时间(秒)") - end: float = Field(..., gt=0, description="结束时间(秒)") - text: str = Field(..., min_length=1, max_length=500, description="字幕文本") - style: Optional[dict[str, Any]] = Field(default=None, description="字幕样式") - - -class SubtitleUpdateRequest(BaseModel): - """更新字幕请求体""" - - start: Optional[float] = Field(default=None, ge=0, description="开始时间(秒)") - end: Optional[float] = Field(default=None, gt=0, description="结束时间(秒)") - text: Optional[str] = Field(default=None, min_length=1, max_length=500, description="字幕文本") - style: Optional[dict[str, Any]] = Field(default=None, description="字幕样式") - - -class SubtitleBatchUpdateRequest(BaseModel): - """批量更新字幕请求体""" - - subtitles: list[dict[str, Any]] = Field( - default_factory=list, - description="字幕列表(全量替换),每条包含 start/end/text,可选 id/style", - ) - - -@router.get( - "/clips/{clip_id}/subtitles", - response_model=list[dict[str, Any]], - summary="获取片段的所有字幕", - deprecated=True, -) -def list_subtitles( - clip_id: str, - current_user: AuthenticatedUser = Depends(get_current_user), - db: Session = Depends(get_db_session), - project_repo=Depends(get_project_repository), -) -> list[dict[str, Any]]: - """获取指定片段的所有字幕,按时间排序。""" - service = EditPlanService(db) - - clip = service.get_clip(clip_id) - if clip is None: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=f"片段不存在: {clip_id}", - ) - plan = service.get_plan(clip.plan_id) - if plan and plan.project_id: - check_project_access(project_repo, current_user, plan.project_id) - - return service.list_subtitles(clip_id) - - -@router.post( - "/clips/{clip_id}/subtitles", - response_model=dict[str, Any], - summary="添加一条字幕", - status_code=status.HTTP_201_CREATED, - deprecated=True, -) -def add_subtitle( - clip_id: str, - body: SubtitleCreateRequest, - current_user: AuthenticatedUser = Depends(get_current_user), - db: Session = Depends(get_db_session), - project_repo=Depends(get_project_repository), -) -> dict[str, Any]: - """给片段添加一条字幕。""" - service = EditPlanService(db) - - clip = service.get_clip(clip_id) - if clip is None: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=f"片段不存在: {clip_id}", - ) - plan = service.get_plan(clip.plan_id) - if plan and plan.project_id: - check_project_access(project_repo, current_user, plan.project_id) - - try: - subtitle = service.add_subtitle( - clip_id, - start=body.start, - end=body.end, - text=body.text, - style=body.style, - ) - except ValueError as e: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=str(e), - ) from e - - logger.info("添加字幕: clip_id=%s by user=%s", clip_id, current_user.user.id) - return subtitle - - -@router.put( - "/clips/{clip_id}/subtitles/{subtitle_id}", - response_model=dict[str, Any], - summary="更新一条字幕", - deprecated=True, -) -def update_subtitle( - clip_id: str, - subtitle_id: str, - body: SubtitleUpdateRequest, - current_user: AuthenticatedUser = Depends(get_current_user), - db: Session = Depends(get_db_session), - project_repo=Depends(get_project_repository), -) -> dict[str, Any]: - """更新一条字幕的时间、文本或样式。""" - service = EditPlanService(db) - - clip = service.get_clip(clip_id) - if clip is None: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=f"片段不存在: {clip_id}", - ) - plan = service.get_plan(clip.plan_id) - if plan and plan.project_id: - check_project_access(project_repo, current_user, plan.project_id) - - try: - subtitle = service.update_subtitle( - clip_id, - subtitle_id, - start=body.start, - end=body.end, - text=body.text, - style=body.style, - ) - except ValueError as e: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=str(e), - ) from e - - logger.info( - "更新字幕: clip_id=%s subtitle_id=%s by user=%s", - clip_id, - subtitle_id, - current_user.user.id, - ) - return subtitle - - -@router.delete( - "/clips/{clip_id}/subtitles/{subtitle_id}", - summary="删除一条字幕", - status_code=status.HTTP_204_NO_CONTENT, - response_model=None, - response_class=Response, - deprecated=True, -) -def delete_subtitle( - clip_id: str, - subtitle_id: str, - current_user: AuthenticatedUser = Depends(get_current_user), - db: Session = Depends(get_db_session), - project_repo=Depends(get_project_repository), -) -> None: - """删除一条字幕。""" - service = EditPlanService(db) - - clip = service.get_clip(clip_id) - if clip is None: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=f"片段不存在: {clip_id}", - ) - plan = service.get_plan(clip.plan_id) - if plan and plan.project_id: - check_project_access(project_repo, current_user, plan.project_id) - - deleted = service.delete_subtitle(clip_id, subtitle_id) - if not deleted: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=f"字幕不存在: {subtitle_id}", - ) - - logger.info( - "删除字幕: clip_id=%s subtitle_id=%s by user=%s", - clip_id, - subtitle_id, - current_user.user.id, - ) - - -@router.put( - "/clips/{clip_id}/subtitles", - response_model=list[dict[str, Any]], - summary="批量更新字幕(全量替换)", - deprecated=True, -) -def batch_update_subtitles( - clip_id: str, - body: SubtitleBatchUpdateRequest, - current_user: AuthenticatedUser = Depends(get_current_user), - db: Session = Depends(get_db_session), - project_repo=Depends(get_project_repository), -) -> list[dict[str, Any]]: - """批量更新片段的所有字幕(全量替换)。 - - 用于批量编辑、SRT导入、ASR结果导入等场景。 - 每条字幕包含 start/end/text,已有 id 则保留,否则生成新 id。 - """ - service = EditPlanService(db) - - clip = service.get_clip(clip_id) - if clip is None: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=f"片段不存在: {clip_id}", - ) - plan = service.get_plan(clip.plan_id) - if plan and plan.project_id: - check_project_access(project_repo, current_user, plan.project_id) - - try: - subtitles = service.batch_update_subtitles(clip_id, body.subtitles) - except ValueError as e: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=str(e), - ) from e - - logger.info( - "批量更新字幕: clip_id=%s count=%d by user=%s", - clip_id, - len(subtitles), - current_user.user.id, - ) - return subtitles - - -# ── BGM 背景音乐 ─────────────────────────────────────────────────────────── - - -class BGMConfigUpdateRequest(BaseModel): - """更新BGM配置请求体""" - - enabled: Optional[bool] = Field(default=None, description="是否启用 BGM") - source: Optional[str] = Field(default=None, description="BGM 来源: library/upload/ai_recommend") - asset_id: Optional[str] = Field(default=None, max_length=64, description="BGM 素材 ID") - preset_id: Optional[str] = Field(default=None, max_length=64, description="预设 BGM ID") - audio_url: Optional[str] = Field(default=None, max_length=500, description="BGM 音频 URL") - volume: Optional[float] = Field(default=None, ge=0.0, le=1.0, description="音量 (0.0 ~ 1.0)") - fade_in: Optional[float] = Field(default=None, ge=0.0, le=30.0, description="淡入时长(秒)") - fade_out: Optional[float] = Field(default=None, ge=0.0, le=30.0, description="淡出时长(秒)") - loop_enabled: Optional[bool] = Field(default=None, description="是否循环播放") - sidechain_enabled: Optional[bool] = Field(default=None, description="是否启用人声闪避") - sidechain_ratio: Optional[float] = Field(default=None, ge=0.0, le=1.0, description="闪避音量降低比例") - - -@router.get( - "/{plan_id}/bgm", - response_model=dict[str, Any], - summary="获取剪辑计划的 BGM 配置", - deprecated=True, -) -def get_plan_bgm( - plan_id: str, - current_user: AuthenticatedUser = Depends(get_current_user), - db: Session = Depends(get_db_session), - project_repo=Depends(get_project_repository), -) -> dict[str, Any]: - """获取指定剪辑计划的 BGM 配置。""" - service = EditPlanService(db) - - plan = service.get_plan(plan_id) - if plan is None: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=f"剪辑计划不存在: {plan_id}", - ) - if plan.project_id: - check_project_access(project_repo, current_user, plan.project_id) - - config = plan.config or {} - bgm_config = config.get("bgm", {}) - - return { - "plan_id": plan.id, - "bgm": bgm_config, - } - - -@router.put( - "/{plan_id}/bgm", - response_model=dict[str, Any], - summary="更新剪辑计划的 BGM 配置", - deprecated=True, -) -def update_plan_bgm( - plan_id: str, - body: BGMConfigUpdateRequest, - current_user: AuthenticatedUser = Depends(get_current_user), - db: Session = Depends(get_db_session), - project_repo=Depends(get_project_repository), -) -> dict[str, Any]: - """更新剪辑计划的 BGM 配置。 - - 支持部分更新,只传需要修改的字段即可。 - 启用 BGM 后需要指定来源(asset_id / preset_id / audio_url 三选一)。 - """ - service = EditPlanService(db) - - plan = service.get_plan(plan_id) - if plan is None: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=f"剪辑计划不存在: {plan_id}", - ) - if plan.project_id: - check_project_access(project_repo, current_user, plan.project_id) - - # 读取当前 BGM 配置,合并更新 - config = dict(plan.config) if plan.config else {} - current_bgm = dict(config.get("bgm", {})) - - update_data = body.model_dump(exclude_none=True) - current_bgm.update(update_data) - - # 校验:启用 BGM 时至少有一个有效来源 - if current_bgm.get("enabled"): - has_source = any(current_bgm.get(key) for key in ("asset_id", "preset_id", "audio_url") if current_bgm.get(key)) - if not has_source: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="启用 BGM 时需要指定素材来源(asset_id / preset_id / audio_url)", - ) - - # 保存到 plan.config.bgm - config["bgm"] = current_bgm - updated_plan = service.update_plan_config(plan_id, config) - - logger.info( - "更新BGM配置: plan_id=%s enabled=%s by user=%s", - plan_id, - current_bgm.get("enabled", False), - current_user.user.id, - ) - - return { - "plan_id": updated_plan.id, - "bgm": current_bgm, - } - - -# ── BGM 预设库 ──────────────────────────────────────────────────────────────── - - -@router.get( - "/bgm/presets", - response_model=dict[str, Any], - summary="获取预设 BGM 列表", - deprecated=True, -) -def list_bgm_presets( - style: Optional[str] = Query(default=None, description="按风格筛选"), - keyword: Optional[str] = Query(default=None, description="关键词搜索"), - skip: int = Query(default=0, ge=0, description="分页偏移"), - limit: int = Query(default=50, ge=1, le=200, description="每页数量"), -) -> dict[str, Any]: - """获取预设 BGM 列表,支持按风格筛选和关键词搜索。 - - 风格可选: upbeat(轻快)、relax(治愈)、tech(科技)、commerce(电商)、 - emotional(情感)、cinematic(电影) - """ - from packages.domain.preset_bgm import ( - BGM_STYLES, - PRESET_BGM_LIBRARY, - list_preset_bgm_by_style, - search_preset_bgm, - ) - - bgm_list = PRESET_BGM_LIBRARY - - if keyword: - bgm_list = search_preset_bgm(keyword) - elif style: - bgm_list = list_preset_bgm_by_style(style) - - total = len(bgm_list) - paged = bgm_list[skip : skip + limit] - - return { - "total": total, - "skip": skip, - "limit": limit, - "styles": BGM_STYLES, - "items": [ - { - "id": bgm.id, - "name": bgm.name, - "style": bgm.style, - "style_label": BGM_STYLES.get(bgm.style, bgm.style), - "duration": bgm.duration, - "artist": bgm.artist, - "description": bgm.description, - "tags": bgm.tags, - "audio_url": bgm.audio_url, - } - for bgm in paged - ], - } - - -# ── 保存为模板 ──────────────────────────────────────────────────────────────── - - -class SaveAsTemplateRequest(BaseModel): - """保存为模板请求体""" - - name: str = Field(..., min_length=1, max_length=200, description="模板名称") - description: str = Field(default="", max_length=500, description="模板描述") - template_type: str = Field(default="custom", max_length=50, description="模板类型") - preview_url: str = Field(default="", max_length=500, description="预览图 URL") - - -@router.post( - "/{plan_id}/save-as-template", - response_model=dict[str, Any], - summary="将剪辑计划保存为模板", - status_code=status.HTTP_201_CREATED, - deprecated=True, -) -def save_plan_as_template( - plan_id: str, - body: SaveAsTemplateRequest, - current_user: AuthenticatedUser = Depends(get_current_user), - db: Session = Depends(get_db_session), - project_repo=Depends(get_project_repository), -) -> dict[str, Any]: - """将指定剪辑计划的配置和片段结构保存为一个新模板。 - - 新模板会复制计划的所有片段配置(类型、时长、转场、文案等), - 但不绑定具体素材,可重复用于创建新的剪辑计划。 - """ - # 校验计划存在性和项目权限 - plan_service = EditPlanService(db) - plan = plan_service.get_plan(plan_id) - if plan is None: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=f"剪辑计划不存在: {plan_id}", - ) - if plan.project_id: - check_project_access(project_repo, current_user, plan.project_id) - - template_service = EditTemplateService(db) - try: - result = template_service.save_plan_as_template( - plan_id=plan_id, - name=body.name, - description=body.description, - template_type=body.template_type, - preview_url=body.preview_url, - ) - except ValueError as e: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=str(e), - ) from e - - template = result["template"] - clip_configs = result["clip_configs"] - - logger.info( - "保存计划为模板: plan_id=%s template_id=%s name=%s by user=%s", - plan_id, - template.id, - body.name, - current_user.user.id, - ) - - return { - "id": template.id, - "name": template.name, - "description": template.description, - "template_type": template.template_type, - "editing_mode": template.editing_mode, - "preview_url": template.preview_url, - "status": template.status.value, - "clip_count": len(clip_configs), - "created_at": template.created_at.isoformat(), - } - - -# ── Include sub-routers (拆分模块) ──────────────────────────────────────────── - -from .edit_plans_ai import router as ai_router -from .edit_plans_clips import router as clips_router -from .edit_plans_clips_batch import router as clips_batch_router -from .edit_plans_cover import router as cover_router -from .edit_plans_generation import router as generation_router -from .edit_plans_timeline import router as timeline_router - -router.include_router(generation_router) -router.include_router(ai_router) -router.include_router(timeline_router) -router.include_router(clips_router, prefix="/{plan_id}/clips", tags=["EditPlan Clips"]) -router.include_router(clips_batch_router, prefix="/{plan_id}/clips", tags=["EditPlan Clips"]) -router.include_router(cover_router) diff --git a/apps/api/app/api/routes/edit_plans_adjustments.py b/apps/api/app/api/routes/edit_plans_adjustments.py deleted file mode 100644 index c67b62d20..000000000 --- a/apps/api/app/api/routes/edit_plans_adjustments.py +++ /dev/null @@ -1,313 +0,0 @@ -"""片段调整 API. - -- PUT /clips/{clip_id}/speed 调速 -- PUT /clips/{clip_id}/volume 音量调节 -- PUT /clips/{clip_id}/trim 裁剪(trim in/out) -- PUT /clips/{clip_id}/adjustments 统一调整(speed+volume+trim) -- POST /{plan_id}/clips/batch-speed 批量调速 -""" - -from __future__ import annotations - -import logging -from typing import Any, 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, status -from pydantic import BaseModel, Field -from sqlalchemy.orm import Session - -from ._helpers import check_project_access, deprecated_edit_plans_api - -logger = logging.getLogger(__name__) - -router = APIRouter( - dependencies=[Depends(deprecated_edit_plans_api)], -) - - -# ── Schemas ────────────────────────────────────────────────────────────────── - - -class SpeedAdjustRequest(BaseModel): - """调速请求""" - - speed: float = Field(..., ge=0.25, le=4.0, description="播放速度 0.25~4.0") - - -class VolumeAdjustRequest(BaseModel): - """音量调节请求""" - - volume: float = Field(..., ge=0.0, le=2.0, description="音量倍率 0~2.0(1.0=原音量)") - - -class TrimAdjustRequest(BaseModel): - """裁剪请求""" - - trim_start: float = Field(0.0, ge=0.0, description="开头裁剪秒数") - trim_end: float = Field(0.0, ge=0.0, description="结尾裁剪秒数") - - -class ClipAdjustmentsRequest(BaseModel): - """统一调整请求""" - - speed: Optional[float] = Field(default=None, ge=0.25, le=4.0) - volume: Optional[float] = Field(default=None, ge=0.0, le=2.0) - trim_start: Optional[float] = Field(default=None, ge=0.0) - trim_end: Optional[float] = Field(default=None, ge=0.0) - - -class BatchSpeedRequest(BaseModel): - """批量调速请求""" - - speed: float = Field(..., ge=0.25, le=4.0, description="播放速度") - - -class ClipAdjustResponse(BaseModel): - """片段调整响应""" - - clip_id: str - speed: float - volume: float - trim_start: float - trim_end: float - duration: float - - -class BatchSpeedResponse(BaseModel): - """批量调速响应""" - - updated_count: int - plan_id: str - - -# ── Helpers ────────────────────────────────────────────────────────────────── - - -def _get_clip_config(clip) -> dict: - config = getattr(clip, "config", {}) or {} - if not isinstance(config, dict): - config = {} - return config - - -def _get_volume(clip) -> float: - config = _get_clip_config(clip) - return float(config.get("volume", 1.0)) - - -def _get_trim(clip) -> tuple[float, float]: - config = _get_clip_config(clip) - trim_start = float(config.get("trim_start", 0.0)) - trim_end = float(config.get("trim_end", 0.0)) - return trim_start, trim_end - - -def _build_response(clip) -> ClipAdjustResponse: - trim_start, trim_end = _get_trim(clip) - return ClipAdjustResponse( - clip_id=clip.id, - speed=clip.playback_speed, - volume=_get_volume(clip), - trim_start=trim_start, - trim_end=trim_end, - duration=clip.duration, - ) - - -def _validate_trim(trim_start: float, trim_end: float, total_duration: float) -> None: - """验证裁剪时长不超过总时长""" - if trim_start + trim_end >= total_duration: - raise ValueError(f"裁剪总时长({trim_start + trim_end:.2f}s)不能大于等于片段总时长({total_duration:.2f}s)") - - -# ── Routes ─────────────────────────────────────────────────────────────────── - - -def _get_svc_and_plan_and_clip(db, clip_id, current_user, project_repository): - 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) - return svc, plan, clip - - -@router.put("/clips/{clip_id}/speed", response_model=ClipAdjustResponse, deprecated=True) -def adjust_speed( - clip_id: str, - body: SpeedAdjustRequest, - db: Session = Depends(get_db_session), - current_user: AuthenticatedUser = Depends(get_current_user), - project_repository: Any = Depends(get_project_repository), -) -> ClipAdjustResponse: - """调整片段播放速度""" - svc, _, clip = _get_svc_and_plan_and_clip(db, clip_id, current_user, project_repository) - - updated = svc.update_clip(clip_id, playback_speed=body.speed) - - logger.info( - "调整片段速度: clip_id=%s speed=%.2f by user=%s", - clip_id, - body.speed, - current_user.user.id, - ) - return _build_response(updated) - - -@router.put("/clips/{clip_id}/volume", response_model=ClipAdjustResponse, deprecated=True) -def adjust_volume( - clip_id: str, - body: VolumeAdjustRequest, - db: Session = Depends(get_db_session), - current_user: AuthenticatedUser = Depends(get_current_user), - project_repository: Any = Depends(get_project_repository), -) -> ClipAdjustResponse: - """调整片段音量""" - svc, _, clip = _get_svc_and_plan_and_clip(db, clip_id, current_user, project_repository) - - # 更新 config.volume - config = dict(_get_clip_config(clip)) - config["volume"] = body.volume - updated = svc.update_clip(clip_id, config=config) - - logger.info( - "调整片段音量: clip_id=%s volume=%.2f by user=%s", - clip_id, - body.volume, - current_user.user.id, - ) - return _build_response(updated) - - -@router.put("/clips/{clip_id}/trim", response_model=ClipAdjustResponse, deprecated=True) -def adjust_trim( - clip_id: str, - body: TrimAdjustRequest, - db: Session = Depends(get_db_session), - current_user: AuthenticatedUser = Depends(get_current_user), - project_repository: Any = Depends(get_project_repository), -) -> ClipAdjustResponse: - """裁剪片段(trim in/out)""" - svc, _, clip = _get_svc_and_plan_and_clip(db, clip_id, current_user, project_repository) - - # 验证裁剪时长 - try: - _validate_trim(body.trim_start, body.trim_end, clip.duration) - except ValueError as e: - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e - - # 更新 config - config = dict(_get_clip_config(clip)) - config["trim_start"] = body.trim_start - config["trim_end"] = body.trim_end - updated = svc.update_clip(clip_id, config=config) - - logger.info( - "裁剪片段: clip_id=%s trim_start=%.2f trim_end=%.2f by user=%s", - clip_id, - body.trim_start, - body.trim_end, - current_user.user.id, - ) - return _build_response(updated) - - -@router.put("/clips/{clip_id}/adjustments", response_model=ClipAdjustResponse, deprecated=True) -def adjust_all( - clip_id: str, - body: ClipAdjustmentsRequest, - db: Session = Depends(get_db_session), - current_user: AuthenticatedUser = Depends(get_current_user), - project_repository: Any = Depends(get_project_repository), -) -> ClipAdjustResponse: - """统一调整片段的 speed / volume / trim""" - svc, _, clip = _get_svc_and_plan_and_clip(db, clip_id, current_user, project_repository) - - update_kwargs = {} - config_updates = {} - - if body.speed is not None: - update_kwargs["playback_speed"] = body.speed - - if body.volume is not None: - config_updates["volume"] = body.volume - - if body.trim_start is not None: - config_updates["trim_start"] = body.trim_start - - if body.trim_end is not None: - config_updates["trim_end"] = body.trim_end - - # 验证 trim - current_trim_start, current_trim_end = _get_trim(clip) - new_trim_start = body.trim_start if body.trim_start is not None else current_trim_start - new_trim_end = body.trim_end if body.trim_end is not None else current_trim_end - - if body.trim_start is not None or body.trim_end is not None: - try: - _validate_trim(new_trim_start, new_trim_end, clip.duration) - except ValueError as e: - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e - - if config_updates: - config = dict(_get_clip_config(clip)) - config.update(config_updates) - update_kwargs["config"] = config - - if not update_kwargs: - return _build_response(clip) - - updated = svc.update_clip(clip_id, **update_kwargs) - - logger.info( - "统一调整片段: clip_id=%s speed=%s volume=%s by user=%s", - clip_id, - body.speed, - body.volume, - current_user.user.id, - ) - return _build_response(updated) - - -@router.post("/{plan_id}/clips/batch-speed", response_model=BatchSpeedResponse, deprecated=True) -def batch_adjust_speed( - plan_id: str, - body: BatchSpeedRequest, - db: Session = Depends(get_db_session), - current_user: AuthenticatedUser = Depends(get_current_user), - project_repository: Any = Depends(get_project_repository), -) -> BatchSpeedResponse: - """批量调整计划内所有片段的播放速度""" - 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) - - clips = svc.list_clips(plan_id, limit=500, skip=0) - count = 0 - for clip in clips: - svc.update_clip(clip.id, playback_speed=body.speed) - count += 1 - - logger.info( - "批量调速: plan_id=%s count=%d speed=%.2f by user=%s", - plan_id, - count, - body.speed, - current_user.user.id, - ) - return BatchSpeedResponse(updated_count=count, plan_id=plan_id) diff --git a/apps/api/app/api/routes/edit_plans_ai.py b/apps/api/app/api/routes/edit_plans_ai.py deleted file mode 100644 index 1204bbef5..000000000 --- a/apps/api/app/api/routes/edit_plans_ai.py +++ /dev/null @@ -1,205 +0,0 @@ -"""剪辑计划 AI 推荐 & 封面生成 API 端点。 - -从 edit_plans.py 拆分,包含: -- POST /{plan_id}/ai-recommend AI 推荐片段方案 -- POST /{plan_id}/generate-cover AI 生成封面 -""" - -from __future__ import annotations - -import logging -from typing import Any - -from app.api.routes._helpers import check_project_access -from app.api.routes.edit_plans import ( - AIRecommendClipItem, - AIRecommendRequest, - AIRecommendResponse, - GenerateCoverRequest, - GenerateCoverResponse, -) -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, status -from sqlalchemy.orm import Session - -from packages.domain.config_schemas import normalize_plan_config - -from ._helpers import deprecated_edit_plans_api - -logger = logging.getLogger(__name__) - -router = APIRouter( - dependencies=[Depends(deprecated_edit_plans_api)], -) - - -@router.post( - "/{plan_id}/ai-recommend", - response_model=AIRecommendResponse, - deprecated=True, -) -def ai_recommend_clips( - plan_id: str, - body: AIRecommendRequest, - db: Session = Depends(get_db_session), - current_user: AuthenticatedUser = Depends(get_current_user), - project_repository: Any = Depends(get_project_repository), -) -> AIRecommendResponse: - """AI 推荐片段方案 - - 调用 AI 服务分析素材,自动生成片段编排方案并写入剪辑计划。 - - 流程: - 1. 验证计划存在且状态为 draft/editing - 2. 调用 AI 推荐服务(当前为 stub,后续接入真实 AI) - 3. 清除计划现有片段,按推荐方案重新创建 - 4. 更新计划 config(cover/title/subtitle/bgm)和 total_duration - 5. 返回推荐方案详情 - """ - svc = EditPlanService(db) - - try: - plan = svc.get_plan_or_raise(plan_id) - except ValueError as exc: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc - - if plan.project_id: - check_project_access(plan.project_id, current_user.user.id, project_repository) - - plan_status = plan.status.value if hasattr(plan.status, "value") else plan.status - if plan_status not in ("draft", "editing"): - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="当前计划状态不支持AI推荐,请先创建或编辑计划后再试", - ) - - from apps.worker.worker_app.tasks.ai_tasks import run_ai_recommend - - result = run_ai_recommend( - plan_id=plan_id, - template_id=plan.template_id, - asset_ids=body.asset_ids, - editing_mode=body.editing_mode, - target_duration=body.target_duration, - ) - - # 事务保护:清除 → 重建 → 更新 必须在同一逻辑事务中 - try: - svc.delete_all_clips(plan_id) - - for clip_data in result["clips"]: - svc.create_clip( - plan_id=plan_id, - clip_type=clip_data["clip_type"], - order=clip_data["order"], - text_content=clip_data.get("text_content", ""), - duration=clip_data["duration"], - transition_effect=clip_data.get("transition_effect", "cut"), - asset_id=clip_data.get("asset_id", ""), - start_time=clip_data.get("start_time", 0.0), - config=clip_data.get("config", {}), - ) - - normalized_config = normalize_plan_config(result.get("config", {})) - svc.update_plan( - plan_id, - config=normalized_config, - total_duration=result["total_duration"], - ) - except Exception as _e: - logger.exception("AI 推荐写入失败,plan_id=%s 数据可能不一致", plan_id) - try: - db.rollback() - except Exception as rollback_err: - logger.error( - "AI 推荐回滚失败,数据库会话可能处于不一致状态: plan_id=%s error=%s", - plan_id, - rollback_err, - ) - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="AI推荐结果保存失败,请稍后重试", - ) from _e - - logger.info( - "AI 推荐片段方案: plan_id=%s clips=%d duration=%.1f by user=%s", - plan_id, - len(result["clips"]), - result["total_duration"], - current_user.user.id, - ) - - return AIRecommendResponse( - plan_id=plan_id, - clips=[ - AIRecommendClipItem( - clip_type=c["clip_type"], - order=c["order"], - text_content=c.get("text_content", ""), - duration=c["duration"], - transition_effect=c.get("transition_effect", "cut"), - asset_id=c.get("asset_id", ""), - start_time=c.get("start_time", 0.0), - config=c.get("config", {}), - ) - for c in result["clips"] - ], - config=normalized_config, - total_duration=result["total_duration"], - confidence=result["confidence"], - ) - - -@router.post( - "/{plan_id}/generate-cover", - response_model=GenerateCoverResponse, - deprecated=True, -) -def generate_cover( - plan_id: str, - body: GenerateCoverRequest, - db: Session = Depends(get_db_session), - current_user: AuthenticatedUser = Depends(get_current_user), - project_repository: Any = Depends(get_project_repository), -) -> GenerateCoverResponse: - """AI 生成封面 - - 调用 AI 服务从视频中选帧或生成封面图,并更新计划 config.cover。 - """ - svc = EditPlanService(db) - - try: - plan = svc.get_plan_or_raise(plan_id) - except ValueError as exc: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc - - if plan.project_id: - check_project_access(plan.project_id, current_user.user.id, project_repository) - - from apps.worker.worker_app.tasks.ai_tasks import run_generate_cover - - cover_data = run_generate_cover( - plan_id=plan_id, - asset_ids=body.asset_ids, - cover_type=body.cover_type, - frame_time=body.frame_time, - ) - - current_config = dict(plan.config) - current_config["cover"] = cover_data - normalized = normalize_plan_config(current_config) - svc.update_plan_config(plan_id, {"cover": normalized["cover"]}) - - logger.info( - "AI 封面生成: plan_id=%s type=%s by user=%s", - plan_id, - body.cover_type, - current_user.user.id, - ) - - return GenerateCoverResponse( - plan_id=plan_id, - cover=cover_data, - ) diff --git a/apps/api/app/api/routes/edit_plans_clips.py b/apps/api/app/api/routes/edit_plans_clips.py deleted file mode 100755 index a8db5dd2d..000000000 --- a/apps/api/app/api/routes/edit_plans_clips.py +++ /dev/null @@ -1,423 +0,0 @@ -"""剪辑计划片段(Clip)CRUD 路由。""" - -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 fastapi import APIRouter, Depends, HTTPException, Query, Response, status -from sqlalchemy.orm import Session - -from packages.domain.edit_plan_clip import EditPlanClipStatus - -from ._helpers import deprecated_edit_plans_api - -logger = logging.getLogger(__name__) - -router = APIRouter( - dependencies=[Depends(deprecated_edit_plans_api)], -) - - -# ── Schemas ────────────────────────────────────────────────────────────────── - -from pydantic import BaseModel, Field - - -class EditPlanClipResponse(BaseModel): - """剪辑片段响应体""" - - id: str - plan_id: str - clip_type: str - order: int - asset_id: str = "" - text_content: str = "" - start_time: float = 0.0 - duration: float = 0.0 - transition_effect: str = "cut" - transition_duration: float = 0.0 - playback_speed: float = 1.0 - status: str - config: dict[str, Any] = Field(default_factory=dict) - created_at: Optional[str] = None - updated_at: Optional[str] = None - - -class EditPlanClipListResponse(BaseModel): - """剪辑片段列表响应体""" - - items: List[EditPlanClipResponse] - total: int - - -class EditPlanClipCreateRequest(BaseModel): - """创建剪辑片段请求体""" - - clip_type: str = Field( - ..., min_length=1, max_length=50, description="片段类型: main/intro/outro/overlay/background/b_roll 等" - ) - order: int = Field(..., ge=0, description="排序序号") - asset_id: str = Field(default="", max_length=64, description="关联素材 ID") - text_content: str = Field(default="", max_length=5000, description="文本内容(字幕/配音等)") - start_time: float = Field(default=0.0, ge=0.0, description="起始时间 (秒)") - duration: float = Field(default=0.0, ge=0.0, description="时长 (秒)") - transition_effect: str = Field(default="cut", max_length=50, description="转场效果") - transition_duration: float = Field(default=0.0, ge=0.0, description="转场时长 (秒)") - playback_speed: float = Field(default=1.0, gt=0.0, le=10.0, description="播放速度倍率") - config: dict[str, Any] = Field(default_factory=dict, description="扩展配置 (JSON)") - - -class EditPlanClipUpdateRequest(BaseModel): - """更新剪辑片段请求体""" - - clip_type: Optional[str] = Field(default=None, min_length=1, max_length=50, description="片段类型") - order: Optional[int] = Field(default=None, ge=0, description="排序序号") - asset_id: Optional[str] = Field(default=None, max_length=64, description="关联素材 ID") - text_content: Optional[str] = Field(default=None, max_length=5000, description="文本内容") - start_time: Optional[float] = Field(default=None, ge=0.0, description="起始时间 (秒)") - duration: Optional[float] = Field(default=None, ge=0.0, description="时长 (秒)") - transition_effect: Optional[str] = Field(default=None, max_length=50, description="转场效果") - transition_duration: Optional[float] = Field(default=None, ge=0.0, description="转场时长 (秒)") - playback_speed: Optional[float] = Field(default=None, gt=0.0, le=10.0, description="播放速度倍率") - config: Optional[dict[str, Any]] = Field(default=None, description="扩展配置 (JSON)") - - -# ── Helpers ────────────────────────────────────────────────────────────────── - - -def _check_plan_access(plan_id: str, user_id: str, project_repository: Any, db: Session) -> Any: - """验证用户是否有权限访问该剪辑计划(通过项目关联)。 - 返回 plan 对象供后续使用,避免重复查询。 - """ - from app.services.edit_plan_service import EditPlanService - - from ._helpers import check_project_access - - svc = EditPlanService(db) - plan = svc.get_plan(plan_id) - if plan is None: - raise HTTPException(status_code=404, detail=f"剪辑计划不存在: {plan_id}") - if plan.project_id: - check_project_access(plan.project_id, user_id, project_repository) - return plan - - -def _clip_to_response(clip) -> EditPlanClipResponse: - """将领域对象转换为响应体""" - return EditPlanClipResponse( - id=clip.id, - plan_id=clip.plan_id, - clip_type=clip.clip_type, - order=clip.order, - asset_id=clip.asset_id or "", - text_content=clip.text_content or "", - start_time=clip.start_time, - duration=clip.duration, - transition_effect=clip.transition_effect or "cut", - transition_duration=clip.transition_duration or 0.0, - playback_speed=clip.playback_speed or 1.0, - status=clip.status.value if hasattr(clip.status, "value") else str(clip.status), - config=clip.config or {}, - created_at=clip.created_at.isoformat() if clip.created_at else None, - updated_at=clip.updated_at.isoformat() if clip.updated_at else None, - ) - - -def _get_svc(db: Session): - """获取 EditPlanService 实例""" - from app.services.edit_plan_service import EditPlanService - - return EditPlanService(db) - - -# ── Routes ─────────────────────────────────────────────────────────────────── - - -@router.get("", response_model=EditPlanClipListResponse, deprecated=True) -def list_clips( - plan_id: str, - status_filter: Optional[str] = Query(None, alias="status", description="按状态过滤"), - skip: int = Query(0, ge=0, description="分页偏移"), - limit: int = Query(100, ge=1, le=500, description="每页数量"), - db: Session = Depends(get_db_session), - current_user: AuthenticatedUser = Depends(get_current_user), - project_repository: Any = Depends(get_project_repository), -) -> EditPlanClipListResponse: - """获取剪辑计划的片段列表""" - _check_plan_access(plan_id, current_user.user.id, project_repository, db) - - svc = _get_svc(db) - status_enum = EditPlanClipStatus(status_filter) if status_filter else None - clips = svc.list_clips(plan_id, status=status_enum, skip=skip, limit=limit) - total = svc.count_clips(plan_id, status=status_enum) - - return EditPlanClipListResponse( - items=[_clip_to_response(c) for c in clips], - total=total, - ) - - -@router.post("", response_model=EditPlanClipResponse, status_code=status.HTTP_201_CREATED, deprecated=True) -def create_clip( - plan_id: str, - body: EditPlanClipCreateRequest, - db: Session = Depends(get_db_session), - current_user: AuthenticatedUser = Depends(get_current_user), - project_repository: Any = Depends(get_project_repository), -) -> EditPlanClipResponse: - """创建剪辑片段""" - _check_plan_access(plan_id, current_user.user.id, project_repository, db) - - svc = _get_svc(db) - try: - clip = svc.create_clip( - plan_id=plan_id, - clip_type=body.clip_type, - order=body.order, - asset_id=body.asset_id, - text_content=body.text_content, - start_time=body.start_time, - duration=body.duration, - transition_effect=body.transition_effect, - transition_duration=body.transition_duration, - playback_speed=body.playback_speed, - config=body.config, - ) - except ValueError as e: - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e - - logger.info("创建剪辑片段: plan_id=%s clip_id=%s by user=%s", plan_id, clip.id, current_user.user.id) - return _clip_to_response(clip) - - -@router.get("/{clip_id}", response_model=EditPlanClipResponse, deprecated=True) -def get_clip( - plan_id: str, - clip_id: str, - db: Session = Depends(get_db_session), - current_user: AuthenticatedUser = Depends(get_current_user), - project_repository: Any = Depends(get_project_repository), -) -> EditPlanClipResponse: - """获取剪辑片段详情""" - _check_plan_access(plan_id, current_user.user.id, project_repository, db) - - svc = _get_svc(db) - clip = svc.get_clip(clip_id) - if clip is None: - raise HTTPException(status_code=404, detail=f"片段不存在: {clip_id}") - if clip.plan_id != plan_id: - raise HTTPException(status_code=404, detail=f"片段不存在: {clip_id}") - - return _clip_to_response(clip) - - -@router.put("/{clip_id}", response_model=EditPlanClipResponse, deprecated=True) -def update_clip( - plan_id: str, - clip_id: str, - body: EditPlanClipUpdateRequest, - db: Session = Depends(get_db_session), - current_user: AuthenticatedUser = Depends(get_current_user), - project_repository: Any = Depends(get_project_repository), -) -> EditPlanClipResponse: - """更新剪辑片段""" - _check_plan_access(plan_id, current_user.user.id, project_repository, db) - - svc = _get_svc(db) - # 验证 clip 属于该 plan - clip = svc.get_clip(clip_id) - if clip is None: - raise HTTPException(status_code=404, detail=f"片段不存在: {clip_id}") - if clip.plan_id != plan_id: - raise HTTPException(status_code=404, detail=f"片段不存在: {clip_id}") - - try: - updated = svc.update_clip( - clip_id, - clip_type=body.clip_type, - order=body.order, - asset_id=body.asset_id, - text_content=body.text_content, - start_time=body.start_time, - duration=body.duration, - transition_effect=body.transition_effect, - transition_duration=body.transition_duration, - playback_speed=body.playback_speed, - config=body.config, - ) - except ValueError as e: - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e - - logger.info("更新剪辑片段: plan_id=%s clip_id=%s by user=%s", plan_id, clip_id, current_user.user.id) - return _clip_to_response(updated) - - -@router.delete( - "/{clip_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None, response_class=Response, deprecated=True -) -def delete_clip( - plan_id: str, - clip_id: str, - db: Session = Depends(get_db_session), - current_user: AuthenticatedUser = Depends(get_current_user), - project_repository: Any = Depends(get_project_repository), -) -> None: - """删除剪辑片段""" - _check_plan_access(plan_id, current_user.user.id, project_repository, db) - - svc = _get_svc(db) - # 验证 clip 属于该 plan - clip = svc.get_clip(clip_id) - if clip is None: - raise HTTPException(status_code=404, detail=f"片段不存在: {clip_id}") - if clip.plan_id != plan_id: - raise HTTPException(status_code=404, detail=f"片段不存在: {clip_id}") - - deleted = svc.delete_clip(clip_id) - if not deleted: - raise HTTPException(status_code=404, detail=f"片段不存在: {clip_id}") - - logger.info("删除剪辑片段: plan_id=%s clip_id=%s by user=%s", plan_id, clip_id, current_user.user.id) - return None - - -# ── 片段分割与合并 ────────────────────────────────────────────────────────── - - -class SplitClipRequest(BaseModel): - """分割片段请求体""" - - split_time: float = Field(..., gt=0, description="分割点(秒,相对于片段起始)") - - -class MergeClipsRequest(BaseModel): - """合并片段请求体""" - - clip_ids: list[str] = Field(..., min_length=2, description="要合并的片段 ID 列表") - - -@router.post( - "/{clip_id}/split", - response_model=dict[str, Any], - summary="分割片段", - status_code=status.HTTP_200_OK, - deprecated=True, -) -def split_clip( - plan_id: str, - clip_id: str, - body: SplitClipRequest, - current_user: AuthenticatedUser = Depends(get_current_user), - db: Session = Depends(get_db_session), - project_repository: Any = Depends(get_project_repository), -) -> dict[str, Any]: - """将一个片段从指定时间点分割为两个片段。 - - 分割后原片段变为左半部分,新增右半部分片段,后续片段顺序自动后移。 - 若片段有关联素材,会自动设置 trim_start/trim_end 标记裁剪范围。 - """ - _check_plan_access(plan_id, current_user.user.id, project_repository, db) - - svc = _get_svc(db) - clip = svc.get_clip(clip_id) - if clip is None or clip.plan_id != plan_id: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=f"片段不存在: {clip_id}", - ) - - try: - result = svc.split_clip(clip_id, body.split_time) - except ValueError as e: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=str(e), - ) from e - - left = result["left_clip"] - right = result["right_clip"] - logger.info("分割片段: plan_id=%s clip_id=%s by user=%s", plan_id, clip_id, current_user.user.id) - - return { - "left_clip": { - "id": left.id, - "plan_id": left.plan_id, - "clip_type": left.clip_type, - "order": left.order, - "duration": left.duration, - "start_time": left.start_time, - }, - "right_clip": { - "id": right.id, - "plan_id": right.plan_id, - "clip_type": right.clip_type, - "order": right.order, - "duration": right.duration, - "start_time": right.start_time, - }, - } - - -@router.post( - "/merge", - response_model=dict[str, Any], - summary="合并多个连续片段", - status_code=status.HTTP_200_OK, - deprecated=True, -) -def merge_clips( - plan_id: str, - body: MergeClipsRequest, - current_user: AuthenticatedUser = Depends(get_current_user), - db: Session = Depends(get_db_session), - project_repository: Any = Depends(get_project_repository), -) -> dict[str, Any]: - """将多个连续的同类型片段合并为一个片段。 - - 合并要求: - - 至少 2 个片段 - - 属于同一剪辑计划 - - order 连续 - - 类型相同 - - 合并后保留第一个片段,其余删除,后续片段顺序自动前移。 - """ - _check_plan_access(plan_id, current_user.user.id, project_repository, db) - - svc = _get_svc(db) - - # 校验所有片段都属于该 plan - for cid in body.clip_ids: - clip = svc.get_clip(cid) - if clip is None or clip.plan_id != plan_id: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=f"片段不存在: {cid}", - ) - - try: - merged = svc.merge_clips(body.clip_ids) - except ValueError as e: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=str(e), - ) from e - - logger.info( - "合并片段: plan_id=%s clip_count=%d by user=%s", - plan_id, - len(body.clip_ids), - current_user.user.id, - ) - - return { - "id": merged.id, - "plan_id": merged.plan_id, - "clip_type": merged.clip_type, - "order": merged.order, - "duration": merged.duration, - "text_content": merged.text_content, - } diff --git a/apps/api/app/api/routes/edit_plans_clips_batch.py b/apps/api/app/api/routes/edit_plans_clips_batch.py deleted file mode 100755 index 471f2f9c8..000000000 --- a/apps/api/app/api/routes/edit_plans_clips_batch.py +++ /dev/null @@ -1,245 +0,0 @@ -"""剪辑计划片段批量操作 API。""" - -from __future__ import annotations - -import logging -from typing import Any, List - -from app.auth import AuthenticatedUser, get_current_user -from app.dependencies import get_db_session, get_project_repository -from fastapi import APIRouter, Depends, HTTPException, status -from pydantic import BaseModel, Field -from sqlalchemy.orm import Session - -from ._helpers import deprecated_edit_plans_api - -logger = logging.getLogger(__name__) - -router = APIRouter( - dependencies=[Depends(deprecated_edit_plans_api)], -) - - -# ── Schemas ────────────────────────────────────────────────────────────────── - - -class ClipReorderItem(BaseModel): - """重排序条目""" - - clip_id: str - new_order: int = Field(..., ge=0, description="新的排序序号") - - -class ClipReorderRequest(BaseModel): - """片段重排序请求""" - - items: List[ClipReorderItem] = Field(..., min_length=1, max_length=500, description="重排序条目列表") - - -class ClipReorderResponse(BaseModel): - """片段重排序响应""" - - success: bool - updated_count: int - message: str = "" - - -class ClipBatchDeleteRequest(BaseModel): - """批量删除片段请求""" - - clip_ids: List[str] = Field(..., min_length=1, max_length=500, description="要删除的片段ID列表") - - -class ClipBatchDeleteResponse(BaseModel): - """批量删除片段响应""" - - success: bool - deleted_count: int - message: str = "" - - -class ClipsFromAssetsRequest(BaseModel): - """从素材批量创建片段请求""" - - asset_ids: List[str] = Field(..., min_length=1, max_length=200, description="素材 ID 列表,按顺序追加到时间线末尾") - clip_type: str = Field(default="main", description="片段类型,默认 main") - - -class ClipsFromAssetsResponse(BaseModel): - """从素材批量创建片段响应""" - - success: bool - created_count: int - message: str = "" - clip_ids: List[str] = Field(default_factory=list, description="创建的片段ID列表") - - -# ── Helpers ────────────────────────────────────────────────────────────────── - - -def _check_plan_access(plan_id: str, user_id: str, project_repository: Any, db: Session) -> Any: - """验证用户是否有权限访问该剪辑计划,返回 plan 对象。""" - from app.services.edit_plan_service import EditPlanService - - from ._helpers import check_project_access - - svc = EditPlanService(db) - plan = svc.get_plan(plan_id) - if plan is None: - raise HTTPException(status_code=404, detail=f"剪辑计划不存在: {plan_id}") - if plan.project_id: - check_project_access(plan.project_id, user_id, project_repository) - return plan - - -def _get_svc(db: Session): - """获取 EditPlanService 实例""" - from app.services.edit_plan_service import EditPlanService - - return EditPlanService(db) - - -# ── Routes ─────────────────────────────────────────────────────────────────── - - -@router.post("/reorder", response_model=ClipReorderResponse, deprecated=True) -def reorder_clips( - plan_id: str, - body: ClipReorderRequest, - db: Session = Depends(get_db_session), - current_user: AuthenticatedUser = Depends(get_current_user), - project_repository: Any = Depends(get_project_repository), -) -> ClipReorderResponse: - """批量重排序片段 - - 前端拖拽调整顺序后,一次性提交所有变更的 order。 - 自动触发编辑状态回退(从 completed/failed 切回 editing)。 - """ - _check_plan_access(plan_id, current_user.user.id, project_repository, db) - - svc = _get_svc(db) - - # 验证所有 clip 都属于该 plan - clip_ids = [item.clip_id for item in body.items] - existing_clips = svc.list_clips(plan_id, skip=0, limit=10000) - existing_ids = {c.id for c in existing_clips} - - invalid_ids = [cid for cid in clip_ids if cid not in existing_ids] - if invalid_ids: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=f"以下片段不属于该计划: {', '.join(invalid_ids[:5])}", - ) - - # 执行重排序 - updated_count = 0 - for item in body.items: - try: - svc.update_clip(item.clip_id, order=item.new_order) - updated_count += 1 - except ValueError as e: - logger.warning("重排序片段失败: clip_id=%s error=%s", item.clip_id, e) - - logger.info( - "批量重排序片段: plan_id=%s count=%d by user=%s", - plan_id, - updated_count, - current_user.user.id, - ) - - return ClipReorderResponse( - success=True, - updated_count=updated_count, - message=f"成功更新 {updated_count} 个片段的顺序", - ) - - -@router.post("/batch-delete", response_model=ClipBatchDeleteResponse, deprecated=True) -def batch_delete_clips( - plan_id: str, - body: ClipBatchDeleteRequest, - db: Session = Depends(get_db_session), - current_user: AuthenticatedUser = Depends(get_current_user), - project_repository: Any = Depends(get_project_repository), -) -> ClipBatchDeleteResponse: - """批量删除片段 - - 自动触发编辑状态回退(从 completed/failed 切回 editing)。 - """ - _check_plan_access(plan_id, current_user.user.id, project_repository, db) - - svc = _get_svc(db) - - # 验证所有 clip 都属于该 plan - existing_clips = svc.list_clips(plan_id, skip=0, limit=10000) - existing_ids = {c.id for c in existing_clips} - - valid_ids = [cid for cid in body.clip_ids if cid in existing_ids] - skipped = len(body.clip_ids) - len(valid_ids) - - # 执行删除 - deleted_count = 0 - for clip_id in valid_ids: - if svc.delete_clip(clip_id): - deleted_count += 1 - - message = f"成功删除 {deleted_count} 个片段" - if skipped > 0: - message += f",跳过 {skipped} 个不存在的片段" - - logger.info( - "批量删除片段: plan_id=%s deleted=%d skipped=%d by user=%s", - plan_id, - deleted_count, - skipped, - current_user.user.id, - ) - - return ClipBatchDeleteResponse( - success=True, - deleted_count=deleted_count, - message=message, - ) - - -@router.post("/from-assets", response_model=ClipsFromAssetsResponse, deprecated=True) -def create_clips_from_assets( - plan_id: str, - body: ClipsFromAssetsRequest, - db: Session = Depends(get_db_session), - current_user: AuthenticatedUser = Depends(get_current_user), - project_repository: Any = Depends(get_project_repository), -) -> ClipsFromAssetsResponse: - """从素材批量创建片段(追加到时间线末尾) - - 一次性将多个素材作为片段添加到剪辑计划,自动读取素材时长。 - 自动触发编辑状态回退(completed/failed → editing)。 - """ - _check_plan_access(plan_id, current_user.user.id, project_repository, db) - - svc = _get_svc(db) - - try: - clips = svc.create_clips_from_assets( - plan_id=plan_id, - asset_ids=body.asset_ids, - clip_type=body.clip_type, - ) - except ValueError as e: - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e - - clip_ids = [c.id for c in clips] - - logger.info( - "从素材批量创建片段: plan_id=%s count=%d by user=%s", - plan_id, - len(clips), - current_user.user.id, - ) - - return ClipsFromAssetsResponse( - success=True, - created_count=len(clips), - message=f"成功创建 {len(clips)} 个片段", - clip_ids=clip_ids, - ) diff --git a/apps/api/app/api/routes/edit_plans_cover.py b/apps/api/app/api/routes/edit_plans_cover.py deleted file mode 100644 index 022fb1849..000000000 --- a/apps/api/app/api/routes/edit_plans_cover.py +++ /dev/null @@ -1,317 +0,0 @@ -"""封面管理 API. - -- GET /{plan_id}/cover 获取封面配置 -- PUT /{plan_id}/cover 更新封面配置 -- POST /{plan_id}/cover/extract 从指定片段抽帧生成封面 -- POST /{plan_id}/cover/smart 智能选帧生成封面 -""" - -from __future__ import annotations - -import logging -from typing import Any, Optional - -from app.auth import AuthenticatedUser, get_current_user -from app.core.storage import get_storage_service -from app.dependencies import ( - get_asset_repository, - get_db_session, - get_project_repository, -) -from app.services import EditPlanService -from app.services.cover_service import CoverService -from fastapi import APIRouter, Depends, HTTPException, status -from pydantic import BaseModel, Field -from sqlalchemy.orm import Session - -from packages.domain.config_schemas import normalize_plan_config - -from ._helpers import check_project_access, deprecated_edit_plans_api - -logger = logging.getLogger(__name__) - -router = APIRouter( - dependencies=[Depends(deprecated_edit_plans_api)], -) - - -# ── Schemas ────────────────────────────────────────────────────────────────── - - -class CoverConfigResponse(BaseModel): - """封面配置响应""" - - type: str = Field(..., description="封面类型: ai_frame / manual / upload") - image_url: str = Field(default="", description="封面图片 URL") - frame_time: Optional[float] = Field(default=None, description="抽帧时间点(秒)") - - -class CoverUpdateRequest(BaseModel): - """更新封面配置请求""" - - type: Optional[str] = Field(default=None, description="封面类型") - image_url: Optional[str] = Field(default=None, description="封面图片 URL") - frame_time: Optional[float] = Field(default=None, ge=0.0, description="抽帧时间点(秒)") - - -class CoverExtractRequest(BaseModel): - """从片段抽帧生成封面请求""" - - clip_id: str = Field(..., description="片段 ID") - frame_time: float = Field(1.0, ge=0.0, description="抽帧时间点(秒)") - - -class CoverSmartRequest(BaseModel): - """智能选帧请求""" - - clip_id: Optional[str] = Field(default=None, description="指定片段 ID(不传则用第一个视频片段)") - - -class CoverGenerateResponse(BaseModel): - """封面生成响应""" - - type: str = Field(..., description="封面类型") - image_url: str = Field(..., description="封面图片 URL") - frame_time: Optional[float] = Field(default=None, description="抽帧时间点(秒)") - - -# ── Routes ─────────────────────────────────────────────────────────────────── - - -@router.get("/{plan_id}/cover", response_model=CoverConfigResponse, deprecated=True) -def get_cover( - plan_id: str, - db: Session = Depends(get_db_session), - current_user: AuthenticatedUser = Depends(get_current_user), - project_repository: Any = Depends(get_project_repository), -) -> CoverConfigResponse: - """获取封面配置""" - 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) - - cover = CoverService.get_cover_config(plan.config or {}) - return CoverConfigResponse(**cover) - - -@router.put("/{plan_id}/cover", response_model=CoverConfigResponse, deprecated=True) -def update_cover( - plan_id: str, - body: CoverUpdateRequest, - db: Session = Depends(get_db_session), - current_user: AuthenticatedUser = Depends(get_current_user), - project_repository: Any = Depends(get_project_repository), -) -> CoverConfigResponse: - """更新封面配置 - - 用于:设置上传的封面图片 URL、切换封面类型、调整时间点等。 - """ - 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) - - # 合并更新 - current_cover = CoverService.get_cover_config(plan.config or {}) - updates = body.model_dump(exclude_none=True) - new_cover = {**current_cover, **updates} - - # 验证 type 值 - valid_types = {"ai_frame", "manual", "upload", "ai_regenerate"} - if "type" in updates and updates["type"] not in valid_types: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=f"无效的封面类型: {updates['type']},有效值: {valid_types}", - ) - - # 更新到 plan.config - current_config = dict(plan.config or {}) - current_config["cover"] = new_cover - normalized = normalize_plan_config(current_config) - updated_plan = svc.update_plan_config(plan_id, {"cover": normalized["cover"]}) - - result = CoverService.get_cover_config(updated_plan.config or {}) - logger.info("更新封面配置: plan_id=%s type=%s by user=%s", plan_id, result["type"], current_user.user.id) - return CoverConfigResponse(**result) - - -@router.post("/{plan_id}/cover/extract", response_model=CoverGenerateResponse, deprecated=True) -def extract_cover( - plan_id: str, - body: CoverExtractRequest, - db: Session = Depends(get_db_session), - current_user: AuthenticatedUser = Depends(get_current_user), - project_repository: Any = Depends(get_project_repository), - storage_service: Any = Depends(get_storage_service), - asset_repository: Any = Depends(get_asset_repository), -) -> CoverGenerateResponse: - """从指定片段的指定时间点抽帧生成封面""" - 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) - - # 获取片段对应的素材 - clip = svc.get_clip(body.clip_id) - if not clip: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=f"片段不存在: {body.clip_id}", - ) - - if clip.plan_id != plan_id: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="片段不属于该剪辑计划", - ) - - if not clip.asset_id: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="片段没有关联素材,无法抽帧", - ) - - # 抽帧生成封面 - cover_svc = CoverService(storage_service, asset_repository) - try: - cover_data = cover_svc.extract_cover_from_clip( - plan_id=plan_id, - asset_id=clip.asset_id, - frame_time=body.frame_time, - ) - except ValueError as e: - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e - except RuntimeError as e: - logger.error("封面抽帧失败: plan_id=%s clip_id=%s error=%s", plan_id, body.clip_id, e) - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"封面抽帧失败: {e}", - ) from e - - # 更新到 plan.config.cover - current_config = dict(plan.config or {}) - current_config["cover"] = cover_data - normalized = normalize_plan_config(current_config) - svc.update_plan_config(plan_id, {"cover": normalized["cover"]}) - - logger.info( - "封面抽帧完成: plan_id=%s clip_id=%s time=%.2fs by user=%s", - plan_id, - body.clip_id, - body.frame_time, - current_user.user.id, - ) - return CoverGenerateResponse(**cover_data) - - -@router.post("/{plan_id}/cover/smart", response_model=CoverGenerateResponse, deprecated=True) -def smart_cover( - plan_id: str, - body: CoverSmartRequest, - db: Session = Depends(get_db_session), - current_user: AuthenticatedUser = Depends(get_current_user), - project_repository: Any = Depends(get_project_repository), - storage_service: Any = Depends(get_storage_service), - asset_repository: Any = Depends(get_asset_repository), -) -> CoverGenerateResponse: - """智能选帧生成封面 - - 从指定片段(或第一个视频片段)中智能选取一帧作为封面。 - 当前实现:取片段第3秒帧(后续可优化为多帧选最清晰)。 - """ - 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) - - # 确定使用哪个片段 - clip_id = body.clip_id - asset_id = "" - - if clip_id: - clip = svc.get_clip(clip_id) - if not clip: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=f"片段不存在: {clip_id}", - ) - if clip.plan_id != plan_id: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="片段不属于该剪辑计划", - ) - if not clip.asset_id: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="片段没有关联素材", - ) - asset_id = clip.asset_id - else: - # 找第一个有素材的视频片段 - clips = svc.list_clips(plan_id, limit=50, skip=0) - for c in clips: - if c.asset_id and c.clip_type == "video": - asset_id = c.asset_id - clip_id = c.id - break - - if not asset_id: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="没有找到可用的视频片段", - ) - - # 智能选帧 - cover_svc = CoverService(storage_service, asset_repository) - try: - cover_data = cover_svc.generate_smart_cover( - plan_id=plan_id, - asset_id=asset_id, - ) - except ValueError as e: - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e - except RuntimeError as e: - logger.error("智能封面生成失败: plan_id=%s error=%s", plan_id, e) - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"智能封面生成失败: {e}", - ) from e - - # 更新到 plan.config.cover - current_config = dict(plan.config or {}) - current_config["cover"] = cover_data - normalized = normalize_plan_config(current_config) - svc.update_plan_config(plan_id, {"cover": normalized["cover"]}) - - logger.info( - "智能封面生成完成: plan_id=%s clip_id=%s by user=%s", - plan_id, - clip_id, - current_user.user.id, - ) - return CoverGenerateResponse(**cover_data) diff --git a/apps/api/app/api/routes/edit_plans_export.py b/apps/api/app/api/routes/edit_plans_export.py deleted file mode 100644 index 747bfe4be..000000000 --- a/apps/api/app/api/routes/edit_plans_export.py +++ /dev/null @@ -1,276 +0,0 @@ -"""导出设置 API. - -- GET /{plan_id}/export 获取导出配置 -- PUT /{plan_id}/export 更新导出配置 -- GET /export-presets 导出预设列表 -""" - -from __future__ import annotations - -import logging -import re -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, status -from pydantic import BaseModel, Field, validator -from sqlalchemy.orm import Session - -from packages.domain.config_schemas import normalize_plan_config - -from ._helpers import check_project_access, deprecated_edit_plans_api - -logger = logging.getLogger(__name__) - -router = APIRouter( - dependencies=[Depends(deprecated_edit_plans_api)], -) - - -# ── 导出预设 ────────────────────────────────────────────────────────────────── - -EXPORT_PRESETS = [ - { - "id": "export_1080p_30", - "name": "1080P 高清", - "resolution": "1080x1920", - "fps": 30, - "video_bitrate": 8000, - "audio_bitrate": 128, - "format": "mp4", - "quality_preset": "balanced", - "description": "竖屏高清,适合短视频平台", - "size_hint": "约 10MB/分钟", - }, - { - "id": "export_1080p_60", - "name": "1080P 高帧率", - "resolution": "1080x1920", - "fps": 60, - "video_bitrate": 12000, - "audio_bitrate": 128, - "format": "mp4", - "quality_preset": "high", - "description": "60帧高帧率,流畅运动画面", - "size_hint": "约 18MB/分钟", - }, - { - "id": "export_720p_30", - "name": "720P 流畅", - "resolution": "720x1280", - "fps": 30, - "video_bitrate": 4000, - "audio_bitrate": 128, - "format": "mp4", - "quality_preset": "fast", - "description": "快速导出,文件较小", - "size_hint": "约 5MB/分钟", - }, - { - "id": "export_4k_30", - "name": "4K 超清", - "resolution": "2160x3840", - "fps": 30, - "video_bitrate": 20000, - "audio_bitrate": 192, - "format": "mp4", - "quality_preset": "best", - "description": "4K超清画质,专业品质", - "size_hint": "约 30MB/分钟", - }, - { - "id": "export_1080p_30_mov", - "name": "1080P ProRes", - "resolution": "1080x1920", - "fps": 30, - "video_bitrate": 15000, - "audio_bitrate": 256, - "format": "mov", - "quality_preset": "high", - "description": "MOV格式,适合后期剪辑", - "size_hint": "约 25MB/分钟", - }, -] - -VALID_QUALITY_PRESETS = {"ultra_fast", "fast", "balanced", "high", "best"} -VALID_FORMATS = {"mp4", "mov"} - -RESOLUTION_PATTERN = re.compile(r"^\d+x\d+$") - - -# ── Schemas ────────────────────────────────────────────────────────────────── - - -class ExportConfigResponse(BaseModel): - """导出配置响应""" - - resolution: str - fps: int - video_bitrate: int - audio_bitrate: int - format: str - quality_preset: str - watermark_enabled: bool - watermark_text: str - - -class ExportUpdateRequest(BaseModel): - """更新导出配置请求""" - - resolution: Optional[str] = None - fps: Optional[int] = Field(default=None, ge=15, le=60) - video_bitrate: Optional[int] = Field(default=None, ge=1000, le=20000) - audio_bitrate: Optional[int] = Field(default=None, ge=64, le=320) - format: Optional[str] = None - quality_preset: Optional[str] = None - watermark_enabled: Optional[bool] = None - watermark_text: Optional[str] = None - - @validator("resolution") - def validate_resolution(cls, v): - if v is None: - return v - if not RESOLUTION_PATTERN.match(v): - raise ValueError("分辨率格式错误,应为 宽x高,如 1080x1920") - w, h = v.split("x") - if int(w) < 100 or int(h) < 100: - raise ValueError("分辨率数值过小") - if int(w) > 4096 or int(h) > 4096: - raise ValueError("分辨率数值过大,最大 4096x4096") - return v - - @validator("format") - def validate_format(cls, v): - if v is None: - return v - if v not in VALID_FORMATS: - raise ValueError(f"无效格式: {v},支持: {VALID_FORMATS}") - return v - - @validator("quality_preset") - def validate_quality_preset(cls, v): - if v is None: - return v - if v not in VALID_QUALITY_PRESETS: - raise ValueError(f"无效质量预设: {v},支持: {VALID_QUALITY_PRESETS}") - return v - - -class ExportPresetItem(BaseModel): - """导出预设条目""" - - id: str - name: str - resolution: str - fps: int - video_bitrate: int - audio_bitrate: int - format: str - quality_preset: str - description: str - size_hint: str - - -class ExportPresetListResponse(BaseModel): - """导出预设列表响应""" - - items: List[ExportPresetItem] - total: int - - -# ── Helpers ────────────────────────────────────────────────────────────────── - - -def _get_export_config(plan_config: dict) -> dict: - e = plan_config.get("export", {}) - if not isinstance(e, dict): - e = {} - return { - "resolution": e.get("resolution", "1080x1920"), - "fps": e.get("fps", 30), - "video_bitrate": e.get("video_bitrate", 8000), - "audio_bitrate": e.get("audio_bitrate", 128), - "format": e.get("format", "mp4"), - "quality_preset": e.get("quality_preset", "balanced"), - "watermark_enabled": e.get("watermark_enabled", False), - "watermark_text": e.get("watermark_text", ""), - } - - -# ── Routes ─────────────────────────────────────────────────────────────────── - - -@router.get("/export-presets", response_model=ExportPresetListResponse, deprecated=True) -def list_export_presets( - current_user: AuthenticatedUser = Depends(get_current_user), -) -> ExportPresetListResponse: - """获取导出预设列表""" - items = [ExportPresetItem(**p) for p in EXPORT_PRESETS] - return ExportPresetListResponse(items=items, total=len(items)) - - -@router.get("/{plan_id}/export", response_model=ExportConfigResponse, deprecated=True) -def get_export_config( - plan_id: str, - db: Session = Depends(get_db_session), - current_user: AuthenticatedUser = Depends(get_current_user), - project_repository: Any = Depends(get_project_repository), -) -> ExportConfigResponse: - """获取导出配置""" - 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) - - config = _get_export_config(plan.config or {}) - return ExportConfigResponse(**config) - - -@router.put("/{plan_id}/export", response_model=ExportConfigResponse, deprecated=True) -def update_export_config( - plan_id: str, - body: ExportUpdateRequest, - db: Session = Depends(get_db_session), - current_user: AuthenticatedUser = Depends(get_current_user), - project_repository: Any = Depends(get_project_repository), -) -> ExportConfigResponse: - """更新导出配置""" - 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) - - # 合并更新 - current = _get_export_config(plan.config or {}) - updates = body.model_dump(exclude_none=True) - new_export = {**current, **updates} - - # 更新到 plan.config - current_config = dict(plan.config or {}) - current_config["export"] = new_export - normalized = normalize_plan_config(current_config) - updated_plan = svc.update_plan_config(plan_id, {"export": normalized["export"]}) - - result = _get_export_config(updated_plan.config or {}) - logger.info( - "更新导出配置: plan_id=%s resolution=%s fps=%d by user=%s", - plan_id, - result["resolution"], - result["fps"], - current_user.user.id, - ) - return ExportConfigResponse(**result) diff --git a/apps/api/app/api/routes/edit_plans_filter.py b/apps/api/app/api/routes/edit_plans_filter.py deleted file mode 100644 index 6a0a6c909..000000000 --- a/apps/api/app/api/routes/edit_plans_filter.py +++ /dev/null @@ -1,199 +0,0 @@ -"""滤镜调色 API. - -- GET /filter-presets 滤镜预设列表 -- GET /{plan_id}/filter 获取全局滤镜配置 -- PUT /{plan_id}/filter 更新全局滤镜配置 -""" - -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.config_schemas import normalize_plan_config -from packages.domain.filter_presets import ( - FilterPreset, - get_filter_preset, - list_filter_presets, -) - -from ._helpers import check_project_access, deprecated_edit_plans_api - -logger = logging.getLogger(__name__) - -router = APIRouter( - dependencies=[Depends(deprecated_edit_plans_api)], -) - - -# ── Schemas ────────────────────────────────────────────────────────────────── - - -class FilterPresetResponse(BaseModel): - """滤镜预设响应""" - - id: str - name: str - category: str - description: str - tags: List[str] = Field(default_factory=list) - - -class FilterConfigResponse(BaseModel): - """滤镜配置响应""" - - enabled: bool - preset_id: str - intensity: int - brightness: float - contrast: float - saturation: float - warmth: float - - -class FilterUpdateRequest(BaseModel): - """更新滤镜配置请求""" - - enabled: Optional[bool] = None - preset_id: Optional[str] = None - intensity: Optional[int] = Field(default=None, ge=0, le=100) - brightness: Optional[float] = Field(default=None, ge=-1.0, le=1.0) - contrast: Optional[float] = Field(default=None, ge=0.0, le=2.0) - saturation: Optional[float] = Field(default=None, ge=0.0, le=3.0) - warmth: Optional[float] = Field(default=None, ge=-1.0, le=1.0) - - -class FilterPresetListResponse(BaseModel): - """滤镜预设列表响应""" - - items: List[FilterPresetResponse] - total: int - - -# ── Helpers ────────────────────────────────────────────────────────────────── - - -def _preset_to_response(p: FilterPreset) -> FilterPresetResponse: - return FilterPresetResponse( - id=p.id, - name=p.name, - category=p.category, - description=p.description, - tags=list(p.tags), - ) - - -def _get_filter_config(plan_config: dict) -> dict: - """从 plan.config 中提取滤镜配置""" - f = plan_config.get("filter", {}) - if not isinstance(f, dict): - f = {} - return { - "enabled": f.get("enabled", False), - "preset_id": f.get("preset_id", "filter_none"), - "intensity": f.get("intensity", 100), - "brightness": f.get("brightness", 0.0), - "contrast": f.get("contrast", 1.0), - "saturation": f.get("saturation", 1.0), - "warmth": f.get("warmth", 0.0), - } - - -# ── Routes ─────────────────────────────────────────────────────────────────── - - -@router.get("/filter-presets", response_model=FilterPresetListResponse, deprecated=True) -def list_presets( - category: Optional[str] = Query(default=None, description="按分类筛选"), - keyword: Optional[str] = Query(default=None, description="关键词搜索"), - current_user: AuthenticatedUser = Depends(get_current_user), -) -> FilterPresetListResponse: - """获取滤镜预设列表""" - presets = list_filter_presets(category=category, keyword=keyword) - items = [_preset_to_response(p) for p in presets] - return FilterPresetListResponse(items=items, total=len(items)) - - -@router.get("/{plan_id}/filter", response_model=FilterConfigResponse, deprecated=True) -def get_filter( - plan_id: str, - db: Session = Depends(get_db_session), - current_user: AuthenticatedUser = Depends(get_current_user), - project_repository: Any = Depends(get_project_repository), -) -> FilterConfigResponse: - """获取剪辑计划的全局滤镜配置""" - 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) - - config = _get_filter_config(plan.config or {}) - return FilterConfigResponse(**config) - - -@router.put("/{plan_id}/filter", response_model=FilterConfigResponse, deprecated=True) -def update_filter( - plan_id: str, - body: FilterUpdateRequest, - db: Session = Depends(get_db_session), - current_user: AuthenticatedUser = Depends(get_current_user), - project_repository: Any = Depends(get_project_repository), -) -> FilterConfigResponse: - """更新全局滤镜配置""" - 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) - - # 验证 preset_id - updates = body.model_dump(exclude_none=True) - if "preset_id" in updates: - preset = get_filter_preset(updates["preset_id"]) - if preset is None: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=f"无效的滤镜预设: {updates['preset_id']}", - ) - - # 合并更新 - current = _get_filter_config(plan.config or {}) - new_filter = {**current, **updates} - - # 如果设为原图 preset,自动关闭 - if new_filter["preset_id"] == "filter_none": - new_filter["enabled"] = False - - # 更新到 plan.config - current_config = dict(plan.config or {}) - current_config["filter"] = new_filter - normalized = normalize_plan_config(current_config) - updated_plan = svc.update_plan_config(plan_id, {"filter": normalized["filter"]}) - - result = _get_filter_config(updated_plan.config or {}) - logger.info( - "更新滤镜配置: plan_id=%s preset=%s intensity=%d by user=%s", - plan_id, - result["preset_id"], - result["intensity"], - current_user.user.id, - ) - return FilterConfigResponse(**result) diff --git a/apps/api/app/api/routes/edit_plans_generation.py b/apps/api/app/api/routes/edit_plans_generation.py deleted file mode 100644 index 82976bb12..000000000 --- a/apps/api/app/api/routes/edit_plans_generation.py +++ /dev/null @@ -1,418 +0,0 @@ -"""剪辑计划生成相关 API 端点。 - -从 edit_plans.py 拆分,包含: -- POST /{plan_id}/generate 触发剪辑渲染生成 -- GET /{plan_id}/generation-status 查询生成进度 -- GET /{plan_id}/generations 查询关联的生成记录 -""" - -from __future__ import annotations - -import logging -from typing import Any - -from app.api.routes._helpers import check_project_access -from app.api.routes.edit_plans import ( - ClipStatusItem, - EditPlanGenerateResponse, - EditPlanGenerationsResponse, - EditPlanGenerationStatusResponse, -) -from app.auth import AuthenticatedUser, get_current_user -from app.core.celery_app import celery_app -from app.core.storage import OSSStorageService, get_storage_service -from app.core.task_enqueue import GLOBAL_PENDING_LIMIT, USER_PENDING_LIMIT -from app.dependencies import get_asset_library_repository, get_asset_repository, get_db_session, get_project_repository -from app.services import EditPlanService -from fastapi import APIRouter, Depends, HTTPException, status -from sqlalchemy.orm import Session - -from packages.adapters.sqlalchemy_impl.generation_task_repository import ( - SQLAlchemyGenerationTaskRepository, -) -from packages.adapters.sqlalchemy_impl.template_clip_config_repository import ( - SQLAlchemyTemplateClipConfigRepository, -) -from packages.adapters.sqlalchemy_impl.template_repository import ( - SQLAlchemyTemplateRepository, -) -from packages.application.generation_tasks import ( - CreateGenerationTaskCommand, - CreateGenerationTaskUseCase, -) -from packages.domain.edit_plan import EditPlanStatus - -from ._helpers import deprecated_edit_plans_api - -logger = logging.getLogger(__name__) - -router = APIRouter( - dependencies=[Depends(deprecated_edit_plans_api)], -) - - -def _auto_fallback_draft_to_editing(svc: EditPlanService, plan_id: str, plan_check) -> None: - """自动兜底 1: draft → editing""" - if plan_check.status == EditPlanStatus.DRAFT: - logger.info("自动兜底: plan=%s draft→editing", plan_id) - svc.transition_status(plan_id, EditPlanStatus.EDITING) - - -def _auto_fallback_copy_template_clips(svc: EditPlanService, plan_id: str, plan_check, db: Session) -> None: - """自动兜底 2: 无片段 + 有 template_id → 从模板复制片段配置""" - existing_clips = svc.count_clips(plan_id) - if existing_clips == 0 and plan_check.template_id: - logger.info( - "自动兜底: plan=%s 无片段,从模板 %s 复制片段配置", - plan_id, - plan_check.template_id, - ) - clip_config_repo = SQLAlchemyTemplateClipConfigRepository(db) - configs = clip_config_repo.list_by_template(plan_check.template_id) - if configs: - for cfg in configs: - svc.create_clip( - plan_id=plan_id, - clip_type=cfg.clip_type.value if hasattr(cfg.clip_type, "value") else cfg.clip_type, - order=cfg.order, - template_clip_config_id=cfg.id, - duration=cfg.default_duration, - transition_effect=( - cfg.transition_effect.value - if hasattr(cfg.transition_effect, "value") - else cfg.transition_effect - ), - ) - logger.info("自动兜底: plan=%s 从新模型 template_clip_configs 复制了 %d 个片段", plan_id, len(configs)) - else: - tpl_repo = SQLAlchemyTemplateRepository(db) - segments = tpl_repo.list_segments(plan_check.template_id) - for seg in segments: - avg_duration = (seg.duration_min + seg.duration_max) / 2 - svc.create_clip( - plan_id=plan_id, - clip_type="main", - order=seg.segment_order, - duration=avg_duration, - config={ - "material_type": seg.material_type or "", - "template_segment_id": seg.id, - }, - ) - logger.info("自动兜底: plan=%s 从旧模型 template_segments 复制了 %d 个片段", plan_id, len(segments)) - - -def _auto_fallback_assign_assets( - svc: EditPlanService, - plan_id: str, - plan_check, -) -> list: - """自动兜底 3: 为没有素材的片段分配素材。返回剩余无素材片段列表。""" - all_clips = svc.list_clips(plan_id) - clips_without_asset = [c for c in all_clips if not c.asset_id] - config_asset_ids = (plan_check.config or {}).get("asset_ids", []) - - if clips_without_asset and config_asset_ids: - logger.info( - "自动兜底3: plan=%s 为 %d 个无素材片段分配 %d 个指定素材", - plan_id, - len(clips_without_asset), - len(config_asset_ids), - ) - for i, clip in enumerate(clips_without_asset): - asset_idx = i % len(config_asset_ids) - svc.assign_asset(clip.id, config_asset_ids[asset_idx]) - logger.info("自动兜底3: plan=%s 素材分配完成", plan_id) - clips_without_asset = [] - - return clips_without_asset - - -def _auto_fallback_auto_material_mode( - svc: EditPlanService, - plan_id: str, - plan_check, - clips_without_asset: list, - asset_library_repo: Any, - asset_repo: Any, -) -> None: - """自动兜底 4: 项目有视频素材库时,自动选取 ready 视频素材分配给无素材片段 - - 注:原先需要 material_mode=="auto" 才触发,但全代码库没有任何地方设置为 auto, - 导致这道兜底防线永远不生效。现改为:只要有 project_id 且存在无素材片段, - 就自动从项目视频素材库选取素材兜底,确保一键生成等场景能正常出片。 - """ - if not clips_without_asset: - return - if not plan_check.project_id: - return - - import random - - logger.info( - "自动兜底4: plan=%s 自动选素材分配给 %d 个无素材片段", - plan_id, - len(clips_without_asset), - ) - libs = asset_library_repo.find_by_project(plan_check.project_id) - video_lib = None - for lib in libs: - lib_kind = lib.kind.value if hasattr(lib.kind, "value") else lib.kind - if lib_kind == "video": - video_lib = lib - break - - if video_lib: - assets = asset_repo.find_by_library(video_lib.id) - ready_videos = [ - a - for a in assets - if (a.status.value if hasattr(a.status, "value") else a.status) == "ready" - and a.mime_type - and a.mime_type.startswith("video") - ] - if ready_videos: - random.shuffle(ready_videos) - for i, clip in enumerate(clips_without_asset): - asset = ready_videos[i % len(ready_videos)] - svc.assign_asset(clip.id, asset.id) - logger.info( - "自动兜底4: plan=%s 从素材库 %s 分配了 %d 个素材给 %d 个片段", - plan_id, - video_lib.name, - len(ready_videos), - len(clips_without_asset), - ) - else: - logger.warning("自动兜底4: plan=%s 素材库无可用视频素材", plan_id) - else: - logger.warning("自动兜底4: plan=%s 项目无视频素材库", plan_id) - - -def _check_queue_limits(gen_task_repo, user_id: str) -> None: - """队列限流预检查""" - try: - has_count = hasattr(gen_task_repo, "count_pending_by_user") and hasattr(gen_task_repo, "count_pending_total") - if has_count: - user_pending = gen_task_repo.count_pending_by_user(user_id) - global_pending = gen_task_repo.count_pending_total() - if user_pending >= USER_PENDING_LIMIT: - raise HTTPException( - status_code=429, - detail=f"您的待处理任务过多(当前 {user_pending}/{USER_PENDING_LIMIT}),请等待完成后再提交", - ) - if global_pending >= GLOBAL_PENDING_LIMIT: - raise HTTPException( - status_code=503, - detail="系统繁忙,请稍后再试", - ) - except HTTPException: - raise - except Exception as e: - logger.warning("[队列限流] 剪辑计划限流检查失败,跳过: %s", e) - - -@router.post("/{plan_id}/generate", response_model=EditPlanGenerateResponse, deprecated=True) -def generate_plan( - plan_id: str, - db: Session = Depends(get_db_session), - current_user: AuthenticatedUser = Depends(get_current_user), - project_repository: Any = Depends(get_project_repository), - asset_library_repo: Any = Depends(get_asset_library_repository), - asset_repo: Any = Depends(get_asset_repository), -) -> EditPlanGenerateResponse: - """触发剪辑计划渲染生成 - - 前置条件:计划状态必须为 editing,且至少有一个片段。 - 流程: - 1. 验证计划状态为 editing - 2. 将 pending 片段标记为 ready - 3. 创建 GenerationTask - 4. 调度 Celery 任务 worker.render_edit_plan - 5. 将计划状态流转为 rendering - """ - svc = EditPlanService(db) - plan_check = svc.get_plan(plan_id) - if plan_check is None: - raise HTTPException(status_code=404, detail=f"剪辑计划不存在: {plan_id}") - if plan_check.project_id: - check_project_access(plan_check.project_id, current_user.user.id, project_repository) - - # 自动兜底流程 - _auto_fallback_draft_to_editing(svc, plan_id, plan_check) - _auto_fallback_copy_template_clips(svc, plan_id, plan_check, db) - clips_without_asset = _auto_fallback_assign_assets(svc, plan_id, plan_check) - _auto_fallback_auto_material_mode(svc, plan_id, plan_check, clips_without_asset, asset_library_repo, asset_repo) - - # 检查是否可生成 - try: - can_gen, reason = svc.can_generate(plan_id) - except ValueError as exc: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc - if not can_gen: - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=reason) - - # 核心生成流程 - try: - clip_count = svc.mark_clips_ready(plan_id) - - gen_task_repo = SQLAlchemyGenerationTaskRepository(db) - user_id = current_user.user.id - _check_queue_limits(gen_task_repo, user_id) - - gen_task_use_case = CreateGenerationTaskUseCase(gen_task_repo) - plan = svc.get_plan_or_raise(plan_id) - # 从 plan.config 中读取 asset_ids 并传递给 GenerationTask - config_asset_ids = (plan.config or {}).get("asset_ids", []) - gen_task = gen_task_use_case.execute( - CreateGenerationTaskCommand( - project_id=plan.project_id or "", - template_id=plan.template_id, - created_by_user_id=current_user.user.id, - source_edit_plan_id=plan_id, - asset_ids=list(config_asset_ids) if config_asset_ids else [], - ) - ) - - svc.update_plan_config(plan_id, {"generation_task_id": gen_task.id}) - svc.transition_status(plan_id, EditPlanStatus.RENDERING) - celery_app.send_task("worker.render_edit_plan", args=[plan_id]) - - updated_plan = svc.get_plan_or_raise(plan_id) - - logger.info( - "触发剪辑计划生成: plan_id=%s gen_task_id=%s clips=%d by user=%s", - plan_id, - gen_task.id, - clip_count, - current_user.user.id, - ) - - return EditPlanGenerateResponse( - plan_id=plan_id, - plan_status=updated_plan.status.value if hasattr(updated_plan.status, "value") else updated_plan.status, - generation_task_id=gen_task.id, - clip_count=clip_count, - ) - except HTTPException: - raise - except Exception as _e: - logger.exception("触发剪辑计划生成失败: plan_id=%s", plan_id) - try: - svc.transition_status(plan_id, EditPlanStatus.FAILED) - except Exception: - logger.warning("标记计划失败状态时异常: plan_id=%s", plan_id) - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="生成失败,请稍后重试", - ) from _e - - -@router.get( - "/{plan_id}/generation-status", - response_model=EditPlanGenerationStatusResponse, - deprecated=True, -) -def get_generation_status( - plan_id: str, - db: Session = Depends(get_db_session), - current_user: AuthenticatedUser = Depends(get_current_user), - project_repository: Any = Depends(get_project_repository), - storage_service: OSSStorageService = Depends(get_storage_service), -) -> EditPlanGenerationStatusResponse: - """查询剪辑计划生成进度""" - svc = EditPlanService(db) - try: - gen_status = svc.get_generation_status(plan_id) - except ValueError as exc: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc - - plan = gen_status["plan"] - if plan.project_id: - check_project_access(plan.project_id, current_user.user.id, project_repository) - clips = gen_status["clips"] - - clip_items = [ - ClipStatusItem( - clip_id=c.id, - clip_type=c.clip_type, - order=c.order, - status=c.status.value if hasattr(c.status, "value") else c.status, - asset_id=c.asset_id or "", - text_content=c.text_content or "", - duration=c.duration, - ) - for c in clips - ] - - # 从 plan.config 中取渲染结果 URL,转换为签名 URL - raw_video_url = (plan.config or {}).get("rendered_url", "") - video_url = "" - if raw_video_url: - try: - video_url = storage_service.get_download_url(raw_video_url, expires_seconds=86400) - except Exception as e: - logger.warning("生成视频签名URL失败,返回原始URL: plan_id=%s error=%s", plan_id, e) - video_url = raw_video_url - # 从 gen_status 中取进度、错误信息、任务状态 - progress = gen_status.get("progress", 0.0) - error_message = gen_status.get("error_message", "") - gen_task_status = gen_status.get("generation_task_status") - # 如果计划已完成但进度还是0,补100 - plan_status_val = plan.status.value if hasattr(plan.status, "value") else plan.status - if plan_status_val == "completed" and progress < 100: - progress = 100.0 - - return EditPlanGenerationStatusResponse( - plan_id=plan_id, - plan_status=plan_status_val, - generation_task_id=gen_status["generation_task_id"], - generation_task_status=gen_task_status, - progress=progress, - video_url=video_url, - error_message=error_message, - clips=clip_items, - ) - - -@router.get( - "/{plan_id}/generations", - response_model=EditPlanGenerationsResponse, - deprecated=True, -) -def list_plan_generations( - plan_id: str, - db: Session = Depends(get_db_session), - current_user: AuthenticatedUser = Depends(get_current_user), - project_repository: Any = Depends(get_project_repository), -) -> EditPlanGenerationsResponse: - """查询剪辑计划关联的所有生成记录""" - svc = EditPlanService(db) - plan = svc.get_plan_or_raise(plan_id) - if plan.project_id: - check_project_access(plan.project_id, current_user.user.id, project_repository) - - from app.schemas.generation_task import GenerationTaskResponse - - gen_task_repo = SQLAlchemyGenerationTaskRepository(db) - tasks = gen_task_repo.list_by_source_edit_plan(plan_id) - items = [ - GenerationTaskResponse( - id=t.id, - project_id=t.project_id, - asset_library_id=t.asset_library_id, - strategy_id=t.strategy_id, - voice_library_id=t.voice_library_id, - template_id=t.template_id, - asset_ids=t.asset_ids, - title_ids=t.title_ids, - voice_ids=t.voice_ids, - source_edit_plan_id=t.source_edit_plan_id or "", - status=t.status.value if hasattr(t.status, "value") else t.status, - progress=t.progress, - result_count=t.result_count, - error_message=t.error_message, - ) - for t in tasks - ] - return EditPlanGenerationsResponse(items=items, total=len(items)) diff --git a/apps/api/app/api/routes/edit_plans_timeline.py b/apps/api/app/api/routes/edit_plans_timeline.py deleted file mode 100644 index 3ac228b94..000000000 --- a/apps/api/app/api/routes/edit_plans_timeline.py +++ /dev/null @@ -1,263 +0,0 @@ -"""剪辑计划时间线 & 模板生成 API 端点。 - -从 edit_plans.py 拆分,包含: -- GET /{plan_id}/timeline 时间线场景数据 -- POST /generate-from-template 基于模板+素材自动生成剪辑计划 -""" - -from __future__ import annotations - -import logging -from typing import Any, List - -from app.api.routes._helpers import auto_select_video_assets, check_project_access -from app.api.routes.edit_plans import ( - GenerateFromTemplateRequest, - GenerateFromTemplateResponse, - _PlanClipItem, - _to_response, -) -from app.auth import AuthenticatedUser, get_current_user -from app.dependencies import ( - get_asset_library_repository, - get_asset_repository, - get_db_session, - get_project_repository, -) -from app.services import EditPlanService, PlanGeneratorService -from fastapi import APIRouter, Depends, HTTPException, status -from pydantic import BaseModel, Field -from sqlalchemy.orm import Session - -from ._helpers import deprecated_edit_plans_api - -logger = logging.getLogger(__name__) - -router = APIRouter( - dependencies=[Depends(deprecated_edit_plans_api)], -) - - -# ── Timeline Schemas ────────────────────────────────────────────────────────── - - -class TimelineSceneResponse(BaseModel): - """时间线场景""" - - scene: str = Field(..., description="场景描述") - time: str = Field(..., description='时间范围,如 "0:00 - 0:05"') - duration: float = Field(..., ge=0, description="时长(秒)") - color: str = Field(..., description="展示颜色") - clip_id: str = Field(default="", description="关联的片段 ID") - clip_type: str = Field(default="", description="片段类型") - - -class TimelineResponse(BaseModel): - """时间线响应""" - - plan_id: str - total_duration: float - scenes: List[TimelineSceneResponse] - - -# clip_type → 颜色映射 -_CLIP_TYPE_COLORS = { - "intro": "#6366f1", - "title": "#6366f1", - "product": "#818cf8", - "showcase": "#10b981", - "scene": "#10b981", - "subtitle": "#f59e0b", - "text": "#f59e0b", - "cta": "#ef4444", - "outro": "#ef4444", - "voiceover": "#8b5cf6", - "transition": "#64748b", -} - -_DEFAULT_COLOR = "#6366f1" - - -def _format_time(seconds: float) -> str: - """将秒数格式化为 M:SS""" - m = int(seconds) // 60 - s = int(seconds) % 60 - return f"{m}:{s:02d}" - - -def _clip_type_to_scene_label(clip_type: str, text_content: str) -> str: - """根据 clip_type 和 text_content 生成场景描述""" - type_labels = { - "intro": "开场", - "title": "标题", - "product": "产品展示", - "showcase": "场景展示", - "scene": "场景", - "subtitle": "字幕", - "text": "文字", - "cta": "结尾 CTA", - "outro": "结尾", - "voiceover": "配音", - "transition": "转场", - } - label = type_labels.get(clip_type, clip_type or "片段") - if text_content: - short = text_content[:20].strip() - if short: - return f"{label} - {short}" - return label - - -# ── Routes ──────────────────────────────────────────────────────────────────── - - -@router.get( - "/{plan_id}/timeline", - response_model=TimelineResponse, - deprecated=True, -) -def get_plan_timeline( - plan_id: str, - db: Session = Depends(get_db_session), - current_user: AuthenticatedUser = Depends(get_current_user), - project_repository: Any = Depends(get_project_repository), -) -> TimelineResponse: - """获取剪辑计划的时间线场景数据""" - svc = EditPlanService(db) - plan = svc.get_plan_or_raise(plan_id) - if plan.project_id: - check_project_access(plan.project_id, current_user.user.id, project_repository) - - clips = svc.list_clips(plan_id=plan_id, skip=0, limit=200) - clips.sort(key=lambda c: c.order) - - scenes: List[TimelineSceneResponse] = [] - current_time = 0.0 - - for clip in clips: - start = current_time - end = start + clip.duration - color = _CLIP_TYPE_COLORS.get(clip.clip_type, _DEFAULT_COLOR) - scene_label = _clip_type_to_scene_label(clip.clip_type, clip.text_content) - - scenes.append( - TimelineSceneResponse( - scene=scene_label, - time=f"{_format_time(start)} - {_format_time(end)}", - duration=clip.duration, - color=color, - clip_id=clip.id, - clip_type=clip.clip_type, - ) - ) - current_time = end - - total_duration = sum(s.duration for s in scenes) or plan.total_duration - - return TimelineResponse( - plan_id=plan_id, - total_duration=total_duration, - scenes=scenes, - ) - - -@router.post( - "/generate-from-template", - response_model=GenerateFromTemplateResponse, - status_code=status.HTTP_201_CREATED, - deprecated=True, -) -def generate_from_template( - body: GenerateFromTemplateRequest, - db: Session = Depends(get_db_session), - current_user: AuthenticatedUser = Depends(get_current_user), - project_repository: Any = Depends(get_project_repository), - asset_repository: Any = Depends(get_asset_repository), - asset_library_repository: Any = Depends(get_asset_library_repository), -) -> GenerateFromTemplateResponse: - """基于模板 + 素材自动生成剪辑计划""" - from app.services import EditTemplateService - - if body.project_id: - check_project_access(body.project_id, current_user.user.id, project_repository) - - template_svc = EditTemplateService(db) - - try: - template = template_svc.get_template_or_raise(body.template_id) - except ValueError as exc: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc - - clip_configs = template_svc.list_clip_configs(body.template_id, skip=0, limit=200) - - # 自动选素材:未传 asset_ids 但有 project_id 时,从项目视频素材库选 ready 的视频素材 - resolved_asset_ids = list(body.asset_ids) - if not resolved_asset_ids and body.project_id: - auto_assets = auto_select_video_assets( - project_id=body.project_id, - asset_library_repo=asset_library_repository, - asset_repo=asset_repository, - logger=logger, - ) - if auto_assets: - resolved_asset_ids = auto_assets - logger.info( - "generate-from-template 自动选素材: project_id=%s count=%d", - body.project_id, - len(auto_assets), - ) - - generator = PlanGeneratorService(db) - result = generator.generate_from_template( - template=template, - clip_configs=clip_configs, - asset_ids=resolved_asset_ids, - project_id=body.project_id, - created_by_user_id=current_user.user.id, - name=body.name, - ) - - plan = result["plan"] - clips = result["clips"] - - # 把 asset_ids 写入 plan.config,供生成时兜底分配使用 - if resolved_asset_ids: - from app.services import EditPlanService - - from packages.domain.config_schemas import normalize_plan_config - - svc = EditPlanService(db) - current_config = plan.config or {} - if current_config.get("asset_ids") != resolved_asset_ids: - current_config["asset_ids"] = resolved_asset_ids - plan = svc.update_plan(plan.id, config=normalize_plan_config(current_config)) - - logger.info( - "基于模板生成剪辑计划: plan_id=%s template_id=%s clips=%d by user=%d", - plan.id, - body.template_id, - len(clips), - current_user.user.id, - ) - - return GenerateFromTemplateResponse( - plan=_to_response(plan), - clips=[ - _PlanClipItem( - id=c.id, - clip_type=c.clip_type, - order=c.order, - asset_id=c.asset_id, - text_content=c.text_content, - start_time=c.start_time, - duration=c.duration, - transition_effect=c.transition_effect, - transition_duration=c.transition_duration, - status=c.status.value if hasattr(c.status, "value") else c.status, - config=c.config, - created_at=c.created_at, - updated_at=c.updated_at, - ) - for c in clips - ], - ) diff --git a/apps/api/app/api/routes/edit_plans_transitions.py b/apps/api/app/api/routes/edit_plans_transitions.py deleted file mode 100644 index bad1fa411..000000000 --- a/apps/api/app/api/routes/edit_plans_transitions.py +++ /dev/null @@ -1,274 +0,0 @@ -"""转场特效 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, deprecated_edit_plans_api - -logger = logging.getLogger(__name__) - -router = APIRouter( - dependencies=[Depends(deprecated_edit_plans_api)], -) - - -# ── 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, deprecated=True) -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, deprecated=True) -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, deprecated=True) -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/apps/api/app/api/routes/templates_editor.py b/apps/api/app/api/routes/templates_editor.py old mode 100755 new mode 100644 index 439e8f5dc..617ecd62c --- a/apps/api/app/api/routes/templates_editor.py +++ b/apps/api/app/api/routes/templates_editor.py @@ -23,60 +23,462 @@ from __future__ import annotations import logging +import re as _re from typing import Any, List, Optional -from app.api.routes.edit_plans import ( - AIRecommendRequest, - AIRecommendResponse, - BGMConfigUpdateRequest, - ClipStatusItem, - EditPlanGenerateResponse, - EditPlanGenerationsResponse, - EditPlanGenerationStatusResponse, - GenerateCoverRequest, - GenerateCoverResponse, -) -from app.api.routes.edit_plans_adjustments import ( - BatchSpeedRequest, - BatchSpeedResponse, - ClipAdjustmentsRequest, - ClipAdjustResponse, - SpeedAdjustRequest, - TrimAdjustRequest, - VolumeAdjustRequest, -) -from app.api.routes.edit_plans_clips_batch import ( - ClipBatchDeleteRequest, - ClipBatchDeleteResponse, - ClipReorderRequest, - ClipReorderResponse, - ClipsFromAssetsRequest, - ClipsFromAssetsResponse, -) -from app.api.routes.edit_plans_cover import ( - CoverConfigResponse, - CoverExtractRequest, - CoverGenerateResponse, - CoverSmartRequest, - CoverUpdateRequest, -) -from app.api.routes.edit_plans_export import ( - ExportConfigResponse, - ExportPresetListResponse, - ExportUpdateRequest, -) -from app.api.routes.edit_plans_filter import ( - FilterConfigResponse, - FilterPresetListResponse, - FilterUpdateRequest, -) -from app.api.routes.edit_plans_transitions import ( - BatchTransitionRequest, - BatchTransitionResponse, - ClipTransitionResponse, - TransitionPresetListResponse, - TransitionUpdateRequest, -) +from app.schemas.generation_task import GenerationTaskResponse +from pydantic import BaseModel, Field, validator + +# ── Pydantic Schemas (migrated from edit_plans*) ──────────────────────────── + + +# ── From edit_plans.py ────────────────────────────────────────────────────── + + +class ClipStatusItem(BaseModel): + """片段生成状态""" + + clip_id: str + clip_type: str + order: int + status: str + asset_id: str + text_content: str + duration: float + + +class EditPlanGenerationStatusResponse(BaseModel): + """剪辑计划生成进度响应体""" + + plan_id: str + plan_status: str + generation_task_id: Optional[str] = None + generation_task_status: Optional[str] = None + progress: float = 0.0 + video_url: str = "" + error_message: str = "" + clips: List[ClipStatusItem] + + +class EditPlanGenerateResponse(BaseModel): + """剪辑计划触发生成响应体""" + + plan_id: str + plan_status: str + generation_task_id: str + clip_count: int + + +class EditPlanGenerationsResponse(BaseModel): + """剪辑计划关联的生成记录列表响应体""" + + items: List[GenerationTaskResponse] + total: int + + +class AIRecommendRequest(BaseModel): + """AI 推荐片段方案请求体""" + + asset_ids: List[str] = Field(default_factory=list, description="素材 ID 列表") + editing_mode: str = Field(default="one_take", description="剪辑模式: one_take / pip / voice_over / voice_pip") + target_duration: float = Field(default=30.0, ge=1.0, le=600.0, description="目标时长(秒)") + + +class AIRecommendClipItem(BaseModel): + """AI 推荐的单个片段""" + + clip_type: str = Field(..., description="片段类型: intro / showcase / title / subtitle / cta / outro") + order: int = Field(..., ge=0, description="片段顺序") + text_content: str = Field(default="", description="文字内容") + duration: float = Field(..., ge=0.0, description="片段时长(秒)") + transition_effect: str = Field(default="cut", description="转场效果") + transition_duration: float = Field(default=0.0, ge=0.0, description="转场时长(秒),0 表示使用默认值") + asset_id: str = Field(default="", description="关联素材 ID") + start_time: float = Field(default=0.0, ge=0.0, description="素材截取起始时间(秒)") + config: dict[str, Any] = Field(default_factory=dict, description="片段额外配置") + + +class AIRecommendResponse(BaseModel): + """AI 推荐片段方案响应体""" + + plan_id: str = Field(..., description="剪辑计划 ID") + clips: List[AIRecommendClipItem] = Field(..., description="推荐的片段列表") + config: dict[str, Any] = Field(..., description="推荐的 plan config(cover/title/subtitle/bgm)") + total_duration: float = Field(..., ge=0.0, description="推荐方案总时长(秒)") + confidence: float = Field(..., ge=0.0, le=1.0, description="AI 推荐置信度 (0~1)") + + +class GenerateCoverRequest(BaseModel): + """AI 封面生成请求体""" + + asset_ids: List[str] = Field(default_factory=list, description="素材 ID 列表(确定视频来源)") + cover_type: str = Field( + default="ai_frame", + description="封面类型: ai_frame / manual / upload / ai_regenerate", + ) + frame_time: Optional[float] = Field( + default=None, + ge=0.0, + description="手动选帧时间点(秒),仅 cover_type=manual 时有效", + ) + + +class GenerateCoverResponse(BaseModel): + """AI 封面生成响应体""" + + plan_id: str = Field(..., description="剪辑计划 ID") + cover: dict[str, Any] = Field(..., description="封面数据(type / image_url / frame_time 等)") + + +class BGMConfigUpdateRequest(BaseModel): + """更新BGM配置请求体""" + + enabled: Optional[bool] = Field(default=None, description="是否启用 BGM") + source: Optional[str] = Field(default=None, description="BGM 来源: library/upload/ai_recommend") + asset_id: Optional[str] = Field(default=None, max_length=64, description="BGM 素材 ID") + preset_id: Optional[str] = Field(default=None, max_length=64, description="预设 BGM ID") + audio_url: Optional[str] = Field(default=None, max_length=500, description="BGM 音频 URL") + volume: Optional[float] = Field(default=None, ge=0.0, le=1.0, description="音量 (0.0 ~ 1.0)") + fade_in: Optional[float] = Field(default=None, ge=0.0, le=30.0, description="淡入时长(秒)") + fade_out: Optional[float] = Field(default=None, ge=0.0, le=30.0, description="淡出时长(秒)") + loop_enabled: Optional[bool] = Field(default=None, description="是否循环播放") + sidechain_enabled: Optional[bool] = Field(default=None, description="是否启用人声闪避") + sidechain_ratio: Optional[float] = Field(default=None, ge=0.0, le=1.0, description="闪避音量降低比例") + + +# ── From edit_plans_adjustments.py ────────────────────────────────────────── + + +class SpeedAdjustRequest(BaseModel): + """调速请求""" + + speed: float = Field(..., ge=0.25, le=4.0, description="播放速度 0.25~4.0") + + +class VolumeAdjustRequest(BaseModel): + """音量调节请求""" + + volume: float = Field(..., ge=0.0, le=2.0, description="音量倍率 0~2.0(1.0=原音量)") + + +class TrimAdjustRequest(BaseModel): + """裁剪请求""" + + trim_start: float = Field(0.0, ge=0.0, description="开头裁剪秒数") + trim_end: float = Field(0.0, ge=0.0, description="结尾裁剪秒数") + + +class ClipAdjustmentsRequest(BaseModel): + """统一调整请求""" + + speed: Optional[float] = Field(default=None, ge=0.25, le=4.0) + volume: Optional[float] = Field(default=None, ge=0.0, le=2.0) + trim_start: Optional[float] = Field(default=None, ge=0.0) + trim_end: Optional[float] = Field(default=None, ge=0.0) + + +class BatchSpeedRequest(BaseModel): + """批量调速请求""" + + speed: float = Field(..., ge=0.25, le=4.0, description="播放速度") + + +class ClipAdjustResponse(BaseModel): + """片段调整响应""" + + clip_id: str + speed: float + volume: float + trim_start: float + trim_end: float + duration: float + + +class BatchSpeedResponse(BaseModel): + """批量调速响应""" + + updated_count: int + plan_id: str + + +# ── From edit_plans_clips_batch.py ────────────────────────────────────────── + + +class ClipReorderItem(BaseModel): + """重排序条目""" + + clip_id: str + new_order: int = Field(..., ge=0, description="新的排序序号") + + +class ClipReorderRequest(BaseModel): + """片段重排序请求""" + + items: List[ClipReorderItem] = Field(..., min_length=1, max_length=500, description="重排序条目列表") + + +class ClipReorderResponse(BaseModel): + """片段重排序响应""" + + success: bool + updated_count: int + message: str = "" + + +class ClipBatchDeleteRequest(BaseModel): + """批量删除片段请求""" + + clip_ids: List[str] = Field(..., min_length=1, max_length=500, description="要删除的片段ID列表") + + +class ClipBatchDeleteResponse(BaseModel): + """批量删除片段响应""" + + success: bool + deleted_count: int + message: str = "" + + +class ClipsFromAssetsRequest(BaseModel): + """从素材批量创建片段请求""" + + asset_ids: List[str] = Field(..., min_length=1, max_length=200, description="素材 ID 列表,按顺序追加到时间线末尾") + clip_type: str = Field(default="main", description="片段类型,默认 main") + + +class ClipsFromAssetsResponse(BaseModel): + """从素材批量创建片段响应""" + + success: bool + created_count: int + message: str = "" + clip_ids: List[str] = Field(default_factory=list, description="创建的片段ID列表") + + +# ── From edit_plans_cover.py ──────────────────────────────────────────────── + + +class CoverConfigResponse(BaseModel): + """封面配置响应""" + + type: str = Field(..., description="封面类型: ai_frame / manual / upload") + image_url: str = Field(default="", description="封面图片 URL") + frame_time: Optional[float] = Field(default=None, description="抽帧时间点(秒)") + + +class CoverUpdateRequest(BaseModel): + """更新封面配置请求""" + + type: Optional[str] = Field(default=None, description="封面类型") + image_url: Optional[str] = Field(default=None, description="封面图片 URL") + frame_time: Optional[float] = Field(default=None, ge=0.0, description="抽帧时间点(秒)") + + +class CoverExtractRequest(BaseModel): + """从片段抽帧生成封面请求""" + + clip_id: str = Field(..., description="片段 ID") + frame_time: float = Field(1.0, ge=0.0, description="抽帧时间点(秒)") + + +class CoverSmartRequest(BaseModel): + """智能选帧请求""" + + clip_id: Optional[str] = Field(default=None, description="指定片段 ID(不传则用第一个视频片段)") + + +class CoverGenerateResponse(BaseModel): + """封面生成响应""" + + type: str = Field(..., description="封面类型") + image_url: str = Field(..., description="封面图片 URL") + frame_time: Optional[float] = Field(default=None, description="抽帧时间点(秒)") + + +# ── From edit_plans_export.py ─────────────────────────────────────────────── + +_EXPORT_RESOLUTION_PATTERN = _re.compile(r"^\d+x\d+$") +_EXPORT_VALID_QUALITY_PRESETS = {"ultra_fast", "fast", "balanced", "high", "best"} +_EXPORT_VALID_FORMATS = {"mp4", "mov"} + + +class ExportConfigResponse(BaseModel): + """导出配置响应""" + + resolution: str + fps: int + video_bitrate: int + audio_bitrate: int + format: str + quality_preset: str + watermark_enabled: bool + watermark_text: str + + +class ExportUpdateRequest(BaseModel): + """更新导出配置请求""" + + resolution: Optional[str] = None + fps: Optional[int] = Field(default=None, ge=15, le=60) + video_bitrate: Optional[int] = Field(default=None, ge=1000, le=20000) + audio_bitrate: Optional[int] = Field(default=None, ge=64, le=320) + format: Optional[str] = None + quality_preset: Optional[str] = None + watermark_enabled: Optional[bool] = None + watermark_text: Optional[str] = None + + @validator("resolution") + def validate_resolution(cls, v): + if v is None: + return v + if not _EXPORT_RESOLUTION_PATTERN.match(v): + raise ValueError("分辨率格式错误,应为 宽x高,如 1080x1920") + w, h = v.split("x") + if int(w) < 100 or int(h) < 100: + raise ValueError("分辨率数值过小") + if int(w) > 4096 or int(h) > 4096: + raise ValueError("分辨率数值过大,最大 4096x4096") + return v + + @validator("format") + def validate_format(cls, v): + if v is None: + return v + if v not in _EXPORT_VALID_FORMATS: + raise ValueError(f"无效格式: {v},支持: {_EXPORT_VALID_FORMATS}") + return v + + @validator("quality_preset") + def validate_quality_preset(cls, v): + if v is None: + return v + if v not in _EXPORT_VALID_QUALITY_PRESETS: + raise ValueError(f"无效质量预设: {v},支持: {_EXPORT_VALID_QUALITY_PRESETS}") + return v + + +class ExportPresetItem(BaseModel): + """导出预设条目""" + + id: str + name: str + resolution: str + fps: int + video_bitrate: int + audio_bitrate: int + format: str + quality_preset: str + description: str + size_hint: str + + +class ExportPresetListResponse(BaseModel): + """导出预设列表响应""" + + items: List[ExportPresetItem] + total: int + + +# ── From edit_plans_filter.py ─────────────────────────────────────────────── + + +class FilterPresetResponse(BaseModel): + """滤镜预设响应""" + + id: str + name: str + category: str + description: str + tags: List[str] = Field(default_factory=list) + + +class FilterConfigResponse(BaseModel): + """滤镜配置响应""" + + enabled: bool + preset_id: str + intensity: int + brightness: float + contrast: float + saturation: float + warmth: float + + +class FilterUpdateRequest(BaseModel): + """更新滤镜配置请求""" + + enabled: Optional[bool] = None + preset_id: Optional[str] = None + intensity: Optional[int] = Field(default=None, ge=0, le=100) + brightness: Optional[float] = Field(default=None, ge=-1.0, le=1.0) + contrast: Optional[float] = Field(default=None, ge=0.0, le=2.0) + saturation: Optional[float] = Field(default=None, ge=0.0, le=3.0) + warmth: Optional[float] = Field(default=None, ge=-1.0, le=1.0) + + +class FilterPresetListResponse(BaseModel): + """滤镜预设列表响应""" + + items: List[FilterPresetResponse] + total: int + + +# ── From edit_plans_transitions.py ────────────────────────────────────────── + + +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 + + from app.auth import AuthenticatedUser, get_current_user from app.core.celery_app import celery_app from app.core.storage import OSSStorageService, get_storage_service @@ -89,7 +491,6 @@ from app.dependencies import ( from app.services.edit_plan_service import EditPlanService from app.services.edit_template_service import EditTemplateService from fastapi import APIRouter, Depends, HTTPException, Query, status -from pydantic import BaseModel, Field from sqlalchemy.orm import Session from packages.adapters.sqlalchemy_impl.generation_task_repository import ( diff --git a/apps/api/app/services/edit_plan_service.py b/apps/api/app/services/edit_plan_service.py old mode 100644 new mode 100755 index ee1ff055a..8b8ad6f87 --- a/apps/api/app/services/edit_plan_service.py +++ b/apps/api/app/services/edit_plan_service.py @@ -39,70 +39,6 @@ class EditPlanService: # ── 剪辑计划 CRUD ────────────────────────────────────────────────────── - def list_plans( - self, - *, - template_id: Optional[str] = None, - project_id: Optional[str] = None, - status: Optional[EditPlanStatus] = None, - skip: int = 0, - limit: int = 50, - ) -> List[EditPlan]: - """列出剪辑计划 - - Args: - template_id: 按模板 ID 筛选 - project_id: 按项目 ID 筛选 - status: 按状态筛选 - skip: 分页偏移 - limit: 每页数量 - """ - if project_id: - return self._plan_repo.list_by_project( - project_id, - status=status, - skip=skip, - limit=limit, - ) - if template_id: - return self._plan_repo.list_by_template( - template_id, - status=status, - skip=skip, - limit=limit, - ) - return self._plan_repo.list_all(status=status, skip=skip, limit=limit) - - def count_plans( - self, - *, - template_id: Optional[str] = None, - project_id: Optional[str] = None, - status: Optional[EditPlanStatus] = None, - ) -> int: - """统计计划数量 - - Note: - 当指定 template_id/project_id 时,通过全量查询计算 total(repo 限制)。 - """ - if project_id: - all_matching = self._plan_repo.list_by_project( - project_id, - status=status, - skip=0, - limit=10000, - ) - return len(all_matching) - if template_id: - all_matching = self._plan_repo.list_by_template( - template_id, - status=status, - skip=0, - limit=10000, - ) - return len(all_matching) - return self._plan_repo.count(status=status) - def get_plan(self, plan_id: str) -> Optional[EditPlan]: """获取计划详情""" return self._plan_repo.get(plan_id) @@ -124,7 +60,7 @@ class EditPlanService: project_id: str = "", created_by_user_id: str = "", ) -> EditPlan: - """创建剪辑计划 + """创建剪辑计划(基础 CRUD,供内部测试与脚本使用) Raises: ValueError: 参数校验失败 @@ -190,23 +126,6 @@ class EditPlanService: logger.info("更新剪辑计划: id=%s", plan_id) return result - def delete_plan(self, plan_id: str) -> bool: - """删除剪辑计划及其所有片段 - - Returns: - bool: 是否删除成功 - """ - existing = self._plan_repo.get(plan_id) - if existing is None: - return False - - # 先删除所有片段 - self._clip_repo.delete_by_plan(plan_id) - # 再删除计划 - self._plan_repo.delete(plan_id) - logger.info("删除剪辑计划: id=%s", plan_id) - return True - # ── 状态机流转 ────────────────────────────────────────────────────────── def transition_status(self, plan_id: str, target_status: EditPlanStatus) -> EditPlan: @@ -447,63 +366,6 @@ class EditPlanService: logger.info("删除所有片段: plan_id=%s count=%d", plan_id, count) return count - def create_clips_from_assets( - self, - plan_id: str, - asset_ids: list[str], - *, - clip_type: str = "main", - ) -> list[EditPlanClip]: - """从素材批量创建片段(追加到时间线末尾)。 - - Args: - plan_id: 计划 ID - asset_ids: 素材 ID 列表(按顺序追加) - clip_type: 片段类型 - - Returns: - list[EditPlanClip]: 创建的片段列表 - """ - if not asset_ids: - return [] - - # 确保计划存在 + 自动回退状态 - self.get_plan_or_raise(plan_id) - self._auto_resume_editing(plan_id) - - # 查询素材信息(取 duration) - from packages.adapters.sqlalchemy_impl.models import AssetModel - - session = self._clip_repo.session # type: ignore[attr-defined] - assets = session.query(AssetModel).filter(AssetModel.id.in_(asset_ids)).all() - asset_map = {a.id: a for a in assets} - - # 从现有片段数量开始追加 - existing_count = self._clip_repo.count(plan_id=plan_id) - - # 批量创建片段 - created: list[EditPlanClip] = [] - for i, asset_id in enumerate(asset_ids): - asset = asset_map.get(asset_id) - duration = asset.duration if asset and asset.duration else 0.0 - - clip = self.create_clip( - plan_id=plan_id, - clip_type=clip_type, - order=existing_count + i, - asset_id=asset_id, - duration=duration, - ) - created.append(clip) - - logger.info( - "从素材批量创建片段: plan_id=%s count=%d", - plan_id, - len(created), - ) - return created - - # ── 渲染生成流程 ──────────────────────────────────────────────────────── # ── 片段分割与合并 ────────────────────────────────────────────────────── def split_clip(self, clip_id: str, split_time: float) -> Dict[str, Any]: @@ -674,249 +536,7 @@ class EditPlanService: return merged_clip - # ── 字幕管理 ────────────────────────────────────────────────────────── - - def list_subtitles(self, clip_id: str) -> List[Dict[str, Any]]: - """获取片段的所有字幕 - - Returns: - List[dict]: 字幕列表,按 start 时间排序 - """ - clip = self.get_clip_or_raise(clip_id) - config = clip.config or {} - subtitles = config.get("subtitles", []) - # 按开始时间排序 - subtitles.sort(key=lambda s: s.get("start", 0)) - return subtitles - - def get_subtitle(self, clip_id: str, subtitle_id: str) -> Optional[Dict[str, Any]]: - """获取单条字幕""" - subtitles = self.list_subtitles(clip_id) - for s in subtitles: - if s.get("id") == subtitle_id: - return s - return None - - def add_subtitle( - self, - clip_id: str, - start: float, - end: float, - text: str, - *, - style: Optional[Dict[str, Any]] = None, - ) -> Dict[str, Any]: - """添加一条字幕 - - Args: - clip_id: 片段 ID - start: 开始时间(秒,相对于片段) - end: 结束时间(秒) - text: 字幕文本 - style: 样式配置(字体、大小、颜色、位置等) - - Returns: - dict: 新增的字幕条目 - - Raises: - ValueError: 时间非法或文本为空 - """ - clip = self.get_clip_or_raise(clip_id) - - if start < 0 or end <= start: - raise ValueError(f"字幕时间非法: start={start}, end={end}") - if not text.strip(): - raise ValueError("字幕文本不能为空") - if end > clip.duration + 0.001: - raise ValueError(f"字幕结束时间不能超过片段时长: end={end:.3f}, duration={clip.duration:.3f}") - - self._auto_resume_editing(clip.plan_id) - - from uuid import uuid4 - - config = dict(clip.config) if clip.config else {} - subtitles = list(config.get("subtitles", [])) - - subtitle = { - "id": uuid4().hex, - "start": round(start, 3), - "end": round(end, 3), - "text": text.strip(), - "style": style or {}, - } - subtitles.append(subtitle) - subtitles.sort(key=lambda s: s.get("start", 0)) - - config["subtitles"] = subtitles - clip.config = config - self._clip_repo.update(clip) - - logger.info( - "添加字幕: clip_id=%s subtitle_id=%s start=%.3fs end=%.3fs", - clip_id, - subtitle["id"], - start, - end, - ) - - return subtitle - - def update_subtitle( - self, - clip_id: str, - subtitle_id: str, - *, - start: Optional[float] = None, - end: Optional[float] = None, - text: Optional[str] = None, - style: Optional[Dict[str, Any]] = None, - ) -> Dict[str, Any]: - """更新一条字幕 - - Returns: - dict: 更新后的字幕条目 - - Raises: - ValueError: 字幕不存在或参数非法 - """ - clip = self.get_clip_or_raise(clip_id) - config = dict(clip.config) if clip.config else {} - subtitles = list(config.get("subtitles", [])) - - found = False - for i, s in enumerate(subtitles): - if s.get("id") == subtitle_id: - # 更新字段 - updated_s = dict(s) - if start is not None: - updated_s["start"] = round(start, 3) - if end is not None: - updated_s["end"] = round(end, 3) - if text is not None: - if not text.strip(): - raise ValueError("字幕文本不能为空") - updated_s["text"] = text.strip() - if style is not None: - updated_s["style"] = style - - # 校验时间 - if updated_s["start"] < 0 or updated_s["end"] <= updated_s["start"]: - raise ValueError(f"字幕时间非法: start={updated_s['start']}, end={updated_s['end']}") - if updated_s["end"] > clip.duration + 0.001: - raise ValueError("字幕结束时间不能超过片段时长") - - subtitles[i] = updated_s - found = True - break - - if not found: - raise ValueError(f"字幕不存在: {subtitle_id}") - - self._auto_resume_editing(clip.plan_id) - - subtitles.sort(key=lambda s: s.get("start", 0)) - config["subtitles"] = subtitles - clip.config = config - self._clip_repo.update(clip) - - logger.info("更新字幕: clip_id=%s subtitle_id=%s", clip_id, subtitle_id) - - return subtitles[next(i for i, s in enumerate(subtitles) if s["id"] == subtitle_id)] - - def delete_subtitle(self, clip_id: str, subtitle_id: str) -> bool: - """删除一条字幕 - - Returns: - bool: 是否删除成功 - """ - clip = self.get_clip_or_raise(clip_id) - config = dict(clip.config) if clip.config else {} - subtitles = list(config.get("subtitles", [])) - - new_subtitles = [s for s in subtitles if s.get("id") != subtitle_id] - if len(new_subtitles) == len(subtitles): - return False - - self._auto_resume_editing(clip.plan_id) - - config["subtitles"] = new_subtitles - clip.config = config - self._clip_repo.update(clip) - - logger.info("删除字幕: clip_id=%s subtitle_id=%s", clip_id, subtitle_id) - return True - - def batch_update_subtitles( - self, - clip_id: str, - subtitles: List[Dict[str, Any]], - ) -> List[Dict[str, Any]]: - """批量更新字幕(全量替换,用于批量编辑或导入) - - Args: - clip_id: 片段 ID - subtitles: 字幕列表,每条需包含 start/end/text,已有 id 则保留 - - Returns: - List[dict]: 更新后的字幕列表 - """ - clip = self.get_clip_or_raise(clip_id) - - from uuid import uuid4 - - validated = [] - for s in subtitles: - start = float(s.get("start", 0)) - end = float(s.get("end", 0)) - text = str(s.get("text", "")) - - if start < 0 or end <= start: - raise ValueError(f"字幕时间非法: start={start}, end={end}") - if not text.strip(): - continue # 跳过空字幕 - if end > clip.duration + 0.001: - raise ValueError(f"字幕结束时间不能超过片段时长: end={end}") - - subtitle_id = s.get("id") or uuid4().hex - validated.append( - { - "id": subtitle_id, - "start": round(start, 3), - "end": round(end, 3), - "text": text.strip(), - "style": s.get("style", {}), - } - ) - - validated.sort(key=lambda s: s["start"]) - - self._auto_resume_editing(clip.plan_id) - - config = dict(clip.config) if clip.config else {} - config["subtitles"] = validated - clip.config = config - self._clip_repo.update(clip) - - logger.info( - "批量更新字幕: clip_id=%s count=%d", - clip_id, - len(validated), - ) - - return validated - - def get_plan_with_clips(self, plan_id: str) -> Dict[str, Any]: - """获取计划及其所有片段 - - Returns: - dict: {"plan": EditPlan, "clips": List[EditPlanClip]} - """ - plan = self.get_plan_or_raise(plan_id) - clips = self._clip_repo.list_by_plan(plan_id) - return { - "plan": plan, - "clips": clips, - } + # ── 渲染生成流程 ──────────────────────────────────────────────────────── def get_generation_status(self, plan_id: str) -> Dict[str, Any]: """获取渲染进度状态 @@ -1026,85 +646,3 @@ class EditPlanService: updated_at=plan.updated_at, ) return self._plan_repo.update(updated) - - # ── 复制计划 ──────────────────────────────────────────────────────────── - - def copy_plan( - self, - plan_id: str, - *, - new_name: Optional[str] = None, - project_id: Optional[str] = None, - ) -> EditPlan: - """复制一个剪辑计划(含所有片段配置)。 - - 新计划状态为 editing,不含生成任务和结果记录。 - - Args: - plan_id: 源计划 ID - new_name: 新计划名称,不传则为「原名 - 副本」 - project_id: 新计划的项目 ID,不传则复用源计划 - - Returns: - EditPlan: 新创建的计划 - - Raises: - ValueError: 源计划不存在 - """ - source = self.get_plan_or_raise(plan_id) - source_clips = self._clip_repo.list_by_plan(plan_id) - - # 新计划名称 - name = new_name or f"{source.name} - 副本" - new_project_id = project_id if project_id is not None else source.project_id - - # 复制 plan 配置(去除渲染结果相关字段) - new_config = dict(source.config) - new_config.pop("rendered_url", None) - new_config.pop("rendered_storage_key", None) - new_config.pop("generation_task_id", None) - - # 创建新计划 - new_plan = EditPlan.create( - template_id=source.template_id, - name=name, - config=new_config, - total_duration=source.total_duration, - project_id=new_project_id, - created_by_user_id=source.created_by_user_id, - source_edit_plan_id=plan_id, - ) - # 强制切到 editing 状态 - if new_plan.status != EditPlanStatus.EDITING: - try: - new_plan.start_editing() - except ValueError: - pass - - created_plan = self._plan_repo.create(new_plan) - logger.info( - "复制剪辑计划: source=%s target=%s name=%s clips=%d", - plan_id, - created_plan.id, - name, - len(source_clips), - ) - - # 复制所有片段 - for clip in source_clips: - new_clip = self.create_clip( - plan_id=created_plan.id, - clip_type=clip.clip_type, - order=clip.order, - asset_id=clip.asset_id or "", - text_content=clip.text_content or "", - start_time=clip.start_time, - duration=clip.duration, - transition_effect=clip.transition_effect or "cut", - transition_duration=clip.transition_duration or 0.0, - playback_speed=clip.playback_speed or 1.0, - config=dict(clip.config) if clip.config else None, - ) - logger.debug("复制片段: source=%s target=%s order=%d", clip.id, new_clip.id, clip.order) - - return self.get_plan_or_raise(created_plan.id) diff --git a/apps/api/app/services/edit_template_service.py b/apps/api/app/services/edit_template_service.py index ef8c72652..43cf7af99 100755 --- a/apps/api/app/services/edit_template_service.py +++ b/apps/api/app/services/edit_template_service.py @@ -754,8 +754,8 @@ class EditTemplateService: template.bump_version() # 版本号 +1 updated_template = self._template_repo.update(template) - # 批量删除旧的片段配置(走 repository,保证测试 stub 和真实行为一致) - self._clip_config_repo.delete_by_template(template_id) + # 批量删除旧的片段配置(外层事务统一提交) + self._clip_config_repo.delete_by_template(template_id, commit=False) # 创建新的片段配置 created_configs: list[TemplateClipConfig] = [] diff --git a/apps/worker/worker_app/tasks/edit_plan_generation.py b/apps/worker/worker_app/tasks/edit_plan_generation.py index 7ecbe3e8c..c1f52ecac 100755 --- a/apps/worker/worker_app/tasks/edit_plan_generation.py +++ b/apps/worker/worker_app/tasks/edit_plan_generation.py @@ -142,8 +142,6 @@ def _finalize_render_success( plan.config["rendered_storage_key"] = storage_key if hasattr(plan, "total_duration") and duration > 0: plan.total_duration = duration - if hasattr(plan, "result_count"): - plan.result_count = 1 plan.mark_completed() plan_repo.update(plan) diff --git a/packages/adapters/sqlalchemy_impl/edit_plan_repository.py b/packages/adapters/sqlalchemy_impl/edit_plan_repository.py index 871fd88ed..ad6c87eb5 100755 --- a/packages/adapters/sqlalchemy_impl/edit_plan_repository.py +++ b/packages/adapters/sqlalchemy_impl/edit_plan_repository.py @@ -100,7 +100,6 @@ class SQLAlchemyEditPlanRepository: name=plan.name, status=plan.status, total_duration=plan.total_duration, - result_count=plan.result_count, source_edit_plan_id=plan.source_edit_plan_id or None, project_id=plan.project_id or "", created_by_user_id=plan.created_by_user_id or "", @@ -120,7 +119,6 @@ class SQLAlchemyEditPlanRepository: model.name = plan.name model.status = plan.status model.total_duration = plan.total_duration - model.result_count = plan.result_count model.source_edit_plan_id = plan.source_edit_plan_id or None model.project_id = plan.project_id or "" model.created_by_user_id = plan.created_by_user_id or "" @@ -154,7 +152,6 @@ class SQLAlchemyEditPlanRepository: name=model.name, status=EditPlanStatus(model.status) if model.status else EditPlanStatus.DRAFT, total_duration=model.total_duration or 0.0, - result_count=int(model.result_count or 0), source_edit_plan_id=model.source_edit_plan_id or "", project_id=model.project_id or "", created_by_user_id=model.created_by_user_id or "", diff --git a/packages/adapters/sqlalchemy_impl/models.py b/packages/adapters/sqlalchemy_impl/models.py index d4f209a5c..ff64a71d0 100755 --- a/packages/adapters/sqlalchemy_impl/models.py +++ b/packages/adapters/sqlalchemy_impl/models.py @@ -172,7 +172,6 @@ class EditPlanModel(Base): name = Column(String(200), nullable=False) status = Column(String(20), nullable=False, default="draft", index=True) total_duration = Column(Float, nullable=False, default=0.0) - result_count = Column(Integer, nullable=False, default=0) config = Column(JSON, nullable=False, default=dict) source_edit_plan_id = Column(String(36), nullable=True, index=True) project_id = Column(String(36), nullable=False, default="", index=True) diff --git a/packages/adapters/sqlalchemy_impl/template_clip_config_repository.py b/packages/adapters/sqlalchemy_impl/template_clip_config_repository.py old mode 100644 new mode 100755 index 4e06c01a8..17ca3b73a --- a/packages/adapters/sqlalchemy_impl/template_clip_config_repository.py +++ b/packages/adapters/sqlalchemy_impl/template_clip_config_repository.py @@ -92,14 +92,20 @@ class SQLAlchemyTemplateClipConfigRepository: self.session.commit() return True - def delete_by_template(self, template_id: str) -> int: - """删除模板下所有片段配置,返回删除数量""" + def delete_by_template(self, template_id: str, *, commit: bool = True) -> int: + """删除模板下所有片段配置,返回删除数量 + + Args: + template_id: 模板ID + commit: 是否提交事务,默认True。外层有事务控制时传False。 + """ count = ( self.session.query(TemplateClipConfigModel) .filter(TemplateClipConfigModel.template_id == template_id) .delete() ) - self.session.commit() + if commit: + self.session.commit() return count def count(self, *, template_id: Optional[str] = None) -> int: diff --git a/packages/domain/edit_plan.py b/packages/domain/edit_plan.py index 7b7467da6..4eec3e915 100755 --- a/packages/domain/edit_plan.py +++ b/packages/domain/edit_plan.py @@ -42,7 +42,6 @@ class EditPlan: name: str status: EditPlanStatus = EditPlanStatus.DRAFT total_duration: float = 0.0 - result_count: int = 0 source_edit_plan_id: str = "" project_id: str = "" created_by_user_id: str = "" @@ -58,7 +57,6 @@ class EditPlan: *, config: dict[str, Any] | None = None, total_duration: float = 0.0, - result_count: int = 0, source_edit_plan_id: str = "", project_id: str = "", created_by_user_id: str = "", @@ -75,7 +73,6 @@ class EditPlan: name=clean_name, status=EditPlanStatus.DRAFT, total_duration=total_duration, - result_count=result_count, source_edit_plan_id=source_edit_plan_id.strip(), project_id=project_id.strip(), created_by_user_id=created_by_user_id.strip(), diff --git a/tests/unit/test_config_schemas_and_ai_endpoints.py b/tests/unit/test_config_schemas_and_ai_endpoints.py index b79e2b75a..314d38541 100644 --- a/tests/unit/test_config_schemas_and_ai_endpoints.py +++ b/tests/unit/test_config_schemas_and_ai_endpoints.py @@ -4,11 +4,6 @@ - config_schemas: normalize_plan_config / normalize_template_config 默认值填充、部分覆盖、非标准字段保留 - config_schemas: Pydantic 枚举校验(CoverType / TextPosition / BGMSource) - ai_tasks: run_ai_recommend / run_generate_cover stub 返回结构 -- edit_plans API: POST /{plan_id}/ai-recommend 正常/404/400 -- edit_plans API: POST /{plan_id}/generate-cover 正常/404 -- edit_plans API: create_plan config 标准化 -- edit_plans API: update_plan config 标准化 -- edit_templates API: create_template / update_template config 标准化 """ from __future__ import annotations @@ -17,7 +12,6 @@ import os import sys from pathlib import Path from typing import Any -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") @@ -271,270 +265,3 @@ class TestAIRunTasks: cover_type="upload", ) assert result["type"] == "upload" - - -# --------------------------------------------------------------------------- -# API 端点测试 — AI 推荐 & 封面生成 -# --------------------------------------------------------------------------- - - -class StubEditPlanRepository: - """内存中的 EditPlan 仓储 stub(支持 clips)""" - - def __init__(self): - self._plans: dict[str, Any] = {} - self._clips: dict[str, list] = {} # plan_id → [clip] - self._counter = 0 - - def _next_id(self) -> str: - self._counter += 1 - return f"plan-{self._counter:03d}" - - def get(self, plan_id: str): - return self._plans.get(plan_id) - - def create(self, plan): - self._plans[plan.id] = plan - return plan - - def update(self, plan): - if plan.id not in self._plans: - raise ValueError(f"EditPlan {plan.id} not found") - self._plans[plan.id] = plan - return plan - - def delete(self, plan_id: str): - if plan_id not in self._plans: - return False - del self._plans[plan_id] - return True - - def list_all(self, *, status=None, skip=0, limit=50): - items = list(self._plans.values()) - if status: - items = [p for p in items if p.status == status] - return items[skip : skip + limit] - - def count(self, *, template_id=None, status=None): - return len(list(self._plans.values())) - - def delete_by_plan(self, plan_id: str): - self._clips.pop(plan_id, None) - - -def _make_auth_user(): - from app.auth import AuthenticatedUser - - from packages.domain.entities import User - - user = User(id="user-001", email="test@example.com", display_name="测试用户") - return AuthenticatedUser(user=user) - - -def _create_ai_test_app(): - """创建带 stub 注入的测试 FastAPI 应用(支持 AI 端点)""" - import app.services.edit_plan_service as service_module - from app.api.routes import edit_plans as edit_plans_module - from app.api.routes.edit_plans import router - - stub_repo = StubEditPlanRepository() - - # Mock service methods that interact with DB - original_plan_repo = service_module.SQLAlchemyEditPlanRepository - original_clip_repo = service_module.SQLAlchemyEditPlanClipRepository - original_gen_repo = service_module.SQLAlchemyGenerationTaskRepository - - service_module.SQLAlchemyEditPlanRepository = lambda db: stub_repo - service_module.SQLAlchemyEditPlanClipRepository = lambda db: stub_repo - service_module.SQLAlchemyGenerationTaskRepository = lambda db: stub_repo - - app = FastAPI() - app.include_router(router, prefix="/api/v1/edit-plans") - app.dependency_overrides[edit_plans_module.get_current_user] = _make_auth_user - app.dependency_overrides[edit_plans_module.get_db_session] = lambda: MagicMock() - - def cleanup(): - service_module.SQLAlchemyEditPlanRepository = original_plan_repo - service_module.SQLAlchemyEditPlanClipRepository = original_clip_repo - service_module.SQLAlchemyGenerationTaskRepository = original_gen_repo - - return app, stub_repo, cleanup - - -from fastapi import FastAPI -from fastapi.testclient import TestClient - -from packages.domain.edit_plan import EditPlan - - -@pytest.fixture -def ai_client(): - app, stub_repo, cleanup = _create_ai_test_app() - yield TestClient(app), stub_repo - cleanup() - - -class TestAIRecommendEndpoint: - def test_ai_recommend_success(self, ai_client): - c, repo = ai_client - plan = EditPlan.create("tpl-001", "测试计划") - repo.create(plan) - - resp = c.post( - f"/api/v1/edit-plans/{plan.id}/ai-recommend", - json={"asset_ids": ["asset-1", "asset-2"]}, - ) - assert resp.status_code == 200 - data = resp.json() - assert data["plan_id"] == plan.id - assert "clips" in data - assert len(data["clips"]) >= 2 - assert "config" in data - assert data["total_duration"] > 0 - assert "confidence" in data - - def test_ai_recommend_not_found(self, ai_client): - c, repo = ai_client - resp = c.post( - "/api/v1/edit-plans/nonexistent/ai-recommend", - json={"asset_ids": ["asset-1"]}, - ) - assert resp.status_code == 404 - - def test_ai_recommend_rejects_rendering_status(self, ai_client): - c, repo = ai_client - plan = EditPlan.create("tpl-001", "渲染中计划") - plan.start_editing() - plan.start_rendering() - repo.create(plan) - - resp = c.post( - f"/api/v1/edit-plans/{plan.id}/ai-recommend", - json={"asset_ids": ["asset-1"]}, - ) - assert resp.status_code == 400 - assert "当前计划状态" in resp.json()["detail"] or "编辑计划" in resp.json()["detail"] - - def test_ai_recommend_with_custom_params(self, ai_client): - c, repo = ai_client - plan = EditPlan.create("tpl-001", "自定义参数计划") - repo.create(plan) - - resp = c.post( - f"/api/v1/edit-plans/{plan.id}/ai-recommend", - json={ - "asset_ids": ["asset-1"], - "editing_mode": "pip", - "target_duration": 15.0, - }, - ) - assert resp.status_code == 200 - - def test_ai_recommend_invalid_duration(self, ai_client): - c, repo = ai_client - plan = EditPlan.create("tpl-001", "测试计划") - repo.create(plan) - - resp = c.post( - f"/api/v1/edit-plans/{plan.id}/ai-recommend", - json={"asset_ids": ["asset-1"], "target_duration": -5.0}, - ) - assert resp.status_code == 422 - - -class TestGenerateCoverEndpoint: - def test_generate_cover_ai_frame(self, ai_client): - c, repo = ai_client - plan = EditPlan.create("tpl-001", "封面测试计划") - repo.create(plan) - - resp = c.post( - f"/api/v1/edit-plans/{plan.id}/generate-cover", - json={"asset_ids": ["asset-1"], "cover_type": "ai_frame"}, - ) - assert resp.status_code == 200 - data = resp.json() - assert data["plan_id"] == plan.id - assert "cover" in data - assert data["cover"]["type"] == "ai_frame" - - def test_generate_cover_manual(self, ai_client): - c, repo = ai_client - plan = EditPlan.create("tpl-001", "手动封面计划") - repo.create(plan) - - resp = c.post( - f"/api/v1/edit-plans/{plan.id}/generate-cover", - json={"asset_ids": ["asset-1"], "cover_type": "manual", "frame_time": 3.5}, - ) - assert resp.status_code == 200 - data = resp.json() - assert data["cover"]["type"] == "manual" - assert data["cover"]["frame_time"] == 3.5 - - def test_generate_cover_not_found(self, ai_client): - c, repo = ai_client - resp = c.post( - "/api/v1/edit-plans/nonexistent/generate-cover", - json={"asset_ids": []}, - ) - assert resp.status_code == 404 - - def test_generate_cover_default_type(self, ai_client): - c, repo = ai_client - plan = EditPlan.create("tpl-001", "默认封面计划") - repo.create(plan) - - # 不传 cover_type,默认 ai_frame - resp = c.post( - f"/api/v1/edit-plans/{plan.id}/generate-cover", - json={"asset_ids": ["asset-1"]}, - ) - assert resp.status_code == 200 - assert resp.json()["cover"]["type"] == "ai_frame" - - -# --------------------------------------------------------------------------- -# Config 标准化集成测试(create/update plan & template) -# --------------------------------------------------------------------------- - - -class TestConfigNormalizationInAPI: - """验证 create/update 端点自动标准化 config""" - - def test_create_plan_normalizes_config(self, ai_client): - c, repo = ai_client - resp = c.post( - "/api/v1/edit-plans", - json={ - "template_id": "tpl-001", - "name": "标准化测试", - "config": {"title": {"text": "自定义标题"}}, - }, - ) - assert resp.status_code == 201 - config = resp.json()["config"] - # 传入的 title.text 被保留 - assert config["title"]["text"] == "自定义标题" - # 未传入的 title 字段填充默认值 - assert config["title"]["font"] == "思源黑体" - # cover/bgm/subtitle 全部填充默认值 - assert config["cover"]["type"] == "ai_frame" - assert config["bgm"]["volume"] == 0.3 - assert config["subtitle"]["position"] == "bottom" - - def test_update_plan_normalizes_config(self, ai_client): - c, repo = ai_client - plan = EditPlan.create("tpl-001", "更新标准化测试") - repo.create(plan) - - resp = c.put( - f"/api/v1/edit-plans/{plan.id}", - json={"config": {"bgm": {"volume": 0.9}}}, - ) - assert resp.status_code == 200 - config = resp.json()["config"] - assert config["bgm"]["volume"] == 0.9 - assert config["bgm"]["source"] == "library" - assert config["cover"]["type"] == "ai_frame" - assert config["title"]["enabled"] is True diff --git a/tests/unit/test_edit_plan_adjustments_api.py b/tests/unit/test_edit_plan_adjustments_api.py deleted file mode 100644 index 171d589c1..000000000 --- a/tests/unit/test_edit_plan_adjustments_api.py +++ /dev/null @@ -1,491 +0,0 @@ -""" -片段调整 API 单元测试 - -覆盖: -- PUT /clips/{clip_id}/speed - 调速 -- PUT /clips/{clip_id}/volume - 音量调节 -- PUT /clips/{clip_id}/trim - 裁剪 -- PUT /clips/{clip_id}/adjustments - 统一调整 -- POST /{plan_id}/clips/batch-speed - 批量调速 -""" - -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 - -# --------------------------------------------------------------------------- -# Stub Repository -# --------------------------------------------------------------------------- - - -class StubEditPlanRepository: - def __init__(self, plans: dict[str, EditPlan] | None = None): - self._plans = plans or {} - - 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 list_all(self, *, status=None, skip=0, limit=50): - return list(self._plans.values())[skip : skip + limit] - - def list_by_template(self, template_id, *, status=None, skip=0, limit=50): - return [p for p in self._plans.values() if p.template_id == template_id][skip : skip + limit] - - def delete(self, plan_id: str) -> bool: - return self._plans.pop(plan_id, None) is not None - - def count(self, *, status=None, template_id=None): - return len(self._plans) - - -class StubEditPlanClipRepository: - def __init__(self, clips: dict[str, EditPlanClip] | None = None): - self._clips = clips or {} - - 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] - items.sort(key=lambda c: c.order) - return items[skip : skip + limit] - - def count(self, plan_id, *, status=None): - return len([c for c in self._clips.values() if c.plan_id == plan_id]) - - def get(self, clip_id: str) -> Optional[EditPlanClip]: - return self._clips.get(clip_id) - - def create(self, clip: EditPlanClip) -> EditPlanClip: - 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: - return self._clips.pop(clip_id, None) is not None - - def delete_by_plan(self, plan_id: str) -> int: - before = len(self._clips) - self._clips = {k: v for k, v in self._clips.items() if v.plan_id != plan_id} - return before - len(self._clips) - - -# --------------------------------------------------------------------------- -# 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, duration=10.0, speed=1.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=duration, - transition_effect="cut", - transition_duration=0.0, - playback_speed=speed, - 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.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, duration=10.0), - "clip-002": _make_clip("clip-002", order=1, duration=15.0), - "clip-003": _make_clip("clip-003", order=2, duration=20.0), - } - 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") - - def _mock_auth(): - mock = MagicMock() - mock.user.id = "user-001" - return 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() - - import app.api.routes.edit_plans_adjustments as adj_module - - app.dependency_overrides[adj_module.get_current_user] = _mock_auth - app.dependency_overrides[adj_module.get_db_session] = lambda: MagicMock() - app.dependency_overrides[adj_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 adj_client(): - app, plan_repo, clip_repo, cleanup = _create_test_app() - yield TestClient(app), plan_repo, clip_repo - cleanup() - - -# --------------------------------------------------------------------------- -# 调速测试 -# --------------------------------------------------------------------------- - - -class TestAdjustSpeed: - def test_speed_up(self, adj_client): - c, _, clip_repo = adj_client - resp = c.put( - "/api/v1/edit-plans/clips/clip-001/speed", - json={"speed": 2.0}, - ) - assert resp.status_code == 200 - data = resp.json() - assert data["speed"] == 2.0 - assert data["clip_id"] == "clip-001" - - clip = clip_repo.get("clip-001") - assert clip.playback_speed == 2.0 - - def test_slow_down(self, adj_client): - c, _, clip_repo = adj_client - resp = c.put( - "/api/v1/edit-plans/clips/clip-001/speed", - json={"speed": 0.5}, - ) - assert resp.status_code == 200 - data = resp.json() - assert data["speed"] == 0.5 - - def test_speed_clip_not_found(self, adj_client): - c, _, _ = adj_client - resp = c.put( - "/api/v1/edit-plans/clips/clip-nonexist/speed", - json={"speed": 1.5}, - ) - assert resp.status_code == 404 - - def test_speed_out_of_range_low(self, adj_client): - c, _, _ = adj_client - resp = c.put( - "/api/v1/edit-plans/clips/clip-001/speed", - json={"speed": 0.1}, - ) - assert resp.status_code == 422 - - def test_speed_out_of_range_high(self, adj_client): - c, _, _ = adj_client - resp = c.put( - "/api/v1/edit-plans/clips/clip-001/speed", - json={"speed": 5.0}, - ) - assert resp.status_code == 422 - - def test_speed_default_value(self, adj_client): - c, _, clip_repo = adj_client - # 验证默认 speed - clip = clip_repo.get("clip-001") - assert clip.playback_speed == 1.0 - - -# --------------------------------------------------------------------------- -# 音量调节测试 -# --------------------------------------------------------------------------- - - -class TestAdjustVolume: - def test_set_volume(self, adj_client): - c, _, clip_repo = adj_client - resp = c.put( - "/api/v1/edit-plans/clips/clip-001/volume", - json={"volume": 0.5}, - ) - assert resp.status_code == 200 - data = resp.json() - assert data["volume"] == 0.5 - - clip = clip_repo.get("clip-001") - assert clip.config["volume"] == 0.5 - - def test_mute(self, adj_client): - c, _, clip_repo = adj_client - resp = c.put( - "/api/v1/edit-plans/clips/clip-001/volume", - json={"volume": 0.0}, - ) - assert resp.status_code == 200 - data = resp.json() - assert data["volume"] == 0.0 - - def test_boost_volume(self, adj_client): - c, _, clip_repo = adj_client - resp = c.put( - "/api/v1/edit-plans/clips/clip-001/volume", - json={"volume": 1.5}, - ) - assert resp.status_code == 200 - assert resp.json()["volume"] == 1.5 - - def test_volume_out_of_range(self, adj_client): - c, _, _ = adj_client - resp = c.put( - "/api/v1/edit-plans/clips/clip-001/volume", - json={"volume": 3.0}, - ) - assert resp.status_code == 422 - - def test_volume_clip_not_found(self, adj_client): - c, _, _ = adj_client - resp = c.put( - "/api/v1/edit-plans/clips/clip-nonexist/volume", - json={"volume": 1.0}, - ) - assert resp.status_code == 404 - - def test_default_volume(self, adj_client): - c, _, _ = adj_client - resp = c.put( - "/api/v1/edit-plans/clips/clip-001/speed", - json={"speed": 1.0}, - ) - data = resp.json() - # 默认音量应该是 1.0 - assert data["volume"] == 1.0 - - -# --------------------------------------------------------------------------- -# 裁剪测试 -# --------------------------------------------------------------------------- - - -class TestAdjustTrim: - def test_trim_start(self, adj_client): - c, _, clip_repo = adj_client - resp = c.put( - "/api/v1/edit-plans/clips/clip-001/trim", - json={"trim_start": 2.0, "trim_end": 0.0}, - ) - assert resp.status_code == 200 - data = resp.json() - assert data["trim_start"] == 2.0 - assert data["trim_end"] == 0.0 - - clip = clip_repo.get("clip-001") - assert clip.config["trim_start"] == 2.0 - - def test_trim_both_ends(self, adj_client): - c, _, clip_repo = adj_client - resp = c.put( - "/api/v1/edit-plans/clips/clip-001/trim", - json={"trim_start": 1.5, "trim_end": 2.5}, - ) - assert resp.status_code == 200 - data = resp.json() - assert data["trim_start"] == 1.5 - assert data["trim_end"] == 2.5 - - def test_trim_exceeds_duration(self, adj_client): - c, _, _ = adj_client - # 片段时长 10 秒,裁剪 8+3 = 11 > 10 - resp = c.put( - "/api/v1/edit-plans/clips/clip-001/trim", - json={"trim_start": 8.0, "trim_end": 3.0}, - ) - assert resp.status_code == 400 - assert "不能大于等于片段总时长" in resp.json()["detail"] - - def test_trim_clip_not_found(self, adj_client): - c, _, _ = adj_client - resp = c.put( - "/api/v1/edit-plans/clips/clip-nonexist/trim", - json={"trim_start": 1.0}, - ) - assert resp.status_code == 404 - - def test_default_trim_zero(self, adj_client): - c, _, _ = adj_client - resp = c.put( - "/api/v1/edit-plans/clips/clip-001/speed", - json={"speed": 1.0}, - ) - data = resp.json() - assert data["trim_start"] == 0.0 - assert data["trim_end"] == 0.0 - - -# --------------------------------------------------------------------------- -# 统一调整测试 -# --------------------------------------------------------------------------- - - -class TestAdjustAll: - def test_adjust_speed_and_volume(self, adj_client): - c, _, clip_repo = adj_client - resp = c.put( - "/api/v1/edit-plans/clips/clip-001/adjustments", - json={"speed": 1.5, "volume": 0.8}, - ) - assert resp.status_code == 200 - data = resp.json() - assert data["speed"] == 1.5 - assert data["volume"] == 0.8 - - clip = clip_repo.get("clip-001") - assert clip.playback_speed == 1.5 - assert clip.config["volume"] == 0.8 - - def test_adjust_all_four(self, adj_client): - c, _, clip_repo = adj_client - resp = c.put( - "/api/v1/edit-plans/clips/clip-001/adjustments", - json={"speed": 2.0, "volume": 0.5, "trim_start": 1.0, "trim_end": 1.0}, - ) - assert resp.status_code == 200 - data = resp.json() - assert data["speed"] == 2.0 - assert data["volume"] == 0.5 - assert data["trim_start"] == 1.0 - assert data["trim_end"] == 1.0 - - clip = clip_repo.get("clip-001") - assert clip.playback_speed == 2.0 - assert clip.config["volume"] == 0.5 - assert clip.config["trim_start"] == 1.0 - assert clip.config["trim_end"] == 1.0 - - def test_adjust_empty_body(self, adj_client): - c, _, _ = adj_client - resp = c.put( - "/api/v1/edit-plans/clips/clip-001/adjustments", - json={}, - ) - assert resp.status_code == 200 - data = resp.json() - # 保持默认值 - assert data["speed"] == 1.0 - assert data["volume"] == 1.0 - - def test_adjust_trim_exceeds(self, adj_client): - c, _, _ = adj_client - resp = c.put( - "/api/v1/edit-plans/clips/clip-001/adjustments", - json={"trim_start": 9.0, "trim_end": 2.0}, - ) - assert resp.status_code == 400 - - def test_adjust_clip_not_found(self, adj_client): - c, _, _ = adj_client - resp = c.put( - "/api/v1/edit-plans/clips/clip-nonexist/adjustments", - json={"speed": 1.5}, - ) - assert resp.status_code == 404 - - -# --------------------------------------------------------------------------- -# 批量调速测试 -# --------------------------------------------------------------------------- - - -class TestBatchSpeed: - def test_batch_speed_all(self, adj_client): - c, _, clip_repo = adj_client - resp = c.post( - "/api/v1/edit-plans/plan-001/clips/batch-speed", - json={"speed": 1.5}, - ) - assert resp.status_code == 200 - data = resp.json() - assert data["updated_count"] == 3 - assert data["plan_id"] == "plan-001" - - for cid in ["clip-001", "clip-002", "clip-003"]: - clip = clip_repo.get(cid) - assert clip.playback_speed == 1.5 - - def test_batch_speed_plan_not_found(self, adj_client): - c, _, _ = adj_client - resp = c.post( - "/api/v1/edit-plans/plan-nonexist/clips/batch-speed", - json={"speed": 2.0}, - ) - assert resp.status_code == 404 - - def test_batch_speed_invalid(self, adj_client): - c, _, _ = adj_client - resp = c.post( - "/api/v1/edit-plans/plan-001/clips/batch-speed", - json={"speed": 10.0}, - ) - assert resp.status_code == 422 diff --git a/tests/unit/test_edit_plan_cover_api.py b/tests/unit/test_edit_plan_cover_api.py deleted file mode 100644 index a38d1de01..000000000 --- a/tests/unit/test_edit_plan_cover_api.py +++ /dev/null @@ -1,559 +0,0 @@ -""" -封面管理 API 单元测试 - -覆盖: -- GET /{plan_id}/cover - 获取封面配置 -- PUT /{plan_id}/cover - 更新封面配置 -- POST /{plan_id}/cover/extract - 从片段抽帧 -- POST /{plan_id}/cover/smart - 智能选帧 -""" - -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, patch - -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 - -# --------------------------------------------------------------------------- -# Stub Repository -# --------------------------------------------------------------------------- - - -class StubEditPlanRepository: - def __init__(self, plans: dict[str, EditPlan] | None = None): - self._plans = plans or {} - self._counter = 100 - - def _next_id(self) -> str: - self._counter += 1 - return f"plan-{self._counter:03d}" - - 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: - if not plan.id: - plan.id = self._next_id() - 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 = 200 - - def _next_id(self) -> str: - self._counter += 1 - return f"clip-{self._counter:03d}" - - 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) - - -class StubAssetRepository: - def __init__(self, assets: dict | None = None): - self._assets = assets or {} - - def get(self, asset_id: str): - return self._assets.get(asset_id) - - -class StubStorageService: - def __init__(self): - self.uploaded = {} - self.downloaded = {} - - def upload_file(self, file_or_path, storage_key, content_type="application/octet-stream"): - self.uploaded[storage_key] = file_or_path - return f"https://oss.example.com/{storage_key}" - - def get_url(self, storage_key: str) -> str: - return f"https://oss.example.com/{storage_key}" - - def download_file(self, storage_key: str, local_path: str): - self.downloaded[storage_key] = local_path - # 创建一个假文件(空文件也可以,因为抽帧会被 mock 掉) - Path(local_path).parent.mkdir(parents=True, exist_ok=True) - with open(local_path, "wb") as f: - f.write(b"fake video data for testing") - - -# --------------------------------------------------------------------------- -# Test Fixtures -# --------------------------------------------------------------------------- - - -def _make_sample_plan(plan_id="plan-001", config=None): - if config is None: - config = normalize_plan_config({}) - return EditPlan( - id=plan_id, - template_id="tpl-001", - name="测试计划", - status=EditPlanStatus.EDITING, - total_duration=30.0, - config=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_sample_clip(clip_id="clip-001", plan_id="plan-001", asset_id="asset-001", clip_type="video"): - return EditPlanClip( - id=clip_id, - plan_id=plan_id, - clip_type=clip_type, - order=0, - asset_id=asset_id, - text_content="", - start_time=0.0, - duration=10.0, - transition_effect="none", - transition_duration=0.0, - 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_cover as cover_module - import app.services.edit_plan_service as service_module - from app.api.routes.edit_plans import router - - # 创建 stub - plan = _make_sample_plan() - clip = _make_sample_clip() - stub_plan_repo = StubEditPlanRepository({plan.id: plan}) - stub_clip_repo = StubEditPlanClipRepository({clip.id: clip}) - - # 替换服务模块中的 Repository 类 - 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 - - # 覆盖依赖 - app.dependency_overrides[cover_module.get_current_user] = _mock_auth - app.dependency_overrides[cover_module.get_db_session] = lambda: MagicMock() - app.dependency_overrides[cover_module.get_project_repository] = lambda: MagicMock() - - # Mock storage 和 asset repo - stub_storage = StubStorageService() - stub_asset_repo = StubAssetRepository( - { - "asset-001": MagicMock( - storage_key="videos/test.mp4", - mime_type="video/mp4", - ), - "asset-img": MagicMock( - storage_key="images/test.jpg", - mime_type="image/jpeg", - ), - } - ) - - app.dependency_overrides[cover_module.get_storage_service] = lambda: stub_storage - app.dependency_overrides[cover_module.get_asset_repository] = lambda: stub_asset_repo - - # 也需要覆盖 edit_plans 主模块的 auth(用于其他路由) - 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() - - 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, stub_storage, cleanup - - -@pytest.fixture -def cover_client(): - app, plan_repo, clip_repo, storage, cleanup = _create_test_app() - yield TestClient(app), plan_repo, clip_repo, storage - cleanup() - - -# --------------------------------------------------------------------------- -# GET /{plan_id}/cover 测试 -# --------------------------------------------------------------------------- - - -class TestGetCover: - def test_get_default_cover(self, cover_client): - c, _, _, _ = cover_client - resp = c.get("/api/v1/edit-plans/plan-001/cover") - assert resp.status_code == 200 - data = resp.json() - assert data["type"] == "ai_frame" - assert data["image_url"] == "" - assert data["frame_time"] is None - - def test_get_cover_not_found(self, cover_client): - c, _, _, _ = cover_client - resp = c.get("/api/v1/edit-plans/plan-nonexist/cover") - assert resp.status_code == 404 - - def test_get_cover_with_custom_config(self, cover_client): - c, plan_repo, _, _ = cover_client - # 更新 plan 的 cover 配置 - plan = plan_repo.get("plan-001") - new_config = dict(plan.config) - new_config["cover"] = {"type": "manual", "image_url": "https://example.com/cover.jpg", "frame_time": 5.5} - plan.config = new_config - plan_repo.update(plan) - - resp = c.get("/api/v1/edit-plans/plan-001/cover") - assert resp.status_code == 200 - data = resp.json() - assert data["type"] == "manual" - assert data["image_url"] == "https://example.com/cover.jpg" - assert data["frame_time"] == 5.5 - - -# --------------------------------------------------------------------------- -# PUT /{plan_id}/cover 测试 -# --------------------------------------------------------------------------- - - -class TestUpdateCover: - def test_update_cover_type_and_url(self, cover_client): - c, plan_repo, _, _ = cover_client - resp = c.put( - "/api/v1/edit-plans/plan-001/cover", - json={"type": "upload", "image_url": "https://example.com/uploaded.jpg"}, - ) - assert resp.status_code == 200 - data = resp.json() - assert data["type"] == "upload" - assert data["image_url"] == "https://example.com/uploaded.jpg" - - # 验证存储 - plan = plan_repo.get("plan-001") - assert plan.config["cover"]["type"] == "upload" - assert plan.config["cover"]["image_url"] == "https://example.com/uploaded.jpg" - - def test_update_cover_frame_time(self, cover_client): - c, plan_repo, _, _ = cover_client - resp = c.put( - "/api/v1/edit-plans/plan-001/cover", - json={"type": "manual", "frame_time": 3.14}, - ) - assert resp.status_code == 200 - data = resp.json() - assert data["type"] == "manual" - assert data["frame_time"] == 3.14 - - plan = plan_repo.get("plan-001") - assert plan.config["cover"]["frame_time"] == 3.14 - - def test_update_cover_invalid_type(self, cover_client): - c, _, _, _ = cover_client - resp = c.put( - "/api/v1/edit-plans/plan-001/cover", - json={"type": "invalid_type"}, - ) - assert resp.status_code == 400 - - def test_update_cover_not_found(self, cover_client): - c, _, _, _ = cover_client - resp = c.put( - "/api/v1/edit-plans/plan-nonexist/cover", - json={"type": "upload", "image_url": "test.jpg"}, - ) - assert resp.status_code == 404 - - def test_update_cover_partial(self, cover_client): - """只更新 image_url,type 保持不变""" - c, plan_repo, _, _ = cover_client - # 先设置一个类型 - c.put("/api/v1/edit-plans/plan-001/cover", json={"type": "manual", "frame_time": 2.0}) - - # 只更新 image_url - resp = c.put( - "/api/v1/edit-plans/plan-001/cover", - json={"image_url": "https://example.com/new.jpg"}, - ) - assert resp.status_code == 200 - data = resp.json() - assert data["type"] == "manual" # 保持不变 - assert data["image_url"] == "https://example.com/new.jpg" - assert data["frame_time"] == 2.0 # 保持不变 - - -# --------------------------------------------------------------------------- -# POST /{plan_id}/cover/extract 测试 -# --------------------------------------------------------------------------- - - -class TestExtractCover: - def test_extract_success(self, cover_client): - c, plan_repo, _, _ = cover_client - with patch("app.services.cover_service.CoverService._extract_frame") as mock_extract: - # mock ffmpeg 抽帧,直接创建输出文件 - def fake_extract(video_path, output_path, **kwargs): - output_path.parent.mkdir(parents=True, exist_ok=True) - output_path.write_bytes(b"fake jpeg data") - - mock_extract.side_effect = fake_extract - - resp = c.post( - "/api/v1/edit-plans/plan-001/cover/extract", - json={"clip_id": "clip-001", "frame_time": 2.5}, - ) - - assert resp.status_code == 200 - data = resp.json() - assert data["type"] == "manual" - assert data["frame_time"] == 2.5 - assert data["image_url"].startswith("https://oss.example.com/covers/") - - # 验证 plan.config 已更新 - plan = plan_repo.get("plan-001") - assert plan.config["cover"]["type"] == "manual" - assert plan.config["cover"]["frame_time"] == 2.5 - - def test_extract_clip_not_found(self, cover_client): - c, _, _, _ = cover_client - resp = c.post( - "/api/v1/edit-plans/plan-001/cover/extract", - json={"clip_id": "clip-nonexist", "frame_time": 1.0}, - ) - assert resp.status_code == 404 - - def test_extract_plan_not_found(self, cover_client): - c, _, _, _ = cover_client - resp = c.post( - "/api/v1/edit-plans/plan-nonexist/cover/extract", - json={"clip_id": "clip-001", "frame_time": 1.0}, - ) - assert resp.status_code == 404 - - def test_extract_clip_no_asset(self, cover_client): - c, _, clip_repo, _ = cover_client - # 创建一个没有 asset 的片段 - empty_clip = _make_sample_clip(clip_id="clip-empty", asset_id="") - clip_repo.create(empty_clip) - - resp = c.post( - "/api/v1/edit-plans/plan-001/cover/extract", - json={"clip_id": "clip-empty", "frame_time": 1.0}, - ) - assert resp.status_code == 400 - assert "没有关联素材" in resp.json()["detail"] - - def test_extract_clip_not_in_plan(self, cover_client): - c, _, clip_repo, _ = cover_client - # 创建属于另一个 plan 的片段 - other_clip = _make_sample_clip(clip_id="clip-other", plan_id="plan-other") - clip_repo.create(other_clip) - - resp = c.post( - "/api/v1/edit-plans/plan-001/cover/extract", - json={"clip_id": "clip-other", "frame_time": 1.0}, - ) - assert resp.status_code == 400 - assert "不属于该剪辑计划" in resp.json()["detail"] - - def test_extract_negative_frame_time(self, cover_client): - c, _, _, _ = cover_client - resp = c.post( - "/api/v1/edit-plans/plan-001/cover/extract", - json={"clip_id": "clip-001", "frame_time": -1.0}, - ) - assert resp.status_code == 422 # pydantic 校验失败 - - -# --------------------------------------------------------------------------- -# POST /{plan_id}/cover/smart 测试 -# --------------------------------------------------------------------------- - - -class TestSmartCover: - def test_smart_cover_with_clip_id(self, cover_client): - c, plan_repo, _, _ = cover_client - with patch("app.services.cover_service.CoverService._extract_frame") as mock_extract: - - def fake_extract(video_path, output_path, **kwargs): - output_path.parent.mkdir(parents=True, exist_ok=True) - output_path.write_bytes(b"fake jpeg data") - - mock_extract.side_effect = fake_extract - - resp = c.post( - "/api/v1/edit-plans/plan-001/cover/smart", - json={"clip_id": "clip-001"}, - ) - - assert resp.status_code == 200 - data = resp.json() - assert data["type"] == "ai_frame" - assert data["image_url"].startswith("https://oss.example.com/covers/") - - plan = plan_repo.get("plan-001") - assert plan.config["cover"]["type"] == "ai_frame" - - def test_smart_cover_auto_pick_first_video(self, cover_client): - c, plan_repo, clip_repo, _ = cover_client - # 添加多个片段,第一个视频应该被选中 - clip2 = _make_sample_clip(clip_id="clip-002", clip_type="audio", asset_id="asset-audio") - clip2.order = 1 - clip_repo.create(clip2) - - with patch("app.services.cover_service.CoverService._extract_frame") as mock_extract: - - def fake_extract(video_path, output_path, **kwargs): - output_path.parent.mkdir(parents=True, exist_ok=True) - output_path.write_bytes(b"fake jpeg data") - - mock_extract.side_effect = fake_extract - - resp = c.post( - "/api/v1/edit-plans/plan-001/cover/smart", - json={}, - ) - - assert resp.status_code == 200 - data = resp.json() - assert data["type"] == "ai_frame" - - def test_smart_cover_clip_not_found(self, cover_client): - c, _, _, _ = cover_client - resp = c.post( - "/api/v1/edit-plans/plan-001/cover/smart", - json={"clip_id": "clip-nonexist"}, - ) - assert resp.status_code == 404 - - def test_smart_cover_no_video_clips(self, cover_client): - c, _, clip_repo, _ = cover_client - # 删除原有片段,添加纯音频片段 - clip_repo.delete("clip-001") - audio_clip = _make_sample_clip(clip_id="clip-audio", clip_type="audio", asset_id="asset-001") - clip_repo.create(audio_clip) - - resp = c.post( - "/api/v1/edit-plans/plan-001/cover/smart", - json={}, - ) - assert resp.status_code == 400 - assert "没有找到可用的视频片段" in resp.json()["detail"] - - def test_smart_cover_plan_not_found(self, cover_client): - c, _, _, _ = cover_client - resp = c.post( - "/api/v1/edit-plans/plan-nonexist/cover/smart", - json={}, - ) - assert resp.status_code == 404 diff --git a/tests/unit/test_edit_plan_export_api.py b/tests/unit/test_edit_plan_export_api.py deleted file mode 100644 index 22daf910e..000000000 --- a/tests/unit/test_edit_plan_export_api.py +++ /dev/null @@ -1,350 +0,0 @@ -""" -导出设置 API 单元测试 - -覆盖: -- GET /export-presets - 导出预设列表 -- GET /{plan_id}/export - 获取导出配置 -- PUT /{plan_id}/export - 更新导出配置 -""" - -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 - -# --------------------------------------------------------------------------- -# Stub Repository -# --------------------------------------------------------------------------- - - -class StubEditPlanRepository: - def __init__(self, plans: dict[str, EditPlan] | None = None): - self._plans = plans or {} - - 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 list_all(self, *, status=None, skip=0, limit=50): - return list(self._plans.values())[skip : skip + limit] - - def list_by_template(self, template_id, *, status=None, skip=0, limit=50): - return [p for p in self._plans.values() if p.template_id == template_id][skip : skip + limit] - - def delete(self, plan_id: str) -> bool: - return self._plans.pop(plan_id, None) is not None - - def count(self, *, status=None, template_id=None): - return len(self._plans) - - -class StubEditPlanClipRepository: - def list_by_plan(self, plan_id, *, status=None, skip=0, limit=100): - return [] - - def count(self, plan_id, *, status=None): - return 0 - - def get(self, clip_id: str): - return None - - def create(self, clip): - return clip - - def update(self, clip): - return clip - - def delete(self, clip_id: str) -> bool: - return False - - def delete_by_plan(self, plan_id: str) -> int: - return 0 - - -# --------------------------------------------------------------------------- -# 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 _create_test_app(): - import app.services.edit_plan_service as service_module - from app.api.routes.edit_plans import router - - plan = _make_sample_plan() - stub_plan_repo = StubEditPlanRepository({plan.id: plan}) - stub_clip_repo = StubEditPlanClipRepository() - - 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") - - def _mock_auth(): - mock = MagicMock() - mock.user.id = "user-001" - return 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() - - import app.api.routes.edit_plans_export as export_module - - app.dependency_overrides[export_module.get_current_user] = _mock_auth - app.dependency_overrides[export_module.get_db_session] = lambda: MagicMock() - app.dependency_overrides[export_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, cleanup - - -@pytest.fixture -def export_client(): - app, plan_repo, cleanup = _create_test_app() - yield TestClient(app), plan_repo - cleanup() - - -# --------------------------------------------------------------------------- -# Export Presets 测试 -# --------------------------------------------------------------------------- - - -class TestExportPresets: - def test_list_all_presets(self, export_client): - c, _ = export_client - resp = c.get("/api/v1/edit-plans/export-presets") - assert resp.status_code == 200 - data = resp.json() - assert data["total"] >= 5 - assert len(data["items"]) == data["total"] - first = data["items"][0] - assert "id" in first - assert "name" in first - assert "resolution" in first - assert "fps" in first - assert "video_bitrate" in first - assert "format" in first - assert "description" in first - assert "size_hint" in first - - def test_preset_has_valid_resolution(self, export_client): - c, _ = export_client - resp = c.get("/api/v1/edit-plans/export-presets") - data = resp.json() - for item in data["items"]: - assert "x" in item["resolution"] - assert item["fps"] >= 15 - assert item["fps"] <= 60 - assert item["format"] in ("mp4", "mov") - - -# --------------------------------------------------------------------------- -# GET /{plan_id}/export 测试 -# --------------------------------------------------------------------------- - - -class TestGetExportConfig: - def test_default_export_config(self, export_client): - c, _ = export_client - resp = c.get("/api/v1/edit-plans/plan-001/export") - assert resp.status_code == 200 - data = resp.json() - assert data["resolution"] == "1080x1920" - assert data["fps"] == 30 - assert data["video_bitrate"] == 8000 - assert data["audio_bitrate"] == 128 - assert data["format"] == "mp4" - assert data["quality_preset"] == "balanced" - assert data["watermark_enabled"] is False - assert data["watermark_text"] == "" - - def test_export_not_found(self, export_client): - c, _ = export_client - resp = c.get("/api/v1/edit-plans/plan-nonexist/export") - assert resp.status_code == 404 - - -# --------------------------------------------------------------------------- -# PUT /{plan_id}/export 测试 -# --------------------------------------------------------------------------- - - -class TestUpdateExportConfig: - def test_update_resolution_and_fps(self, export_client): - c, plan_repo = export_client - resp = c.put( - "/api/v1/edit-plans/plan-001/export", - json={"resolution": "720x1280", "fps": 60}, - ) - assert resp.status_code == 200 - data = resp.json() - assert data["resolution"] == "720x1280" - assert data["fps"] == 60 - - plan = plan_repo.get("plan-001") - assert plan.config["export"]["resolution"] == "720x1280" - assert plan.config["export"]["fps"] == 60 - - def test_update_bitrate(self, export_client): - c, plan_repo = export_client - resp = c.put( - "/api/v1/edit-plans/plan-001/export", - json={"video_bitrate": 12000, "audio_bitrate": 192}, - ) - assert resp.status_code == 200 - data = resp.json() - assert data["video_bitrate"] == 12000 - assert data["audio_bitrate"] == 192 - - def test_update_format(self, export_client): - c, _ = export_client - resp = c.put( - "/api/v1/edit-plans/plan-001/export", - json={"format": "mov"}, - ) - assert resp.status_code == 200 - assert resp.json()["format"] == "mov" - - def test_invalid_format(self, export_client): - c, _ = export_client - resp = c.put( - "/api/v1/edit-plans/plan-001/export", - json={"format": "avi"}, - ) - assert resp.status_code == 422 - - def test_update_quality_preset(self, export_client): - c, _ = export_client - resp = c.put( - "/api/v1/edit-plans/plan-001/export", - json={"quality_preset": "best"}, - ) - assert resp.status_code == 200 - assert resp.json()["quality_preset"] == "best" - - def test_invalid_quality_preset(self, export_client): - c, _ = export_client - resp = c.put( - "/api/v1/edit-plans/plan-001/export", - json={"quality_preset": "ultimate"}, - ) - assert resp.status_code == 422 - - def test_update_watermark(self, export_client): - c, plan_repo = export_client - resp = c.put( - "/api/v1/edit-plans/plan-001/export", - json={"watermark_enabled": True, "watermark_text": "我的视频"}, - ) - assert resp.status_code == 200 - data = resp.json() - assert data["watermark_enabled"] is True - assert data["watermark_text"] == "我的视频" - - plan = plan_repo.get("plan-001") - assert plan.config["export"]["watermark_enabled"] is True - assert plan.config["export"]["watermark_text"] == "我的视频" - - def test_invalid_resolution_format(self, export_client): - c, _ = export_client - resp = c.put( - "/api/v1/edit-plans/plan-001/export", - json={"resolution": "1080*1920"}, - ) - assert resp.status_code == 422 - - def test_resolution_too_large(self, export_client): - c, _ = export_client - resp = c.put( - "/api/v1/edit-plans/plan-001/export", - json={"resolution": "8000x8000"}, - ) - assert resp.status_code == 422 - - def test_fps_out_of_range(self, export_client): - c, _ = export_client - resp = c.put( - "/api/v1/edit-plans/plan-001/export", - json={"fps": 120}, - ) - assert resp.status_code == 422 - - def test_export_not_found(self, export_client): - c, _ = export_client - resp = c.put( - "/api/v1/edit-plans/plan-nonexist/export", - json={"fps": 30}, - ) - assert resp.status_code == 404 - - def test_partial_update_preserves_other_fields(self, export_client): - c, _ = export_client - # 先修改一个 - c.put("/api/v1/edit-plans/plan-001/export", json={"resolution": "720x1280"}) - # 再修改另一个 - resp = c.put("/api/v1/edit-plans/plan-001/export", json={"fps": 60}) - data = resp.json() - # 分辨率应该保持 - assert data["resolution"] == "720x1280" - # fps 更新了 - assert data["fps"] == 60 - # 其他默认值不变 - assert data["format"] == "mp4" - assert data["video_bitrate"] == 8000 diff --git a/tests/unit/test_edit_plan_filter_api.py b/tests/unit/test_edit_plan_filter_api.py deleted file mode 100644 index 9eadc59c4..000000000 --- a/tests/unit/test_edit_plan_filter_api.py +++ /dev/null @@ -1,476 +0,0 @@ -""" -滤镜调色 API 单元测试 - -覆盖: -- GET /filter-presets - 滤镜预设列表 -- GET /{plan_id}/filter - 获取滤镜配置 -- PUT /{plan_id}/filter - 更新滤镜配置 -""" - -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.filter_presets import FILTER_PRESET_LIBRARY, build_ffmpeg_filter - -# --------------------------------------------------------------------------- -# 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 list_by_plan(self, plan_id, *, status=None, skip=0, limit=100): - return [] - - def count(self, plan_id, *, status=None): - return 0 - - def get(self, clip_id: str): - return None - - def create(self, clip): - return clip - - def update(self, clip): - return clip - - def delete(self, clip_id: str) -> bool: - return False - - def delete_by_plan(self, plan_id: str) -> int: - return 0 - - -# --------------------------------------------------------------------------- -# Test Fixtures -# --------------------------------------------------------------------------- - - -def _make_sample_plan(plan_id="plan-001", config=None): - if config is None: - config = normalize_plan_config({}) - return EditPlan( - id=plan_id, - template_id="tpl-001", - name="测试计划", - status=EditPlanStatus.EDITING, - total_duration=30.0, - config=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 _create_test_app(): - import app.api.routes.edit_plans_filter as filter_module - import app.services.edit_plan_service as service_module - from app.api.routes.edit_plans import router - - plan = _make_sample_plan() - stub_plan_repo = StubEditPlanRepository({plan.id: plan}) - stub_clip_repo = StubEditPlanClipRepository() - - 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[filter_module.get_current_user] = _mock_auth - app.dependency_overrides[filter_module.get_db_session] = lambda: MagicMock() - app.dependency_overrides[filter_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, cleanup - - -@pytest.fixture -def filter_client(): - app, plan_repo, cleanup = _create_test_app() - yield TestClient(app), plan_repo - cleanup() - - -# --------------------------------------------------------------------------- -# Filter Presets 测试 -# --------------------------------------------------------------------------- - - -class TestFilterPresets: - def test_list_all_presets(self, filter_client): - c, _ = filter_client - resp = c.get("/api/v1/edit-plans/filter-presets") - assert resp.status_code == 200 - data = resp.json() - assert data["total"] == len(FILTER_PRESET_LIBRARY) - assert data["total"] > 10 - assert len(data["items"]) == data["total"] - # 验证字段 - first = data["items"][0] - assert "id" in first - assert "name" in first - assert "category" in first - assert "description" in first - assert "tags" in first - - def test_filter_by_category_basic(self, filter_client): - c, _ = filter_client - resp = c.get("/api/v1/edit-plans/filter-presets?category=basic") - assert resp.status_code == 200 - data = resp.json() - assert data["total"] > 0 - for item in data["items"]: - assert item["category"] == "basic" - - def test_filter_by_category_bw(self, filter_client): - c, _ = filter_client - resp = c.get("/api/v1/edit-plans/filter-presets?category=bw") - assert resp.status_code == 200 - data = resp.json() - assert data["total"] >= 3 - for item in data["items"]: - assert item["category"] == "bw" - - def test_filter_by_keyword(self, filter_client): - c, _ = filter_client - resp = c.get("/api/v1/edit-plans/filter-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_by_keyword_japanese(self, filter_client): - c, _ = filter_client - resp = c.get("/api/v1/edit-plans/filter-presets?keyword=日系") - assert resp.status_code == 200 - data = resp.json() - assert data["total"] >= 1 - assert data["items"][0]["name"] == "日系" - - def test_filter_empty_result(self, filter_client): - c, _ = filter_client - resp = c.get("/api/v1/edit-plans/filter-presets?keyword=不存在的滤镜") - assert resp.status_code == 200 - data = resp.json() - assert data["total"] == 0 - assert data["items"] == [] - - def test_filter_invalid_category(self, filter_client): - c, _ = filter_client - resp = c.get("/api/v1/edit-plans/filter-presets?category=nonexistent") - assert resp.status_code == 200 - data = resp.json() - assert data["total"] == 0 - - -# --------------------------------------------------------------------------- -# GET /{plan_id}/filter 测试 -# --------------------------------------------------------------------------- - - -class TestGetFilter: - def test_get_default_filter(self, filter_client): - c, _ = filter_client - resp = c.get("/api/v1/edit-plans/plan-001/filter") - assert resp.status_code == 200 - data = resp.json() - assert data["enabled"] is False - assert data["preset_id"] == "filter_none" - assert data["intensity"] == 100 - assert data["brightness"] == 0.0 - assert data["contrast"] == 1.0 - assert data["saturation"] == 1.0 - assert data["warmth"] == 0.0 - - def test_get_filter_not_found(self, filter_client): - c, _ = filter_client - resp = c.get("/api/v1/edit-plans/plan-nonexist/filter") - assert resp.status_code == 404 - - def test_get_filter_with_custom_config(self, filter_client): - c, plan_repo = filter_client - plan = plan_repo.get("plan-001") - new_config = dict(plan.config) - new_config["filter"] = { - "enabled": True, - "preset_id": "filter_cinematic", - "intensity": 80, - "brightness": 0.1, - "contrast": 1.2, - "saturation": 0.9, - "warmth": 0.3, - } - plan.config = new_config - plan_repo.update(plan) - - resp = c.get("/api/v1/edit-plans/plan-001/filter") - assert resp.status_code == 200 - data = resp.json() - assert data["enabled"] is True - assert data["preset_id"] == "filter_cinematic" - assert data["intensity"] == 80 - assert data["brightness"] == 0.1 - - -# --------------------------------------------------------------------------- -# PUT /{plan_id}/filter 测试 -# --------------------------------------------------------------------------- - - -class TestUpdateFilter: - def test_enable_filter(self, filter_client): - c, plan_repo = filter_client - resp = c.put( - "/api/v1/edit-plans/plan-001/filter", - json={"enabled": True, "preset_id": "filter_cinematic"}, - ) - assert resp.status_code == 200 - data = resp.json() - assert data["enabled"] is True - assert data["preset_id"] == "filter_cinematic" - - plan = plan_repo.get("plan-001") - assert plan.config["filter"]["enabled"] is True - assert plan.config["filter"]["preset_id"] == "filter_cinematic" - - def test_adjust_intensity(self, filter_client): - c, plan_repo = filter_client - resp = c.put( - "/api/v1/edit-plans/plan-001/filter", - json={"enabled": True, "preset_id": "filter_cinematic", "intensity": 50}, - ) - assert resp.status_code == 200 - data = resp.json() - assert data["intensity"] == 50 - - plan = plan_repo.get("plan-001") - assert plan.config["filter"]["intensity"] == 50 - - def test_invalid_intensity_returns_422(self, filter_client): - c, _ = filter_client - resp = c.put( - "/api/v1/edit-plans/plan-001/filter", - json={"intensity": 150}, - ) - assert resp.status_code == 422 - - def test_invalid_preset_returns_400(self, filter_client): - c, _ = filter_client - resp = c.put( - "/api/v1/edit-plans/plan-001/filter", - json={"preset_id": "nonexistent_filter"}, - ) - assert resp.status_code == 400 - assert "无效的滤镜预设" in resp.json()["detail"] - - def test_filter_not_found(self, filter_client): - c, _ = filter_client - resp = c.put( - "/api/v1/edit-plans/plan-nonexist/filter", - json={"enabled": True}, - ) - assert resp.status_code == 404 - - def test_set_none_preset_disables_filter(self, filter_client): - c, plan_repo = filter_client - # 先启用一个滤镜 - c.put( - "/api/v1/edit-plans/plan-001/filter", - json={"enabled": True, "preset_id": "filter_cinematic"}, - ) - - # 再设为原图 - resp = c.put( - "/api/v1/edit-plans/plan-001/filter", - json={"preset_id": "filter_none"}, - ) - assert resp.status_code == 200 - data = resp.json() - assert data["preset_id"] == "filter_none" - assert data["enabled"] is False # 原图自动关闭 - - def test_partial_update(self, filter_client): - c, plan_repo = filter_client - # 先设置完整配置 - c.put( - "/api/v1/edit-plans/plan-001/filter", - json={ - "enabled": True, - "preset_id": "filter_warm", - "intensity": 70, - "brightness": 0.05, - }, - ) - - # 只修改强度,其他保持不变 - resp = c.put( - "/api/v1/edit-plans/plan-001/filter", - json={"intensity": 90}, - ) - assert resp.status_code == 200 - data = resp.json() - assert data["intensity"] == 90 - assert data["preset_id"] == "filter_warm" # 保持不变 - assert data["enabled"] is True # 保持不变 - assert data["brightness"] == 0.05 # 保持不变 - - def test_custom_adjustments(self, filter_client): - c, _ = filter_client - resp = c.put( - "/api/v1/edit-plans/plan-001/filter", - json={ - "enabled": True, - "preset_id": "filter_cinematic", - "brightness": 0.1, - "contrast": 1.3, - "saturation": 1.2, - "warmth": 0.2, - }, - ) - assert resp.status_code == 200 - data = resp.json() - assert data["brightness"] == 0.1 - assert data["contrast"] == 1.3 - assert data["saturation"] == 1.2 - assert data["warmth"] == 0.2 - - def test_invalid_brightness_returns_422(self, filter_client): - c, _ = filter_client - resp = c.put( - "/api/v1/edit-plans/plan-001/filter", - json={"brightness": 2.0}, - ) - assert resp.status_code == 422 - - -# --------------------------------------------------------------------------- -# FFmpeg 滤镜生成测试 -# --------------------------------------------------------------------------- - - -class TestBuildFFmpegFilter: - def test_no_filter(self): - assert build_ffmpeg_filter("filter_none", 100) == "" - - def test_zero_intensity(self): - assert build_ffmpeg_filter("filter_cinematic", 0) == "" - - def test_invalid_preset(self): - assert build_ffmpeg_filter("nonexistent", 100) == "" - - def test_cinematic_full(self): - result = build_ffmpeg_filter("filter_cinematic", 100) - assert result.startswith("eq=") - assert "contrast=" in result - assert "saturation=" in result - assert "gamma_r=" in result - - def test_cinematic_half(self): - full = build_ffmpeg_filter("filter_cinematic", 100) - half = build_ffmpeg_filter("filter_cinematic", 50) - assert full != half - # 50% 强度的参数应该更接近原值 - assert "eq=" in half - - def test_bw_filter(self): - result = build_ffmpeg_filter("filter_bw", 100) - assert "saturation=0" in result - - def test_warm_filter(self): - result = build_ffmpeg_filter("filter_warm", 100) - assert "gamma_r=" in result - assert "gamma_b=" in result diff --git a/tests/unit/test_edit_plan_generation_api.py b/tests/unit/test_edit_plan_generation_api.py deleted file mode 100755 index add7d042b..000000000 --- a/tests/unit/test_edit_plan_generation_api.py +++ /dev/null @@ -1,754 +0,0 @@ -"""剪辑计划生成 API 单元测试 — Phase 8 任务 2.05. - -覆盖 2 个新端点: - POST /api/v1/edit-plans/{id}/generate — 触发剪辑渲染生成 - GET /api/v1/edit-plans/{id}/generation-status — 查询生成进度 - -使用 FastAPI TestClient + Stub Repository + dependency_overrides. -""" - -from __future__ import annotations - -import os -import sys -from dataclasses import dataclass, field -from typing import Any, Optional -from unittest.mock import MagicMock, patch - -os.environ.setdefault("JWT_SECRET_KEY", "unit-test-secret-key-for-testing") -os.environ.setdefault("DATABASE_URL", "sqlite:///test.db") - -import pytest -from fastapi import FastAPI -from fastapi.testclient import TestClient - -sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "apps", "api")) - -from packages.domain.edit_plan import EditPlan, EditPlanStatus -from packages.domain.edit_plan_clip import EditPlanClip, EditPlanClipStatus - -# ── Stub Repositories ───────────────────────────────────────────────────────── - - -class StubEditPlanRepository: - """内存中模拟 EditPlan 仓储""" - - def __init__(self) -> None: - self._store: dict[str, EditPlan] = {} - - def list_all( - self, - *, - status: Optional[EditPlanStatus] = None, - skip: int = 0, - limit: int = 50, - ) -> list[EditPlan]: - items = list(self._store.values()) - if status: - 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: str, - *, - status: Optional[EditPlanStatus] = None, - skip: int = 0, - limit: int = 50, - ) -> list[EditPlan]: - items = [p for p in self._store.values() if p.template_id == template_id] - if status: - 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._store.get(plan_id) - - def create(self, plan: EditPlan) -> EditPlan: - self._store[plan.id] = plan - return plan - - def update(self, plan: EditPlan) -> EditPlan: - if plan.id not in self._store: - raise ValueError(f"EditPlan {plan.id} not found") - self._store[plan.id] = plan - return plan - - def delete(self, plan_id: str) -> bool: - if plan_id in self._store: - del self._store[plan_id] - return True - return False - - def count(self, *, status: Optional[EditPlanStatus] = None) -> int: - items = list(self._store.values()) - if status: - items = [p for p in items if p.status == status] - return len(items) - - -class StubEditPlanClipRepository: - """内存中模拟 EditPlanClip 仓储""" - - def __init__(self) -> None: - self._store: dict[str, EditPlanClip] = {} - - def list_by_plan( - self, - plan_id: str, - *, - status: Optional[EditPlanClipStatus] = None, - skip: int = 0, - limit: int = 100, - ) -> list[EditPlanClip]: - items = [c for c in self._store.values() if c.plan_id == plan_id] - if status: - items = [c for c in items if c.status == status] - items.sort(key=lambda c: c.order) - return items[skip : skip + limit] - - def get(self, clip_id: str) -> Optional[EditPlanClip]: - return self._store.get(clip_id) - - def create(self, clip: EditPlanClip) -> EditPlanClip: - self._store[clip.id] = clip - return clip - - def update(self, clip: EditPlanClip) -> EditPlanClip: - if clip.id not in self._store: - raise ValueError(f"EditPlanClip {clip.id} not found") - self._store[clip.id] = clip - return clip - - def delete(self, clip_id: str) -> bool: - if clip_id in self._store: - del self._store[clip_id] - return True - return False - - def delete_by_plan(self, plan_id: str) -> int: - to_delete = [c.id for c in self._store.values() if c.plan_id == plan_id] - for cid in to_delete: - del self._store[cid] - return len(to_delete) - - def count( - self, - plan_id: Optional[str] = None, - *, - status: Optional[EditPlanClipStatus] = None, - ) -> int: - items = list(self._store.values()) - if plan_id: - items = [c for c in items if c.plan_id == plan_id] - if status: - items = [c for c in items if c.status == status] - return len(items) - - -class StubGenerationTaskRepository: - """内存中模拟 GenerationTask 仓储""" - - def __init__(self) -> None: - self._store: dict[str, Any] = {} - - def create(self, task: Any) -> Any: - self._store[task.id] = task - return task - - def get(self, task_id: str) -> Optional[Any]: - return self._store.get(task_id) - - def update(self, task: Any) -> Any: - if task.id not in self._store: - raise ValueError(f"GenerationTask {task.id} not found") - self._store[task.id] = task - return task - - def list_by_project(self, project_id: str) -> list[Any]: - return [t for t in self._store.values() if t.project_id == project_id] - - def list_by_user(self, user_id: str) -> list[Any]: - return [t for t in self._store.values() if t.created_by_user_id == user_id] - - def count_by_user(self, user_id: str) -> int: - return len([t for t in self._store.values() if t.created_by_user_id == user_id]) - - def count_pending_by_user(self, user_id: str) -> int: - return len( - [ - t - for t in self._store.values() - if t.created_by_user_id == user_id and getattr(t, "status", "") == "pending" - ] - ) - - def count_pending_total(self) -> int: - return len([t for t in self._store.values() if getattr(t, "status", "") == "pending"]) - - def list_recent_by_user(self, user_id: str, limit: int = 5) -> list[Any]: - items = [t for t in self._store.values() if t.created_by_user_id == user_id] - items.sort(key=lambda t: t.created_at, reverse=True) - return items[:limit] - - def list_by_user_filtered( - self, - user_id: str, - *, - status: str | None = None, - limit: int | None = None, - offset: int = 0, - ) -> list: - """按用户+状态筛选任务列表(stub实现)。""" - items = [t for t in self._store.values() if t.created_by_user_id == user_id] - if status: - items = [t for t in items if str(t.status) == status] - # 按创建时间倒序 - items.sort(key=lambda t: t.created_at or "", reverse=True) - if offset: - items = items[offset:] - if limit is not None: - items = items[:limit] - return items - - def count_by_user_filtered( - self, - user_id: str, - *, - status: str | None = None, - ) -> int: - """按用户+状态筛选计数(stub实现)。""" - items = [t for t in self._store.values() if t.created_by_user_id == user_id] - if status: - items = [t for t in items if str(t.status) == status] - return len(items) - - def list_by_project_filtered( - self, - project_id: str, - *, - status: str | None = None, - limit: int | None = None, - offset: int = 0, - ) -> list: - """按项目+状态筛选任务列表(stub实现)。""" - items = [t for t in self._store.values() if t.project_id == project_id] - if status: - items = [t for t in items if str(t.status) == status] - # 按创建时间倒序 - items.sort(key=lambda t: t.created_at or "", reverse=True) - if offset: - items = items[offset:] - if limit is not None: - items = items[:limit] - return items - - def count_by_project_filtered( - self, - project_id: str, - *, - status: str | None = None, - ) -> int: - """按项目+状态筛选计数(stub实现)。""" - items = [t for t in self._store.values() if t.project_id == project_id] - if status: - items = [t for t in items if str(t.status) == status] - return len(items) - - -# ── Fixtures ────────────────────────────────────────────────────────────────── - - -@dataclass -class FakeUser: - id: str = "user-001" - email: str = "test@example.com" - - -@dataclass -class FakeAuthenticatedUser: - user: FakeUser = field(default_factory=FakeUser) - session_id: str | None = None - token_type: str | None = None - - -@pytest.fixture -def plan_repo() -> StubEditPlanRepository: - return StubEditPlanRepository() - - -@pytest.fixture -def clip_repo() -> StubEditPlanClipRepository: - return StubEditPlanClipRepository() - - -@pytest.fixture -def gen_task_repo() -> StubGenerationTaskRepository: - return StubGenerationTaskRepository() - - -@pytest.fixture -def app( - plan_repo: StubEditPlanRepository, - clip_repo: StubEditPlanClipRepository, - gen_task_repo: StubGenerationTaskRepository, -) -> FastAPI: - """构建测试 FastAPI 应用,注入 Stub Repository""" - import app.services.edit_plan_service as service_module - from app.api.routes.edit_plans import router - from app.auth import get_current_user - from app.dependencies import get_db_session - - # 替换 Repository 类 - original_plan_repo = service_module.SQLAlchemyEditPlanRepository - original_clip_repo = service_module.SQLAlchemyEditPlanClipRepository - original_gen_repo = service_module.SQLAlchemyGenerationTaskRepository - - service_module.SQLAlchemyEditPlanRepository = lambda session: plan_repo - service_module.SQLAlchemyEditPlanClipRepository = lambda session: clip_repo - service_module.SQLAlchemyGenerationTaskRepository = lambda session: gen_task_repo - - test_app = FastAPI() - test_app.include_router(router, prefix="/api/v1/edit-plans") - - def override_get_db_session(): - yield MagicMock() - - def override_get_current_user(): - return FakeAuthenticatedUser() - - test_app.dependency_overrides[get_db_session] = override_get_db_session - test_app.dependency_overrides[get_current_user] = override_get_current_user - - yield test_app - - # 恢复 - service_module.SQLAlchemyEditPlanRepository = original_plan_repo - service_module.SQLAlchemyEditPlanClipRepository = original_clip_repo - service_module.SQLAlchemyGenerationTaskRepository = original_gen_repo - test_app.dependency_overrides.clear() - - -@pytest.fixture -def client(app: FastAPI) -> TestClient: - return TestClient(app) - - -def _make_plan( - name: str = "测试计划", - template_id: str = "tmpl-001", - status: EditPlanStatus = EditPlanStatus.DRAFT, - **kwargs: Any, -) -> EditPlan: - plan = EditPlan.create(template_id=template_id, name=name, **kwargs) - plan.status = status - return plan - - -def _make_clip( - plan_id: str, - clip_type: str = "MAIN", - order: int = 1, - asset_id: str = "assets/video.mp4", - status: EditPlanClipStatus = EditPlanClipStatus.PENDING, - **kwargs: Any, -) -> EditPlanClip: - clip = EditPlanClip.create( - plan_id=plan_id, - clip_type=clip_type, - order=order, - asset_id=asset_id, - **kwargs, - ) - clip.status = status - return clip - - -# ── POST /api/v1/edit-plans/{id}/generate ───────────────────────────────────── - - -class TestGeneratePlan: - def test_generate_success( - self, - client: TestClient, - plan_repo: StubEditPlanRepository, - clip_repo: StubEditPlanClipRepository, - ) -> None: - """editing 状态 + pending 片段 → 触发成功""" - plan = _make_plan(status=EditPlanStatus.EDITING) - plan_repo.create(plan) - clip = _make_clip(plan.id, order=1) - clip_repo.create(clip) - - with patch("app.api.routes.edit_plans_generation.celery_app") as mock_celery: - mock_celery.send_task = MagicMock() - resp = client.post(f"/api/v1/edit-plans/{plan.id}/generate") - - assert resp.status_code == 200 - data = resp.json() - assert data["plan_id"] == plan.id - assert data["plan_status"] == "rendering" - assert data["clip_count"] == 1 - assert "generation_task_id" in data - - # 验证计划状态已更新 - updated = plan_repo.get(plan.id) - assert updated.status == EditPlanStatus.RENDERING - - # 验证片段状态已更新为 ready - updated_clip = clip_repo.get(clip.id) - assert updated_clip.status == EditPlanClipStatus.READY - - def test_generate_not_found(self, client: TestClient) -> None: - """计划不存在 → 404""" - resp = client.post("/api/v1/edit-plans/nonexistent/generate") - assert resp.status_code == 404 - assert "不存在" in resp.json()["detail"] - - def test_generate_draft_auto_transition_to_editing( - self, - client: TestClient, - plan_repo: StubEditPlanRepository, - ) -> None: - """draft 状态自动转 editing(自动兜底),然后因 0 片段报错""" - plan = _make_plan(status=EditPlanStatus.DRAFT) - plan_repo.create(plan) - resp = client.post(f"/api/v1/edit-plans/{plan.id}/generate") - # draft 自动转 editing,但没有片段所以还是 400 - assert resp.status_code == 400 - assert "请先添加片段后再生成视频" in resp.json()["detail"] - # 验证状态已自动转为 editing - updated_plan = plan_repo.get(plan.id) - assert updated_plan is not None - assert updated_plan.status == EditPlanStatus.EDITING - - def test_generate_wrong_status_rendering( - self, - client: TestClient, - plan_repo: StubEditPlanRepository, - ) -> None: - """rendering 状态 → 400""" - plan = _make_plan(status=EditPlanStatus.RENDERING) - plan_repo.create(plan) - resp = client.post(f"/api/v1/edit-plans/{plan.id}/generate") - assert resp.status_code == 400 - - def test_generate_wrong_status_completed( - self, - client: TestClient, - plan_repo: StubEditPlanRepository, - ) -> None: - """completed 状态 → 400""" - plan = _make_plan(status=EditPlanStatus.COMPLETED) - plan_repo.create(plan) - resp = client.post(f"/api/v1/edit-plans/{plan.id}/generate") - assert resp.status_code == 400 - - def test_generate_no_clips( - self, - client: TestClient, - plan_repo: StubEditPlanRepository, - ) -> None: - """editing 状态但没有片段 → 400""" - plan = _make_plan(status=EditPlanStatus.EDITING) - plan_repo.create(plan) - resp = client.post(f"/api/v1/edit-plans/{plan.id}/generate") - assert resp.status_code == 400 - assert "片段" in resp.json()["detail"] - - def test_generate_with_ready_clips( - self, - client: TestClient, - plan_repo: StubEditPlanRepository, - clip_repo: StubEditPlanClipRepository, - ) -> None: - """已有 ready 状态的片段也可以触发生成""" - plan = _make_plan(status=EditPlanStatus.EDITING) - plan_repo.create(plan) - clip = _make_clip(plan.id, order=1, status=EditPlanClipStatus.READY) - clip_repo.create(clip) - - with patch("app.api.routes.edit_plans_generation.celery_app") as mock_celery: - mock_celery.send_task = MagicMock() - resp = client.post(f"/api/v1/edit-plans/{plan.id}/generate") - - assert resp.status_code == 200 - data = resp.json() - assert data["plan_status"] == "rendering" - - def test_generate_multiple_clips( - self, - client: TestClient, - plan_repo: StubEditPlanRepository, - clip_repo: StubEditPlanClipRepository, - ) -> None: - """多个片段全部从 pending 转为 ready""" - plan = _make_plan(status=EditPlanStatus.EDITING) - plan_repo.create(plan) - for i in range(3): - clip = _make_clip(plan.id, order=i + 1) - clip_repo.create(clip) - - with patch("app.api.routes.edit_plans_generation.celery_app") as mock_celery: - mock_celery.send_task = MagicMock() - resp = client.post(f"/api/v1/edit-plans/{plan.id}/generate") - - assert resp.status_code == 200 - data = resp.json() - assert data["clip_count"] == 3 - - # 验证所有片段都变为 ready - clips = clip_repo.list_by_plan(plan.id) - assert all(c.status == EditPlanClipStatus.READY for c in clips) - - -# ── GET /api/v1/edit-plans/{id}/generation-status ───────────────────────────── - - -class TestGetGenerationStatus: - def test_status_not_found(self, client: TestClient) -> None: - """计划不存在 → 404""" - resp = client.get("/api/v1/edit-plans/nonexistent/generation-status") - assert resp.status_code == 404 - - def test_status_draft_plan( - self, - client: TestClient, - plan_repo: StubEditPlanRepository, - clip_repo: StubEditPlanClipRepository, - ) -> None: - """draft 状态计划的生成状态""" - plan = _make_plan(status=EditPlanStatus.DRAFT) - plan_repo.create(plan) - clip = _make_clip(plan.id, order=1) - clip_repo.create(clip) - - resp = client.get(f"/api/v1/edit-plans/{plan.id}/generation-status") - assert resp.status_code == 200 - data = resp.json() - assert data["plan_id"] == plan.id - assert data["plan_status"] == "draft" - assert data["generation_task_id"] is None - assert len(data["clips"]) == 1 - assert data["clips"][0]["status"] == "pending" - - def test_status_rendering_plan( - self, - client: TestClient, - plan_repo: StubEditPlanRepository, - clip_repo: StubEditPlanClipRepository, - ) -> None: - """rendering 状态计划的生成状态""" - plan = _make_plan(status=EditPlanStatus.RENDERING) - plan.config["generation_task_id"] = "gen-task-001" - plan_repo.create(plan) - - clip1 = _make_clip(plan.id, order=1, status=EditPlanClipStatus.RENDERED) - clip2 = _make_clip(plan.id, order=2, status=EditPlanClipStatus.READY) - clip_repo.create(clip1) - clip_repo.create(clip2) - - resp = client.get(f"/api/v1/edit-plans/{plan.id}/generation-status") - assert resp.status_code == 200 - data = resp.json() - assert data["plan_status"] == "rendering" - assert data["generation_task_id"] == "gen-task-001" - assert len(data["clips"]) == 2 - statuses = {c["status"] for c in data["clips"]} - assert "rendered" in statuses - assert "ready" in statuses - - def test_status_completed_plan( - self, - client: TestClient, - plan_repo: StubEditPlanRepository, - clip_repo: StubEditPlanClipRepository, - ) -> None: - """completed 状态计划的生成状态""" - plan = _make_plan(status=EditPlanStatus.COMPLETED) - plan.config["generation_task_id"] = "gen-task-002" - plan.config["rendered_url"] = "https://oss.example.com/rendered/output.mp4" - plan_repo.create(plan) - - clip = _make_clip(plan.id, order=1, status=EditPlanClipStatus.RENDERED) - clip_repo.create(clip) - - resp = client.get(f"/api/v1/edit-plans/{plan.id}/generation-status") - assert resp.status_code == 200 - data = resp.json() - assert data["plan_status"] == "completed" - assert data["generation_task_id"] == "gen-task-002" - assert len(data["clips"]) == 1 - assert data["clips"][0]["status"] == "rendered" - - def test_status_clip_fields( - self, - client: TestClient, - plan_repo: StubEditPlanRepository, - clip_repo: StubEditPlanClipRepository, - ) -> None: - """验证片段状态返回的字段完整性""" - plan = _make_plan(status=EditPlanStatus.EDITING) - plan_repo.create(plan) - clip = _make_clip( - plan.id, - clip_type="INTRO", - order=1, - asset_id="assets/intro.mp4", - duration=5.0, - ) - clip.text_content = "欢迎观看" - clip_repo.create(clip) - - resp = client.get(f"/api/v1/edit-plans/{plan.id}/generation-status") - assert resp.status_code == 200 - data = resp.json() - clip_data = data["clips"][0] - assert clip_data["clip_id"] == clip.id - assert clip_data["clip_type"] == "INTRO" - assert clip_data["order"] == 1 - assert clip_data["asset_id"] == "assets/intro.mp4" - assert clip_data["text_content"] == "欢迎观看" - assert clip_data["duration"] == 5.0 - - def test_status_no_clips( - self, - client: TestClient, - plan_repo: StubEditPlanRepository, - ) -> None: - """没有片段的计划也能查询状态""" - plan = _make_plan(status=EditPlanStatus.DRAFT) - plan_repo.create(plan) - - resp = client.get(f"/api/v1/edit-plans/{plan.id}/generation-status") - assert resp.status_code == 200 - data = resp.json() - assert data["clips"] == [] - - -# ── Response Schema 验证 ────────────────────────────────────────────────────── - - -class TestResponseSchema: - def test_generate_response_structure( - self, - client: TestClient, - plan_repo: StubEditPlanRepository, - clip_repo: StubEditPlanClipRepository, - ) -> None: - """验证 generate 端点响应结构""" - plan = _make_plan(status=EditPlanStatus.EDITING) - plan_repo.create(plan) - clip = _make_clip(plan.id, order=1) - clip_repo.create(clip) - - with patch("app.api.routes.edit_plans_generation.celery_app") as mock_celery: - mock_celery.send_task = MagicMock() - resp = client.post(f"/api/v1/edit-plans/{plan.id}/generate") - - assert resp.status_code == 200 - data = resp.json() - expected_keys = {"plan_id", "plan_status", "generation_task_id", "clip_count"} - assert set(data.keys()) == expected_keys - - def test_generation_status_response_structure( - self, - client: TestClient, - plan_repo: StubEditPlanRepository, - ) -> None: - """验证 generation-status 端点响应结构""" - plan = _make_plan(status=EditPlanStatus.DRAFT) - plan_repo.create(plan) - - resp = client.get(f"/api/v1/edit-plans/{plan.id}/generation-status") - assert resp.status_code == 200 - data = resp.json() - expected_keys = { - "plan_id", - "plan_status", - "generation_task_id", - "generation_task_status", - "progress", - "video_url", - "error_message", - "clips", - } - assert set(data.keys()) == expected_keys - - -# ── P0-1: 生成接口错误处理 ──────────────────────────────────────────────────── - - -class TestGeneratePlanErrorHandling: - """P0-1: generate 端点异常时返回明确错误信息,不裸 500""" - - def test_generate_internal_error_returns_clear_message( - self, - client: TestClient, - plan_repo: StubEditPlanRepository, - clip_repo: StubEditPlanClipRepository, - ) -> None: - """核心流程抛异常 → 500 + 用户友好的错误信息(不暴露技术细节)""" - plan = _make_plan(status=EditPlanStatus.EDITING) - plan_repo.create(plan) - clip = _make_clip(plan.id, order=1) - clip_repo.create(clip) - - with patch("app.api.routes.edit_plans_generation.celery_app") as mock_celery: - # 模拟 Celery 调度失败 - mock_celery.send_task.side_effect = RuntimeError("Redis 连接超时") - resp = client.post(f"/api/v1/edit-plans/{plan.id}/generate") - - assert resp.status_code == 500 - data = resp.json() - # 验证返回了用户友好的错误信息,不暴露技术细节 - assert "生成失败" in data["detail"] - assert "RuntimeError" not in data["detail"] - assert "Redis" not in data["detail"] - - def test_generate_error_rolls_back_plan_status( - self, - client: TestClient, - plan_repo: StubEditPlanRepository, - clip_repo: StubEditPlanClipRepository, - ) -> None: - """异常时将计划标记为 failed(RENDERING → FAILED 是合法流转)""" - plan = _make_plan(status=EditPlanStatus.EDITING) - plan_repo.create(plan) - clip = _make_clip(plan.id, order=1) - clip_repo.create(clip) - - with patch("app.api.routes.edit_plans_generation.celery_app") as mock_celery: - mock_celery.send_task.side_effect = RuntimeError("调度失败") - resp = client.post(f"/api/v1/edit-plans/{plan.id}/generate") - - assert resp.status_code == 500 - # 计划状态应变为 failed - updated = plan_repo.get(plan.id) - assert updated.status == EditPlanStatus.FAILED - - def test_generate_error_detail_is_user_friendly( - self, - client: TestClient, - plan_repo: StubEditPlanRepository, - clip_repo: StubEditPlanClipRepository, - ) -> None: - """错误信息对用户友好,不暴露技术细节(异常类型、内部错误信息)""" - plan = _make_plan(status=EditPlanStatus.EDITING) - plan_repo.create(plan) - clip = _make_clip(plan.id, order=1) - clip_repo.create(clip) - - with patch("app.api.routes.edit_plans_generation.celery_app") as mock_celery: - mock_celery.send_task.side_effect = ConnectionError("Broker 不可达") - resp = client.post(f"/api/v1/edit-plans/{plan.id}/generate") - - assert resp.status_code == 500 - detail = resp.json()["detail"] - # 验证不暴露技术细节 - assert "ConnectionError" not in detail - assert "Broker 不可达" not in detail - # 验证返回了用户友好的提示 - assert "生成失败" in detail diff --git a/tests/unit/test_edit_plan_service.py b/tests/unit/test_edit_plan_service.py index df4fc6307..806f3de61 100755 --- a/tests/unit/test_edit_plan_service.py +++ b/tests/unit/test_edit_plan_service.py @@ -320,77 +320,10 @@ class TestEditPlanServiceCRUD: with pytest.raises(ValueError, match="剪辑计划不存在"): svc.get_plan_or_raise("nonexistent") - def test_list_plans(self): - svc = _make_service() - svc.create_plan("tpl-001", "计划1") - svc.create_plan("tpl-001", "计划2") - result = svc.list_plans() - assert len(result) == 2 - - def test_list_plans_by_template(self): - svc = _make_service() - svc.create_plan("tpl-001", "计划1") - svc.create_plan("tpl-002", "计划2") - result = svc.list_plans(template_id="tpl-001") - assert len(result) == 1 - assert result[0].name == "计划1" - - def test_list_plans_by_status(self): - svc = _make_service() - p1 = svc.create_plan("tpl-001", "计划1") - svc.create_plan("tpl-001", "计划2") - svc.transition_status(p1.id, EditPlanStatus.EDITING) - result = svc.list_plans(status=EditPlanStatus.EDITING) - assert len(result) == 1 - - def test_count_plans(self): - svc = _make_service() - svc.create_plan("tpl-001", "计划1") - svc.create_plan("tpl-001", "计划2") - assert svc.count_plans() == 2 - - def test_update_plan_name(self): - svc = _make_service() - p = svc.create_plan("tpl-001", "原名") - updated = svc.update_plan(p.id, name="新名") - assert updated.name == "新名" - def test_update_plan_not_found_raises(self): svc = _make_service() with pytest.raises(ValueError, match="剪辑计划不存在"): svc.update_plan("nonexistent", name="新名") - - def test_delete_plan(self): - svc = _make_service() - p = svc.create_plan("tpl-001", "要删除") - assert svc.delete_plan(p.id) is True - assert svc.get_plan(p.id) is None - - def test_delete_plan_not_found(self): - svc = _make_service() - assert svc.delete_plan("nonexistent") is False - - def test_delete_plan_also_deletes_clips(self): - svc = _make_service() - p = svc.create_plan("tpl-001", "带片段") - svc.create_clip(p.id, "intro", 0) - svc.create_clip(p.id, "main", 1) - assert svc.count_clips(p.id) == 2 - svc.delete_plan(p.id) - # 片段应被一并删除 - assert svc.count_clips(p.id) == 0 - - -# =========================================================================== -# 状态机流转测试 -# =========================================================================== - - -class TestStatusTransitions: - """状态机流转测试""" - - def test_transition_draft_to_editing(self): - svc = _make_service() p = svc.create_plan("tpl-001", "测试") result = svc.transition_status(p.id, EditPlanStatus.EDITING) assert result.status == EditPlanStatus.EDITING @@ -600,22 +533,6 @@ class TestGenerationWorkflow: clips = svc.list_clips(p.id) for c in clips: assert c.status == EditPlanClipStatus.READY - - def test_get_plan_with_clips(self): - svc = _make_service() - p = svc.create_plan("tpl-001", "测试") - svc.create_clip(p.id, "intro", 0) - svc.create_clip(p.id, "main", 1) - result = svc.get_plan_with_clips(p.id) - assert result["plan"].id == p.id - assert len(result["clips"]) == 2 - - def test_get_plan_with_clips_not_found(self): - svc = _make_service() - with pytest.raises(ValueError, match="剪辑计划不存在"): - svc.get_plan_with_clips("nonexistent") - - def test_get_generation_status(self): svc = _make_service() p = svc.create_plan("tpl-001", "测试") svc.create_clip(p.id, "intro", 0) @@ -849,234 +766,6 @@ class TestClipSplit: assert right.asset_id == "asset-001" -class TestSubtitleManagement: - """字幕管理测试""" - - def test_add_subtitle_basic(self): - """基础:添加一条字幕""" - svc = _make_service() - p = svc.create_plan("tpl-001", "测试计划") - svc.transition_status(p.id, EditPlanStatus.EDITING) - clip = svc.create_clip(p.id, "main", 0, duration=10.0) - - subtitle = svc.add_subtitle(clip.id, start=1.0, end=3.0, text="大家好") - - assert subtitle["text"] == "大家好" - assert subtitle["start"] == 1.0 - assert subtitle["end"] == 3.0 - assert "id" in subtitle - assert len(subtitle["id"]) > 0 - - def test_add_subtitle_with_style(self): - """添加带样式的字幕""" - svc = _make_service() - p = svc.create_plan("tpl-001", "测试计划") - svc.transition_status(p.id, EditPlanStatus.EDITING) - clip = svc.create_clip(p.id, "main", 0, duration=10.0) - - style = {"font_size": 24, "color": "#ffffff", "position": "bottom"} - subtitle = svc.add_subtitle(clip.id, start=0.0, end=2.0, text="测试", style=style) - - assert subtitle["style"]["font_size"] == 24 - assert subtitle["style"]["color"] == "#ffffff" - - def test_list_subtitles_sorted_by_time(self): - """字幕列表按时间排序""" - svc = _make_service() - p = svc.create_plan("tpl-001", "测试计划") - svc.transition_status(p.id, EditPlanStatus.EDITING) - clip = svc.create_clip(p.id, "main", 0, duration=10.0) - - svc.add_subtitle(clip.id, start=5.0, end=6.0, text="第二") - svc.add_subtitle(clip.id, start=1.0, end=2.0, text="第一") - svc.add_subtitle(clip.id, start=8.0, end=9.0, text="第三") - - subtitles = svc.list_subtitles(clip.id) - assert len(subtitles) == 3 - assert subtitles[0]["text"] == "第一" - assert subtitles[1]["text"] == "第二" - assert subtitles[2]["text"] == "第三" - - def test_add_subtitle_invalid_time_raises(self): - """非法时间报错""" - svc = _make_service() - p = svc.create_plan("tpl-001", "测试计划") - svc.transition_status(p.id, EditPlanStatus.EDITING) - clip = svc.create_clip(p.id, "main", 0, duration=10.0) - - # 开始时间为负 - with pytest.raises(ValueError, match="时间非法"): - svc.add_subtitle(clip.id, start=-1.0, end=2.0, text="test") - - # 结束时间 <= 开始时间 - with pytest.raises(ValueError, match="时间非法"): - svc.add_subtitle(clip.id, start=5.0, end=3.0, text="test") - - # 超过片段时长 - with pytest.raises(ValueError, match="不能超过片段时长"): - svc.add_subtitle(clip.id, start=8.0, end=15.0, text="test") - - def test_add_subtitle_empty_text_raises(self): - """空文本报错""" - svc = _make_service() - p = svc.create_plan("tpl-001", "测试计划") - svc.transition_status(p.id, EditPlanStatus.EDITING) - clip = svc.create_clip(p.id, "main", 0, duration=10.0) - - with pytest.raises(ValueError, match="不能为空"): - svc.add_subtitle(clip.id, start=1.0, end=2.0, text=" ") - - def test_get_subtitle(self): - """获取单条字幕""" - svc = _make_service() - p = svc.create_plan("tpl-001", "测试计划") - svc.transition_status(p.id, EditPlanStatus.EDITING) - clip = svc.create_clip(p.id, "main", 0, duration=10.0) - - sub = svc.add_subtitle(clip.id, start=1.0, end=2.0, text="测试") - found = svc.get_subtitle(clip.id, sub["id"]) - - assert found is not None - assert found["text"] == "测试" - - # 不存在的返回 None - assert svc.get_subtitle(clip.id, "nonexistent") is None - - def test_update_subtitle_text(self): - """更新字幕文本""" - svc = _make_service() - p = svc.create_plan("tpl-001", "测试计划") - svc.transition_status(p.id, EditPlanStatus.EDITING) - clip = svc.create_clip(p.id, "main", 0, duration=10.0) - - sub = svc.add_subtitle(clip.id, start=1.0, end=2.0, text="原文") - updated = svc.update_subtitle(clip.id, sub["id"], text="修改后") - - assert updated["text"] == "修改后" - assert updated["start"] == 1.0 # 时间不变 - - def test_update_subtitle_time(self): - """更新字幕时间""" - svc = _make_service() - p = svc.create_plan("tpl-001", "测试计划") - svc.transition_status(p.id, EditPlanStatus.EDITING) - clip = svc.create_clip(p.id, "main", 0, duration=10.0) - - sub = svc.add_subtitle(clip.id, start=1.0, end=2.0, text="测试") - updated = svc.update_subtitle(clip.id, sub["id"], start=3.0, end=5.0) - - assert updated["start"] == 3.0 - assert updated["end"] == 5.0 - - def test_update_subtitle_not_found_raises(self): - """更新不存在的字幕报错""" - svc = _make_service() - p = svc.create_plan("tpl-001", "测试计划") - svc.transition_status(p.id, EditPlanStatus.EDITING) - clip = svc.create_clip(p.id, "main", 0, duration=10.0) - - with pytest.raises(ValueError, match="字幕不存在"): - svc.update_subtitle(clip.id, "fake-id", text="test") - - def test_delete_subtitle(self): - """删除字幕""" - svc = _make_service() - p = svc.create_plan("tpl-001", "测试计划") - svc.transition_status(p.id, EditPlanStatus.EDITING) - clip = svc.create_clip(p.id, "main", 0, duration=10.0) - - sub = svc.add_subtitle(clip.id, start=1.0, end=2.0, text="要删的") - assert svc.count_clips(p.id) == 1 # 片段还在 - - deleted = svc.delete_subtitle(clip.id, sub["id"]) - assert deleted is True - - subtitles = svc.list_subtitles(clip.id) - assert len(subtitles) == 0 - - def test_delete_subtitle_not_found(self): - """删除不存在的字幕返回 False""" - svc = _make_service() - p = svc.create_plan("tpl-001", "测试计划") - svc.transition_status(p.id, EditPlanStatus.EDITING) - clip = svc.create_clip(p.id, "main", 0, duration=10.0) - - deleted = svc.delete_subtitle(clip.id, "nonexistent") - assert deleted is False - - def test_batch_update_subtitles(self): - """批量更新字幕(全量替换)""" - svc = _make_service() - p = svc.create_plan("tpl-001", "测试计划") - svc.transition_status(p.id, EditPlanStatus.EDITING) - clip = svc.create_clip(p.id, "main", 0, duration=20.0) - - # 先加一条 - svc.add_subtitle(clip.id, start=1.0, end=2.0, text="旧字幕") - - # 全量替换为 3 条 - new_subs = [ - {"start": 0.0, "end": 3.0, "text": "第一条"}, - {"start": 4.0, "end": 7.0, "text": "第二条"}, - {"start": 8.0, "end": 12.0, "text": "第三条"}, - ] - result = svc.batch_update_subtitles(clip.id, new_subs) - - assert len(result) == 3 - assert result[0]["text"] == "第一条" - # 都有 id - assert all("id" in s for s in result) - # 旧字幕没了 - subtitles = svc.list_subtitles(clip.id) - assert len(subtitles) == 3 - - def test_batch_update_preserves_existing_ids(self): - """批量更新时已有 id 的字幕保留原 id""" - svc = _make_service() - p = svc.create_plan("tpl-001", "测试计划") - svc.transition_status(p.id, EditPlanStatus.EDITING) - clip = svc.create_clip(p.id, "main", 0, duration=10.0) - - sub = svc.add_subtitle(clip.id, start=1.0, end=2.0, text="原字幕") - original_id = sub["id"] - - # 带 id 批量更新,修改文本 - updated_list = svc.batch_update_subtitles( - clip.id, - [{"id": original_id, "start": 1.0, "end": 3.0, "text": "修改了"}], - ) - - assert len(updated_list) == 1 - assert updated_list[0]["id"] == original_id - assert updated_list[0]["text"] == "修改了" - - def test_batch_update_skips_empty_text(self): - """批量更新时空文本自动跳过""" - svc = _make_service() - p = svc.create_plan("tpl-001", "测试计划") - svc.transition_status(p.id, EditPlanStatus.EDITING) - clip = svc.create_clip(p.id, "main", 0, duration=10.0) - - subs = [ - {"start": 0.0, "end": 1.0, "text": "有效"}, - {"start": 2.0, "end": 3.0, "text": " "}, # 空白,跳过 - {"start": 4.0, "end": 5.0, "text": "也有效"}, - ] - result = svc.batch_update_subtitles(clip.id, subs) - - assert len(result) == 2 - - def test_empty_clip_returns_empty_list(self): - """没有字幕的片段返回空列表""" - svc = _make_service() - p = svc.create_plan("tpl-001", "测试计划") - svc.transition_status(p.id, EditPlanStatus.EDITING) - clip = svc.create_clip(p.id, "main", 0, duration=10.0) - - subtitles = svc.list_subtitles(clip.id) - assert subtitles == [] - - class TestClipMerge: """片段合并测试""" diff --git a/tests/unit/test_edit_plan_transitions_api.py b/tests/unit/test_edit_plan_transitions_api.py deleted file mode 100644 index 67ef57839..000000000 --- a/tests/unit/test_edit_plan_transitions_api.py +++ /dev/null @@ -1,494 +0,0 @@ -""" -转场特效 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 diff --git a/tests/unit/test_edit_plans_api.py b/tests/unit/test_edit_plans_api.py deleted file mode 100755 index 1dab5494c..000000000 --- a/tests/unit/test_edit_plans_api.py +++ /dev/null @@ -1,906 +0,0 @@ -""" -edit_plans.py 剪辑计划 API 端点单元测试 - -覆盖(25+ 测试用例): -- 创建:正常创建、空名称 400、空 template_id 422 -- 列表:默认分页、按状态筛选、按模板筛选、无效状态 400 -- 详情:正常获取、不存在 404 -- 更新:基础字段更新、状态机合法流转、状态机非法流转 400、不存在 404、无效状态值 400 -- 删除:正常删除、不存在 404 -""" - -from __future__ import annotations - -import os -import sys -from pathlib import Path -from typing import Optional -from unittest.mock import MagicMock, patch - -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 - -# --------------------------------------------------------------------------- -# Stub Repository -# --------------------------------------------------------------------------- - - -class StubEditPlanRepository: - """内存中的 EditPlan 仓储 stub""" - - def __init__(self, plans: dict[str, EditPlan] | None = None): - self._plans = plans or {} - self._counter = 0 - - def _next_id(self) -> str: - self._counter += 1 - return f"plan-{self._counter:03d}" - - def list_all( - self, - *, - status: Optional[EditPlanStatus] = None, - skip: int = 0, - limit: int = 50, - ) -> list[EditPlan]: - 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: str, - *, - status: Optional[EditPlanStatus] = None, - skip: int = 0, - limit: int = 50, - ) -> list[EditPlan]: - 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: - if plan.id not in self._plans: - raise ValueError(f"EditPlan {plan.id} not found") - self._plans[plan.id] = plan - return plan - - def delete(self, plan_id: str) -> bool: - if plan_id not in self._plans: - return False - del self._plans[plan_id] - return True - - def delete_by_plan(self, plan_id: str) -> None: - """按 plan_id 删除关联片段(stub 实现:无操作)。""" - pass - - def count( - self, - *, - template_id: Optional[str] = None, - status: Optional[EditPlanStatus] = None, - ) -> int: - items = list(self._plans.values()) - if template_id: - items = [p for p in items if p.template_id == template_id] - if status is not None: - items = [p for p in items if p.status == status] - return len(items) - - -# --------------------------------------------------------------------------- -# Fixtures -# --------------------------------------------------------------------------- - - -def _make_auth_user(): - """构造 AuthenticatedUser mock""" - from app.auth import AuthenticatedUser - - from packages.domain.entities import User - - user = User( - id="user-001", - email="test@example.com", - display_name="测试用户", - ) - return AuthenticatedUser(user=user) - - -def _create_test_app(): - """创建带 stub 注入的测试 FastAPI 应用""" - import app.services.edit_plan_service as service_module - from app.api.routes import edit_plans as edit_plans_module - from app.api.routes.edit_plans import router - - stub_repo = StubEditPlanRepository() - - # 替换服务模块中的 Repository 类 - original_plan_repo_class = service_module.SQLAlchemyEditPlanRepository - original_clip_repo_class = service_module.SQLAlchemyEditPlanClipRepository - original_generation_task_repo_class = service_module.SQLAlchemyGenerationTaskRepository - service_module.SQLAlchemyEditPlanRepository = lambda db: stub_repo - service_module.SQLAlchemyEditPlanClipRepository = lambda db: stub_repo - service_module.SQLAlchemyGenerationTaskRepository = lambda db: stub_repo - - app = FastAPI() - app.include_router(router, prefix="/api/v1/edit-plans") - - # 覆盖认证依赖 - app.dependency_overrides[edit_plans_module.get_current_user] = _make_auth_user - app.dependency_overrides[edit_plans_module.get_db_session] = lambda: MagicMock() - - def cleanup(): - service_module.SQLAlchemyEditPlanRepository = original_plan_repo_class - service_module.SQLAlchemyEditPlanClipRepository = original_clip_repo_class - service_module.SQLAlchemyGenerationTaskRepository = original_generation_task_repo_class - - return app, stub_repo, cleanup - - -@pytest.fixture -def client(): - app, stub_repo, cleanup = _create_test_app() - yield TestClient(app), stub_repo - cleanup() - - -# --------------------------------------------------------------------------- -# 创建测试 -# --------------------------------------------------------------------------- - - -class TestCreatePlan: - """创建剪辑计划测试。 - - 注意:创建计划时会从模板生成 clips,这里 mock 掉模板服务和生成器, - 专注验证 API 层参数传递和响应格式。 - """ - - def _make_test_plan(self, plan_id="plan-001", template_id="tpl-001", name="测试计划"): - """构造一个测试用 EditPlan""" - return EditPlan.create( - template_id=template_id, - name=name, - config=normalize_plan_config({}), - total_duration=15.0, - ) - - @patch("app.api.routes.edit_plans.EditTemplateService") - @patch("app.services.PlanGeneratorService") - def test_create_success(self, mock_generator_cls, mock_template_svc_cls, client): - c, repo = client - - # Setup mock 模板服务 - mock_template_svc = MagicMock() - mock_template_svc.get_template_or_raise.return_value = MagicMock( - id="tpl-001", - name="测试模板", - config={}, - ) - mock_template_svc.list_clip_configs.return_value = [] - mock_template_svc_cls.return_value = mock_template_svc - - # Setup mock 生成器 - mock_gen = MagicMock() - test_plan = self._make_test_plan(name="我的剪辑计划") - test_plan.status = EditPlanStatus.EDITING - # 把 plan 存到 stub repo,这样后续 update_plan 能找到 - repo.create(test_plan) - mock_gen.generate_from_template.return_value = { - "plan": test_plan, - "clips": [], - } - mock_generator_cls.return_value = mock_gen - - resp = c.post( - "/api/v1/edit-plans", - json={ - "template_id": "tpl-001", - "name": "我的剪辑计划", - "config": {"bgm": {"enabled": True}}, - "total_duration": 60.0, - }, - ) - assert resp.status_code == 201 - data = resp.json() - assert data["name"] == "我的剪辑计划" - assert data["template_id"] == "tpl-001" - assert "id" in data - assert "created_at" in data - - # 验证调用了生成器 - mock_gen.generate_from_template.assert_called_once() - call_kwargs = mock_gen.generate_from_template.call_args[1] - assert call_kwargs["template"].id == "tpl-001" - assert call_kwargs["name"] == "我的剪辑计划" - assert call_kwargs["created_by_user_id"] == "user-001" - - @patch("app.api.routes.edit_plans.EditTemplateService") - @patch("app.services.PlanGeneratorService") - def test_create_with_asset_ids(self, mock_generator_cls, mock_template_svc_cls, client): - """创建计划时传入 asset_ids,应传递给生成器并写入 plan.config""" - c, repo = client - - mock_template_svc = MagicMock() - mock_template_svc.get_template_or_raise.return_value = MagicMock( - id="tpl-001", - name="测试模板", - config={}, - ) - mock_template_svc.list_clip_configs.return_value = [] - mock_template_svc_cls.return_value = mock_template_svc - - mock_gen = MagicMock() - test_plan = self._make_test_plan(name="带素材计划") - test_plan.status = EditPlanStatus.EDITING - repo.create(test_plan) - mock_gen.generate_from_template.return_value = { - "plan": test_plan, - "clips": [], - } - mock_generator_cls.return_value = mock_gen - - asset_ids = ["asset-001", "asset-002", "asset-003"] - resp = c.post( - "/api/v1/edit-plans", - json={ - "template_id": "tpl-001", - "name": "带素材计划", - "asset_ids": asset_ids, - }, - ) - assert resp.status_code == 201 - - # 验证 asset_ids 传递给了生成器 - mock_gen.generate_from_template.assert_called_once() - call_kwargs = mock_gen.generate_from_template.call_args[1] - assert call_kwargs["asset_ids"] == asset_ids - - @patch("app.api.routes.edit_plans.EditTemplateService") - @patch("app.services.PlanGeneratorService") - def test_create_minimal(self, mock_generator_cls, mock_template_svc_cls, client): - c, repo = client - - mock_template_svc = MagicMock() - mock_template_svc.get_template_or_raise.return_value = MagicMock( - id="tpl-001", - name="测试模板", - config={}, - ) - mock_template_svc.list_clip_configs.return_value = [] - mock_template_svc_cls.return_value = mock_template_svc - - mock_gen = MagicMock() - test_plan = self._make_test_plan() - test_plan.status = EditPlanStatus.EDITING - # 把 plan 存到 stub repo - repo.create(test_plan) - mock_gen.generate_from_template.return_value = { - "plan": test_plan, - "clips": [], - } - mock_generator_cls.return_value = mock_gen - - resp = c.post( - "/api/v1/edit-plans", - json={"template_id": "tpl-001", "name": "最小计划"}, - ) - assert resp.status_code == 201 - data = resp.json() - assert "id" in data - - # 验证生成器被调用 - mock_gen.generate_from_template.assert_called_once() - - @patch("app.api.routes.edit_plans.EditTemplateService") - def test_create_template_not_found_falls_back_empty(self, mock_template_svc_cls, client): - """模板不存在时降级为空计划(向后兼容)""" - c, repo = client - - mock_template_svc = MagicMock() - mock_template_svc.get_template_or_raise.side_effect = ValueError("模板不存在") - mock_template_svc_cls.return_value = mock_template_svc - - resp = c.post( - "/api/v1/edit-plans", - json={"template_id": "nonexistent", "name": "测试"}, - ) - assert resp.status_code == 201 - data = resp.json() - assert data["name"] == "测试" - assert data["template_id"] == "nonexistent" - # 空计划没有片段 - assert "clips" not in data or len(data.get("clips", [])) == 0 - - def test_create_empty_name_returns_422(self, client): - c, repo = client - resp = c.post( - "/api/v1/edit-plans", - json={"template_id": "tpl-001", "name": ""}, - ) - assert resp.status_code == 422 - - def test_create_missing_template_id_returns_422(self, client): - c, repo = client - resp = c.post( - "/api/v1/edit-plans", - json={"name": "没有模板的计划"}, - ) - assert resp.status_code == 422 - - def test_create_negative_duration_returns_422(self, client): - c, repo = client - resp = c.post( - "/api/v1/edit-plans", - json={"template_id": "tpl-001", "name": "test", "total_duration": -1.0}, - ) - assert resp.status_code == 422 - - -# --------------------------------------------------------------------------- -# 列表测试 -# --------------------------------------------------------------------------- - - -class TestListPlans: - def _seed_plans(self, repo, count=3, template_id="tpl-001"): - for i in range(count): - plan = EditPlan.create( - template_id=template_id, - name=f"计划{i+1}", - config={"index": i}, - ) - repo.create(plan) - return plan - - def test_list_empty(self, client): - c, repo = client - resp = c.get("/api/v1/edit-plans") - assert resp.status_code == 200 - data = resp.json() - assert data["items"] == [] - assert data["total"] == 0 - assert data["page"] == 1 - assert data["page_size"] == 20 - - def test_list_with_items(self, client): - c, repo = client - self._seed_plans(repo, count=3) - resp = c.get("/api/v1/edit-plans") - assert resp.status_code == 200 - data = resp.json() - assert len(data["items"]) == 3 - assert data["total"] == 3 - - def test_list_pagination(self, client): - c, repo = client - self._seed_plans(repo, count=5) - resp = c.get("/api/v1/edit-plans?page=1&page_size=2") - assert resp.status_code == 200 - data = resp.json() - assert len(data["items"]) == 2 - assert data["total"] == 5 - assert data["page"] == 1 - - resp2 = c.get("/api/v1/edit-plans?page=3&page_size=2") - data2 = resp2.json() - assert len(data2["items"]) == 1 - - def test_list_filter_by_status(self, client): - c, repo = client - p1 = EditPlan.create("tpl-001", "计划A") - repo.create(p1) - p2 = EditPlan.create("tpl-001", "计划B") - repo.create(p2) - p2.start_editing() - repo.update(p2) - - resp = c.get("/api/v1/edit-plans?status=draft") - data = resp.json() - assert data["total"] == 1 - assert data["items"][0]["name"] == "计划A" - - resp2 = c.get("/api/v1/edit-plans?status=editing") - data2 = resp2.json() - assert data2["total"] == 1 - assert data2["items"][0]["name"] == "计划B" - - def test_list_filter_by_template_id(self, client): - c, repo = client - p1 = EditPlan.create("tpl-001", "模板1计划") - repo.create(p1) - p2 = EditPlan.create("tpl-002", "模板2计划") - repo.create(p2) - - resp = c.get("/api/v1/edit-plans?template_id=tpl-001") - data = resp.json() - assert data["total"] == 1 - assert data["items"][0]["name"] == "模板1计划" - - def test_list_filter_by_template_and_status(self, client): - c, repo = client - p1 = EditPlan.create("tpl-001", "模板1草稿") - repo.create(p1) - p2 = EditPlan.create("tpl-001", "模板1编辑中") - repo.create(p2) - p2.start_editing() - repo.update(p2) - p3 = EditPlan.create("tpl-002", "模板2草稿") - repo.create(p3) - - resp = c.get("/api/v1/edit-plans?template_id=tpl-001&status=draft") - data = resp.json() - assert data["total"] == 1 - assert data["items"][0]["name"] == "模板1草稿" - - def test_list_invalid_status_returns_400(self, client): - c, repo = client - resp = c.get("/api/v1/edit-plans?status=invalid_status") - assert resp.status_code == 400 - assert "无效" in resp.json()["detail"] - - -# --------------------------------------------------------------------------- -# 详情测试 -# --------------------------------------------------------------------------- - - -class TestGetPlan: - def test_get_success(self, client): - c, repo = client - plan = EditPlan.create("tpl-001", "测试计划", config={"key": "val"}) - repo.create(plan) - - resp = c.get(f"/api/v1/edit-plans/{plan.id}") - assert resp.status_code == 200 - data = resp.json() - assert data["id"] == plan.id - assert data["name"] == "测试计划" - assert data["config"] == {"key": "val"} - - def test_get_not_found_returns_404(self, client): - c, repo = client - resp = c.get("/api/v1/edit-plans/nonexistent-id") - assert resp.status_code == 404 - assert "剪辑计划不存在" in resp.json()["detail"] - - def test_get_plan_rendered_url_is_signed(self, client): - """plan详情接口返回的rendered_url应该是签名URL,不是裸OSS URL""" - c, repo = client - raw_url = "https://bucket.oss-cn-hangzhou.aliyuncs.com/rendered/plan-001/output.mp4" - signed_url = raw_url + "?OSSAccessKeyId=xxx&Expires=123456&Signature=yyy" - plan = EditPlan.create("tpl-001", "测试计划", config={"rendered_url": raw_url, "other": "val"}) - plan.status = EditPlanStatus.COMPLETED - repo.create(plan) - - mock_storage = MagicMock() - mock_storage.get_download_url.return_value = signed_url - with patch("app.api.routes.edit_plans.get_storage_service", return_value=mock_storage): - resp = c.get(f"/api/v1/edit-plans/{plan.id}") - - assert resp.status_code == 200 - data = resp.json() - assert data["config"]["rendered_url"] == signed_url - assert data["config"]["other"] == "val" - mock_storage.get_download_url.assert_called_once_with(raw_url, expires_seconds=86400) - - def test_list_plans_rendered_url_is_signed(self, client): - """plan列表接口返回的rendered_url也应该是签名URL""" - c, repo = client - raw_url = "https://bucket.oss-cn-hangzhou.aliyuncs.com/rendered/plan-list/output.mp4" - signed_url = raw_url + "?OSSAccessKeyId=xxx&Expires=123456&Signature=yyy" - plan = EditPlan.create("tpl-001", "测试计划", config={"rendered_url": raw_url}) - plan.status = EditPlanStatus.COMPLETED - repo.create(plan) - - mock_storage = MagicMock() - mock_storage.get_download_url.return_value = signed_url - with patch("app.api.routes.edit_plans.get_storage_service", return_value=mock_storage): - resp = c.get("/api/v1/edit-plans") - - assert resp.status_code == 200 - data = resp.json() - assert data["items"][0]["config"]["rendered_url"] == signed_url - - def test_get_plan_no_rendered_url_skips_signing(self, client): - """没有rendered_url的计划不调用签名服务""" - c, repo = client - plan = EditPlan.create("tpl-001", "测试计划", config={"key": "val"}) - repo.create(plan) - - mock_storage = MagicMock() - with patch("app.api.routes.edit_plans.get_storage_service", return_value=mock_storage): - resp = c.get(f"/api/v1/edit-plans/{plan.id}") - - assert resp.status_code == 200 - data = resp.json() - assert data["config"] == {"key": "val"} - mock_storage.get_download_url.assert_not_called() - - -# --------------------------------------------------------------------------- -# 更新测试 -# --------------------------------------------------------------------------- - - -class TestUpdatePlan: - def _seed_plan(self, repo, name="原计划", template_id="tpl-001"): - plan = EditPlan.create(template_id, name) - repo.create(plan) - return plan - - def test_update_name(self, client): - c, repo = client - plan = self._seed_plan(repo) - - resp = c.put(f"/api/v1/edit-plans/{plan.id}", json={"name": "新名称"}) - assert resp.status_code == 200 - assert resp.json()["name"] == "新名称" - - def test_update_config(self, client): - c, repo = client - plan = self._seed_plan(repo) - - resp = c.put( - f"/api/v1/edit-plans/{plan.id}", - json={"config": {"bgm": "sad", "transition": "fade"}}, - ) - assert resp.status_code == 200 - assert resp.json()["config"] == normalize_plan_config({"bgm": "sad", "transition": "fade"}) - - def test_update_total_duration(self, client): - c, repo = client - plan = self._seed_plan(repo) - - resp = c.put(f"/api/v1/edit-plans/{plan.id}", json={"total_duration": 120.5}) - assert resp.status_code == 200 - assert resp.json()["total_duration"] == 120.5 - - def test_update_status_draft_to_editing(self, client): - c, repo = client - plan = self._seed_plan(repo) - assert plan.status == EditPlanStatus.DRAFT - - resp = c.put(f"/api/v1/edit-plans/{plan.id}", json={"status": "editing"}) - assert resp.status_code == 200 - assert resp.json()["status"] == "editing" - - def test_update_status_full_happy_path(self, client): - c, repo = client - plan = self._seed_plan(repo) - - # draft → editing - resp = c.put(f"/api/v1/edit-plans/{plan.id}", json={"status": "editing"}) - assert resp.json()["status"] == "editing" - - # editing → rendering - resp = c.put(f"/api/v1/edit-plans/{plan.id}", json={"status": "rendering"}) - assert resp.json()["status"] == "rendering" - - # rendering → completed - resp = c.put(f"/api/v1/edit-plans/{plan.id}", json={"status": "completed"}) - assert resp.json()["status"] == "completed" - - def test_update_status_failure_and_reset(self, client): - c, repo = client - plan = self._seed_plan(repo) - - # draft → editing → rendering → failed - c.put(f"/api/v1/edit-plans/{plan.id}", json={"status": "editing"}) - c.put(f"/api/v1/edit-plans/{plan.id}", json={"status": "rendering"}) - resp = c.put(f"/api/v1/edit-plans/{plan.id}", json={"status": "failed"}) - assert resp.json()["status"] == "failed" - - # failed → draft (reset) - resp = c.put(f"/api/v1/edit-plans/{plan.id}", json={"status": "draft"}) - assert resp.json()["status"] == "draft" - - def test_update_invalid_transition_returns_400(self, client): - c, repo = client - plan = self._seed_plan(repo) - - # draft → rendering 不合法 - resp = c.put(f"/api/v1/edit-plans/{plan.id}", json={"status": "rendering"}) - assert resp.status_code == 400 - - def test_update_draft_to_completed_returns_400(self, client): - c, repo = client - plan = self._seed_plan(repo) - - # draft → completed 不合法 - resp = c.put(f"/api/v1/edit-plans/{plan.id}", json={"status": "completed"}) - assert resp.status_code == 400 - - def test_update_invalid_status_value_returns_400(self, client): - c, repo = client - plan = self._seed_plan(repo) - - resp = c.put(f"/api/v1/edit-plans/{plan.id}", json={"status": "bogus"}) - assert resp.status_code == 400 - assert "无效" in resp.json()["detail"] - - def test_update_same_status_is_noop(self, client): - c, repo = client - plan = self._seed_plan(repo) - - resp = c.put(f"/api/v1/edit-plans/{plan.id}", json={"status": "draft"}) - assert resp.status_code == 200 - assert resp.json()["status"] == "draft" - - def test_update_not_found_returns_404(self, client): - c, repo = client - resp = c.put("/api/v1/edit-plans/nonexistent", json={"name": "x"}) - assert resp.status_code == 404 - - def test_update_combined_fields_and_status(self, client): - c, repo = client - plan = self._seed_plan(repo) - - resp = c.put( - f"/api/v1/edit-plans/{plan.id}", - json={"name": "新名称", "status": "editing", "total_duration": 90.0}, - ) - assert resp.status_code == 200 - data = resp.json() - assert data["name"] == "新名称" - assert data["status"] == "editing" - assert data["total_duration"] == 90.0 - - -# --------------------------------------------------------------------------- -# 删除测试 -# --------------------------------------------------------------------------- - - -class TestDeletePlan: - def test_delete_success(self, client): - c, repo = client - plan = EditPlan.create("tpl-001", "待删除") - repo.create(plan) - - resp = c.delete(f"/api/v1/edit-plans/{plan.id}") - assert resp.status_code == 204 - assert repo.get(plan.id) is None - - def test_delete_not_found_returns_404(self, client): - c, repo = client - resp = c.delete("/api/v1/edit-plans/nonexistent") - assert resp.status_code == 404 - assert "剪辑计划不存在" in resp.json()["detail"] - - -# --------------------------------------------------------------------------- -# BGM 配置测试 -# --------------------------------------------------------------------------- - - -class TestBGMConfig: - """BGM 配置 API 测试""" - - def test_get_bgm_default_empty(self, client): - """新计划 BGM 默认为空""" - c, repo = client - plan = EditPlan.create("tpl-001", "测试") - repo.create(plan) - - resp = c.get(f"/api/v1/edit-plans/{plan.id}/bgm") - assert resp.status_code == 200 - data = resp.json() - assert data["plan_id"] == plan.id - assert data["bgm"] == {} - - def test_update_bgm_volume(self, client): - """更新 BGM 音量""" - c, repo = client - plan = EditPlan.create("tpl-001", "测试") - repo.create(plan) - - resp = c.put( - f"/api/v1/edit-plans/{plan.id}/bgm", - json={"volume": 0.5, "fade_in": 2.0, "fade_out": 3.0}, - ) - assert resp.status_code == 200 - data = resp.json() - assert data["bgm"]["volume"] == 0.5 - assert data["bgm"]["fade_in"] == 2.0 - assert data["bgm"]["fade_out"] == 3.0 - - def test_enable_bgm_with_preset(self, client): - """启用 BGM 并指定 preset_id""" - c, repo = client - plan = EditPlan.create("tpl-001", "测试") - repo.create(plan) - - resp = c.put( - f"/api/v1/edit-plans/{plan.id}/bgm", - json={ - "enabled": True, - "source": "library", - "preset_id": "bgm_upbeat_001", - "volume": 0.3, - }, - ) - assert resp.status_code == 200 - data = resp.json() - assert data["bgm"]["enabled"] is True - assert data["bgm"]["preset_id"] == "bgm_upbeat_001" - - def test_enable_bgm_without_source_returns_400(self, client): - """启用 BGM 但不指定来源,返回 400""" - c, repo = client - plan = EditPlan.create("tpl-001", "测试") - repo.create(plan) - - resp = c.put( - f"/api/v1/edit-plans/{plan.id}/bgm", - json={"enabled": True, "volume": 0.3}, - ) - assert resp.status_code == 400 - assert "素材来源" in resp.json()["detail"] - - def test_enable_bgm_with_asset_id(self, client): - """启用 BGM 并指定 asset_id""" - c, repo = client - plan = EditPlan.create("tpl-001", "测试") - repo.create(plan) - - resp = c.put( - f"/api/v1/edit-plans/{plan.id}/bgm", - json={ - "enabled": True, - "source": "upload", - "asset_id": "asset-audio-001", - "loop_enabled": True, - }, - ) - assert resp.status_code == 200 - data = resp.json() - assert data["bgm"]["enabled"] is True - assert data["bgm"]["asset_id"] == "asset-audio-001" - assert data["bgm"]["loop_enabled"] is True - - def test_update_bgm_not_found(self, client): - """不存在的计划返回 404""" - c, _ = client - resp = c.put( - "/api/v1/edit-plans/nonexistent/bgm", - json={"volume": 0.5}, - ) - assert resp.status_code == 404 - - def test_get_bgm_not_found(self, client): - """不存在的计划返回 404""" - c, _ = client - resp = c.get("/api/v1/edit-plans/nonexistent/bgm") - assert resp.status_code == 404 - - def test_partial_update_preserves_existing(self, client): - """部分更新保留原有配置""" - c, repo = client - plan = EditPlan.create("tpl-001", "测试") - plan.config = {"bgm": {"volume": 0.5, "fade_in": 1.0}} - repo.create(plan) - - # 只改音量 - resp = c.put( - f"/api/v1/edit-plans/{plan.id}/bgm", - json={"volume": 0.8}, - ) - assert resp.status_code == 200 - data = resp.json() - assert data["bgm"]["volume"] == 0.8 - assert data["bgm"]["fade_in"] == 1.0 # 保留 - - def test_sidechain_config(self, client): - """人声闪避配置更新""" - c, repo = client - plan = EditPlan.create("tpl-001", "测试") - repo.create(plan) - - resp = c.put( - f"/api/v1/edit-plans/{plan.id}/bgm", - json={ - "enabled": True, - "preset_id": "bgm_relax_001", - "sidechain_enabled": True, - "sidechain_ratio": 0.4, - }, - ) - assert resp.status_code == 200 - data = resp.json() - assert data["bgm"]["sidechain_enabled"] is True - assert data["bgm"]["sidechain_ratio"] == 0.4 - - -# --------------------------------------------------------------------------- -# BGM 预设库测试 -# --------------------------------------------------------------------------- - - -class TestBGMPresets: - """BGM 预设列表 API 测试""" - - def test_list_all_presets(self, client): - """获取所有预设 BGM""" - c, _ = client - resp = c.get("/api/v1/edit-plans/bgm/presets") - assert resp.status_code == 200 - data = resp.json() - assert "items" in data - assert "total" in data - assert "styles" in data - assert data["total"] >= 10 # 至少有 10 首预设 - assert len(data["items"]) == data["total"] - - def test_filter_by_style(self, client): - """按风格筛选""" - c, _ = client - resp = c.get("/api/v1/edit-plans/bgm/presets?style=upbeat") - assert resp.status_code == 200 - data = resp.json() - assert data["total"] >= 3 - for item in data["items"]: - assert item["style"] == "upbeat" - - def test_search_by_keyword(self, client): - """关键词搜索""" - c, _ = client - resp = c.get("/api/v1/edit-plans/bgm/presets?keyword=钢琴") - assert resp.status_code == 200 - data = resp.json() - assert data["total"] >= 1 - for item in data["items"]: - has_piano = ( - "钢琴" in item["name"] or "钢琴" in item["description"] or any("钢琴" in tag for tag in item["tags"]) - ) - assert has_piano - - def test_pagination(self, client): - """分页功能""" - c, _ = client - resp = c.get("/api/v1/edit-plans/bgm/presets?skip=0&limit=3") - assert resp.status_code == 200 - data = resp.json() - assert len(data["items"]) == 3 - assert data["skip"] == 0 - assert data["limit"] == 3 - - def test_preset_structure(self, client): - """预设条目字段完整""" - c, _ = client - resp = c.get("/api/v1/edit-plans/bgm/presets?limit=1") - assert resp.status_code == 200 - item = resp.json()["items"][0] - - assert "id" in item - assert "name" in item - assert "style" in item - assert "style_label" in item - assert "duration" in item - assert "artist" in item - assert "description" in item - assert "tags" in item - assert isinstance(item["tags"], list) diff --git a/tests/unit/test_edit_template_service.py b/tests/unit/test_edit_template_service.py index 5a1d30f2d..4846b6b59 100755 --- a/tests/unit/test_edit_template_service.py +++ b/tests/unit/test_edit_template_service.py @@ -167,7 +167,7 @@ class StubTemplateClipConfigRepository: def delete(self, config_id: str) -> bool: return self._configs.pop(config_id, None) is not None - def delete_by_template(self, template_id: str) -> int: + def delete_by_template(self, template_id: str, *, commit: bool = True) -> int: ids = [cid for cid, c in self._configs.items() if c.template_id == template_id] for cid in ids: del self._configs[cid]