Files
xiaoxia-saas/apps/api/app/api/routes/edit_plans.py
T
CI Bot 49665496aa
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Has been cancelled
CI/CD Pipeline / Frontend Lint (pull_request) Has been cancelled
feat: decouple API and Worker cross-service dependencies
2026-06-27 10:22:25 +08:00

306 lines
12 KiB
Python

from datetime import datetime, timezone
from uuid import uuid4
from app.api.routes.permissions import require_workspace_member
from app.auth import AuthenticatedUser, get_current_user
from app.dependencies import (
get_asset_repository,
get_db_session,
get_project_repository,
get_workspace_member_repository,
)
from app.schemas.edit_plan import (
AutoGenerateEditPlanRequest,
CreateEditPlanRequest,
EditPlanClipResponse,
EditPlanResponse,
EditTemplateResponse,
)
from packages.domain.edit_plan import EditingMode, SmartEditPlanGenerator
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from packages.adapters.sqlalchemy_impl.models import EditPlanClipModel, EditPlanModel, EditTemplateModel
from packages.domain import AssetStatus
from packages.ports.workspace_member_repository import WorkspaceMemberRepository
router = APIRouter(prefix="/projects/{project_id}/edit-plans", tags=["剪辑计划"])
def _ensure_project(project_id: str, workspace_id: str, user: AuthenticatedUser, project_repository, member_repository):
project = project_repository.find_by_id(project_id)
if project is None or project.workspace_id != workspace_id:
raise HTTPException(status_code=404, detail="Project not found")
require_workspace_member(workspace_id, user, member_repository)
return project
def _default_template(session: Session, workspace_id: str, project_id: str, user_id: str) -> EditTemplateModel:
template = (
session.query(EditTemplateModel)
.filter(
EditTemplateModel.project_id == project_id,
EditTemplateModel.is_active.is_(True),
)
.order_by(EditTemplateModel.created_at.asc())
.first()
)
if template is not None:
return template
template = EditTemplateModel(
id=uuid4().hex,
workspace_id=workspace_id,
project_id=project_id,
name="基础节奏模板",
description="自动选择可用视频素材,按上传顺序生成三段式剪辑计划。",
target_duration=30,
clip_count=3,
created_by_user_id=user_id,
)
session.add(template)
session.commit()
return template
def _to_template_response(template: EditTemplateModel) -> EditTemplateResponse:
return EditTemplateResponse(
id=template.id,
workspace_id=template.workspace_id,
project_id=template.project_id,
name=template.name,
description=template.description,
target_duration=float(template.target_duration or 0),
clip_count=int(template.clip_count or 0),
is_active=bool(template.is_active),
created_at=template.created_at,
)
def _to_plan_response(
plan: EditPlanModel, clips: list[EditPlanClipModel], asset_names: dict[str, str]
) -> EditPlanResponse:
return EditPlanResponse(
id=plan.id,
workspace_id=plan.workspace_id,
project_id=plan.project_id,
template_id=plan.template_id,
asset_library_id=plan.asset_library_id,
title_id=plan.title_id,
status=plan.status,
summary=plan.summary,
editing_mode=plan.editing_mode,
clips=[
EditPlanClipResponse(
id=clip.id,
asset_id=clip.asset_id,
asset_name=asset_names.get(clip.asset_id, clip.asset_id),
sequence=clip.sequence,
start_time=float(clip.start_time or 0),
duration=float(clip.duration or 0),
reason=clip.reason,
layer=clip.layer,
)
for clip in clips
],
created_at=plan.created_at,
updated_at=plan.updated_at,
)
@router.get("/templates/", response_model=list[EditTemplateResponse])
def list_edit_templates(
project_id: str,
workspace_id: str,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
project_repository=Depends(get_project_repository),
workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository),
session: Session = Depends(get_db_session),
) -> list[EditTemplateResponse]:
_ensure_project(project_id, workspace_id, authenticated_user, project_repository, workspace_member_repository)
template = _default_template(session, workspace_id, project_id, authenticated_user.user.id)
templates = (
session.query(EditTemplateModel)
.filter(EditTemplateModel.project_id == project_id, EditTemplateModel.is_active.is_(True))
.all()
)
return [_to_template_response(item) for item in templates or [template]]
@router.post("", response_model=EditPlanResponse)
def create_edit_plan(
project_id: str,
request: CreateEditPlanRequest,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
project_repository=Depends(get_project_repository),
asset_repository=Depends(get_asset_repository),
workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository),
session: Session = Depends(get_db_session),
) -> EditPlanResponse:
_ensure_project(
project_id, request.workspace_id, authenticated_user, project_repository, workspace_member_repository
)
template = (
session.query(EditTemplateModel).filter(EditTemplateModel.id == request.template_id).first()
if request.template_id
else None
)
if template is None:
template = _default_template(session, request.workspace_id, project_id, authenticated_user.user.id)
assets = [
asset
for asset in asset_repository.list_by_library(request.asset_library_id)
if asset.status == AssetStatus.READY and asset.mime_type.startswith("video/")
]
if not assets:
raise HTTPException(status_code=422, detail="素材库暂无可用于剪辑计划的视频素材")
selected = sorted(assets, key=lambda asset: (-(asset.quality_score or 0), asset.created_at))[
: max(1, int(template.clip_count or 3))
]
plan = EditPlanModel(
id=uuid4().hex,
workspace_id=request.workspace_id,
project_id=project_id,
template_id=template.id,
asset_library_id=request.asset_library_id,
title_id=request.title_id,
status="draft",
summary=f"按《{template.name}》自动选择 {len(selected)} 段素材,预计生成约 {int(template.target_duration or 30)} 秒成片。",
created_by_user_id=authenticated_user.user.id,
updated_at=datetime.now(timezone.utc),
)
session.add(plan)
clips: list[EditPlanClipModel] = []
clip_duration = max(1, float(template.target_duration or 30) / len(selected))
for index, asset in enumerate(selected, start=1):
clip = EditPlanClipModel(
id=uuid4().hex,
edit_plan_id=plan.id,
asset_id=asset.id,
sequence=index,
start_time=0,
duration=min(float(asset.duration or clip_duration), clip_duration),
reason="优先选择已就绪、质量分较高的视频素材。",
)
session.add(clip)
clips.append(clip)
session.commit()
return _to_plan_response(plan, clips, {asset.id: asset.name for asset in selected})
@router.post("/auto-generate", response_model=EditPlanResponse)
def auto_generate_edit_plan(
project_id: str,
request: AutoGenerateEditPlanRequest,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
project_repository=Depends(get_project_repository),
asset_repository=Depends(get_asset_repository),
workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository),
session: Session = Depends(get_db_session),
) -> EditPlanResponse:
"""
智能生成剪辑计划
根据素材的分类结果和质量评分,自动编排剪辑计划。
支持多种剪辑模式:
- one_take: 按分类分组,组内按质量排序,顺序拼接
- pip: 第一个高质量素材为主画面,其余为画中画
- voice_over: person 类素材为主播口播,其余穿插为 B-roll
- voice_pip: 结合 voice_over 和 pip,第一个高质量 person 素材为主画面
"""
_ensure_project(
project_id, request.workspace_id, authenticated_user, project_repository, workspace_member_repository
)
# 获取素材库中的所有素材
assets = asset_repository.list_by_library(request.asset_library_id)
if not assets:
raise HTTPException(status_code=422, detail="素材库中暂无素材")
# 使用智能生成器
generator = SmartEditPlanGenerator(project_id, assets)
try:
plan_result = generator.generate_plan(
editing_mode=request.editing_mode,
target_duration=request.target_duration
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
if not plan_result.clips:
raise HTTPException(status_code=422, detail="无符合条件的视频素材")
# 获取模板
template = (
session.query(EditTemplateModel).filter(EditTemplateModel.id == request.template_id).first()
if request.template_id
else None
)
if template is None:
template = _default_template(session, request.workspace_id, project_id, authenticated_user.user.id)
# 创建剪辑计划
plan = EditPlanModel(
id=uuid4().hex,
workspace_id=request.workspace_id,
project_id=project_id,
template_id=template.id,
asset_library_id=request.asset_library_id,
title_id=request.title_id or "",
status="draft",
editing_mode=request.editing_mode,
summary=plan_result.summary,
created_by_user_id=authenticated_user.user.id,
updated_at=datetime.now(timezone.utc),
)
session.add(plan)
# 创建剪辑片段
clips: list[EditPlanClipModel] = []
asset_name_map = {asset.id: asset.name for asset in assets}
for clip_plan in plan_result.clips:
clip = EditPlanClipModel(
id=uuid4().hex,
edit_plan_id=plan.id,
asset_id=clip_plan.asset_id,
sequence=clip_plan.sequence,
start_time=clip_plan.start_time,
duration=clip_plan.duration,
reason=clip_plan.reason,
layer=clip_plan.layer,
)
session.add(clip)
clips.append(clip)
session.commit()
return _to_plan_response(plan, clips, asset_name_map)
@router.get("/{plan_id}", response_model=EditPlanResponse)
def get_edit_plan(
project_id: str,
plan_id: str,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
project_repository=Depends(get_project_repository),
asset_repository=Depends(get_asset_repository),
workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository),
session: Session = Depends(get_db_session),
) -> EditPlanResponse:
plan = (
session.query(EditPlanModel).filter(EditPlanModel.id == plan_id, EditPlanModel.project_id == project_id).first()
)
if plan is None:
raise HTTPException(status_code=404, detail="Edit plan not found")
_ensure_project(project_id, plan.workspace_id, authenticated_user, project_repository, workspace_member_repository)
clips = (
session.query(EditPlanClipModel)
.filter(EditPlanClipModel.edit_plan_id == plan.id)
.order_by(EditPlanClipModel.sequence.asc())
.all()
)
assets = asset_repository.list_by_library(plan.asset_library_id)
return _to_plan_response(plan, clips, {asset.id: asset.name for asset in assets})