018358cbb1
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
469 lines
16 KiB
Python
Executable File
469 lines
16 KiB
Python
Executable File
"""剪辑计划管理 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.dependencies import get_db_session, get_project_repository
|
||
from app.schemas.generation_task import GenerationTaskResponse
|
||
from app.services import EditPlanService
|
||
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 check_project_access
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
router = APIRouter()
|
||
|
||
|
||
# ── 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")
|
||
|
||
|
||
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 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:
|
||
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=p.config,
|
||
created_at=p.created_at,
|
||
updated_at=p.updated_at,
|
||
)
|
||
|
||
|
||
# ── CRUD Routes ───────────────────────────────────────────────────────────────
|
||
|
||
|
||
@router.get("", response_model=EditPlanListResponse)
|
||
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)
|
||
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)
|
||
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),
|
||
) -> EditPlanResponse:
|
||
"""创建剪辑计划"""
|
||
# 空串 project_id 统一为 ""
|
||
project_id = (body.project_id or "").strip()
|
||
# 项目鉴权
|
||
if project_id:
|
||
check_project_access(project_id, current_user.user.id, project_repository)
|
||
svc = EditPlanService(db)
|
||
# 标准化 config,填充 cover/title/subtitle/bgm 默认值
|
||
normalized_config = normalize_plan_config(body.config)
|
||
try:
|
||
created = svc.create_plan(
|
||
template_id=body.template_id,
|
||
name=body.name,
|
||
config=normalized_config,
|
||
total_duration=body.total_duration,
|
||
project_id=project_id,
|
||
created_by_user_id=current_user.user.id,
|
||
)
|
||
except ValueError as exc:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_400_BAD_REQUEST,
|
||
detail=str(exc),
|
||
) from exc
|
||
logger.info(
|
||
"创建剪辑计划: id=%s name=%s by user=%s",
|
||
created.id,
|
||
created.name,
|
||
current_user.user.id,
|
||
)
|
||
return _to_response(created)
|
||
|
||
|
||
@router.put("/{plan_id}", response_model=EditPlanResponse)
|
||
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)
|
||
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,
|
||
)
|
||
|
||
|
||
# ── Include sub-routers (拆分模块) ────────────────────────────────────────────
|
||
|
||
from .edit_plans_ai import router as ai_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)
|