09d2b12ea8
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Successful in 57s
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 3m7s
CI/CD Pipeline / Unit Tests (push) Successful in 3m13s
CI/CD Pipeline / Integration Tests (push) Successful in 1m22s
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m32s
CI Build & Deploy Pipeline / Build Staging API Image (push) Successful in 18m38s
CI Build & Deploy Pipeline / Build Staging Web Image (push) Successful in 19s
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Successful in 8m7s
CI Build & Deploy Pipeline / Build Production API Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Production Web Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Production Worker Image (push) Has been skipped
CI Build & Deploy Pipeline / Deploy Production (push) Has been skipped
CI Build & Deploy Pipeline / Production Browser E2E (push) Has been skipped
CI Build & Deploy Pipeline / Staging E2E Tests (push) Successful in 2m16s
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Successful in 4m35s
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com> Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
242 lines
7.6 KiB
Python
Executable File
242 lines
7.6 KiB
Python
Executable File
"""剪辑计划片段批量操作 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
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
# ── 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)
|
|
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)
|
|
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)
|
|
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,
|
|
)
|