diff --git a/apps/api/app/api/routes/edit_plans.py b/apps/api/app/api/routes/edit_plans.py index b5f6b775a..33412b4a7 100755 --- a/apps/api/app/api/routes/edit_plans.py +++ b/apps/api/app/api/routes/edit_plans.py @@ -460,9 +460,11 @@ def delete_plan( # ── Include sub-routers (拆分模块) ──────────────────────────────────────────── from .edit_plans_ai import router as ai_router +from .edit_plans_clips import router as clips_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"]) diff --git a/apps/api/app/api/routes/edit_plans_clips.py b/apps/api/app/api/routes/edit_plans_clips.py new file mode 100755 index 000000000..544ec6d46 --- /dev/null +++ b/apps/api/app/api/routes/edit_plans_clips.py @@ -0,0 +1,275 @@ +"""剪辑计划片段(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 + +logger = logging.getLogger(__name__) + +router = APIRouter() + + +# ── 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 ._helpers import check_project_access + from app.services.edit_plan_service import EditPlanService + + 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) +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) +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) +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) +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) +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