f425ef09c7
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
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 / Build Staging Web Image (push) Successful in 22s
CI/CD Pipeline / Frontend Lint (push) Successful in 36s
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 3m8s
CI/CD Pipeline / Unit Tests (push) Successful in 3m11s
CI/CD Pipeline / Integration Tests (push) Successful in 1m24s
CI Build & Deploy Pipeline / Build Staging API Image (push) Successful in 7m25s
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Successful in 8m16s
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m7s
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Successful in 3m38s
CI Build & Deploy Pipeline / Staging E2E Tests (push) Failing after 3m48s
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com> Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
1089 lines
38 KiB
Python
Executable File
1089 lines
38 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, 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 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 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:
|
||
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,
|
||
)
|
||
|
||
|
||
# ── 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)
|
||
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:
|
||
"""创建剪辑计划
|
||
|
||
基于模板自动生成片段结构:
|
||
- 模板存在时:从模板的 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)
|
||
|
||
generator = PlanGeneratorService(db)
|
||
try:
|
||
result = generator.generate_from_template(
|
||
template=template,
|
||
clip_configs=clip_configs,
|
||
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,
|
||
)
|
||
|
||
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)
|
||
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,
|
||
)
|
||
|
||
|
||
@router.post("/{plan_id}/copy", response_model=EditPlanResponse, status_code=status.HTTP_201_CREATED)
|
||
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="获取片段的所有字幕",
|
||
)
|
||
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,
|
||
)
|
||
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="更新一条字幕",
|
||
)
|
||
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,
|
||
)
|
||
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="批量更新字幕(全量替换)",
|
||
)
|
||
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 配置",
|
||
)
|
||
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 配置",
|
||
)
|
||
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 列表",
|
||
)
|
||
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,
|
||
)
|
||
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)
|