ed972a230c
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 1m10s
CI/CD Pipeline / Frontend Lint (push) Successful in 2m34s
CI/CD Pipeline / Build Production Runtime Images (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Build & Push Staging (Watchtower auto-deploy) (push) Successful in 3m40s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 1m51s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 5m1s
1206 lines
43 KiB
Python
1206 lines
43 KiB
Python
"""剪辑计划管理 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} 删除
|
||
- POST /api/v1/edit-plans/{id}/generate 触发剪辑渲染生成(任务 2.05)
|
||
- GET /api/v1/edit-plans/{id}/generation-status 查询生成进度(任务 2.05)
|
||
- POST /api/v1/edit-plans/{id}/ai-recommend AI 推荐片段方案(任务 3.09)
|
||
- POST /api/v1/edit-plans/{id}/generate-cover AI 生成封面(任务 3.09)
|
||
- GET /api/v1/edit-plans/{id}/timeline 时间线场景数据
|
||
- POST /api/v1/edit-plans/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.core.celery_app import celery_app
|
||
from app.dependencies import get_asset_library_repository, get_asset_repository, get_db_session, get_project_repository
|
||
from app.schemas.generation_task import GenerationTaskResponse
|
||
from app.services import EditPlanService, PlanGeneratorService
|
||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||
from pydantic import BaseModel, Field
|
||
from sqlalchemy.orm import Session
|
||
|
||
from packages.adapters.sqlalchemy_impl.asset_library_repository import (
|
||
SQLAlchemyAssetLibraryRepository,
|
||
)
|
||
from packages.adapters.sqlalchemy_impl.asset_repository import (
|
||
SQLAlchemyAssetRepository,
|
||
)
|
||
from packages.adapters.sqlalchemy_impl.generation_task_repository import (
|
||
SQLAlchemyGenerationTaskRepository,
|
||
)
|
||
from packages.adapters.sqlalchemy_impl.template_clip_config_repository import (
|
||
SQLAlchemyTemplateClipConfigRepository,
|
||
)
|
||
from packages.adapters.sqlalchemy_impl.template_repository import (
|
||
SQLAlchemyTemplateRepository,
|
||
)
|
||
from packages.application.generation_tasks import (
|
||
CreateGenerationTaskCommand,
|
||
CreateGenerationTaskUseCase,
|
||
)
|
||
from packages.domain.config_schemas import normalize_plan_config
|
||
from packages.domain.edit_plan import EditPlan, EditPlanStatus
|
||
|
||
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
|
||
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
|
||
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="转场效果")
|
||
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
|
||
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 _check_project_access(project_id: str, user_id: str, project_repository: Any) -> None:
|
||
"""校验用户对项目的访问权限(参照 assets.py 的 can_access 模式)"""
|
||
if not project_id or not project_id.strip():
|
||
return
|
||
project = project_repository.find_by_id(project_id)
|
||
if project is None:
|
||
raise HTTPException(status_code=404, detail="项目不存在")
|
||
if not project.can_access(user_id):
|
||
raise HTTPException(status_code=403, detail="无权访问该项目")
|
||
|
||
|
||
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,
|
||
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,
|
||
)
|
||
|
||
|
||
# ── 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:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_400_BAD_REQUEST,
|
||
detail="无效的筛选条件,请选择正确的状态",
|
||
)
|
||
|
||
# 项目鉴权:如果指定了 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),
|
||
)
|
||
# 项目鉴权
|
||
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),
|
||
)
|
||
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:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_400_BAD_REQUEST,
|
||
detail="无效的状态值,请选择正确的状态",
|
||
)
|
||
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,
|
||
)
|
||
raise HTTPException(
|
||
status_code=status.HTTP_400_BAD_REQUEST,
|
||
detail=err_msg,
|
||
)
|
||
|
||
# 返回最新状态
|
||
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)
|
||
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,
|
||
)
|
||
|
||
|
||
# ── 生成相关端点(任务 2.05) ─────────────────────────────────────────────────
|
||
|
||
|
||
@router.post("/{plan_id}/generate", response_model=EditPlanGenerateResponse)
|
||
def generate_plan(
|
||
plan_id: str,
|
||
db: Session = Depends(get_db_session),
|
||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||
project_repository: Any = Depends(get_project_repository),
|
||
asset_library_repo: Any = Depends(get_asset_library_repository),
|
||
asset_repo: Any = Depends(get_asset_repository),
|
||
) -> EditPlanGenerateResponse:
|
||
"""触发剪辑计划渲染生成
|
||
|
||
前置条件:计划状态必须为 editing,且至少有一个片段。
|
||
流程:
|
||
1. 验证计划状态为 editing
|
||
2. 将 pending 片段标记为 ready
|
||
3. 创建 GenerationTask
|
||
4. 调度 Celery 任务 worker.render_edit_plan
|
||
5. 将计划状态流转为 rendering
|
||
"""
|
||
svc = EditPlanService(db)
|
||
# 项目鉴权
|
||
plan_check = svc.get_plan(plan_id)
|
||
if plan_check is None:
|
||
raise HTTPException(status_code=404, detail=f"剪辑计划不存在: {plan_id}")
|
||
if plan_check.project_id:
|
||
_check_project_access(plan_check.project_id, current_user.user.id, project_repository)
|
||
|
||
# ── 自动兜底 1: draft → editing ──────────────────────────────────────
|
||
if plan_check.status == EditPlanStatus.DRAFT:
|
||
logger.info("自动兜底: plan=%s draft→editing", plan_id)
|
||
svc.transition_status(plan_id, EditPlanStatus.EDITING)
|
||
|
||
# ── 自动兜底 2: 无片段 + 有 template_id → 从模板复制片段配置 ──────────
|
||
existing_clips = svc.count_clips(plan_id)
|
||
if existing_clips == 0 and plan_check.template_id:
|
||
logger.info(
|
||
"自动兜底: plan=%s 无片段,从模板 %s 复制片段配置",
|
||
plan_id,
|
||
plan_check.template_id,
|
||
)
|
||
# 优先从新模型 template_clip_configs 读取,若无则回退到旧模型 template_segments
|
||
clip_config_repo = SQLAlchemyTemplateClipConfigRepository(db)
|
||
configs = clip_config_repo.list_by_template(plan_check.template_id)
|
||
if configs:
|
||
for cfg in configs:
|
||
svc.create_clip(
|
||
plan_id=plan_id,
|
||
clip_type=cfg.clip_type.value if hasattr(cfg.clip_type, "value") else cfg.clip_type,
|
||
order=cfg.order,
|
||
template_clip_config_id=cfg.id,
|
||
duration=cfg.default_duration,
|
||
transition_effect=(
|
||
cfg.transition_effect.value
|
||
if hasattr(cfg.transition_effect, "value")
|
||
else cfg.transition_effect
|
||
),
|
||
)
|
||
logger.info("自动兜底: plan=%s 从新模型 template_clip_configs 复制了 %d 个片段", plan_id, len(configs))
|
||
else:
|
||
# 回退到旧模型 template_segments
|
||
tpl_repo = SQLAlchemyTemplateRepository(db)
|
||
segments = tpl_repo.list_segments(plan_check.template_id)
|
||
for seg in segments:
|
||
avg_duration = (seg.duration_min + seg.duration_max) / 2
|
||
svc.create_clip(
|
||
plan_id=plan_id,
|
||
clip_type="main", # 旧模型无结构角色,统一为主体片段
|
||
order=seg.segment_order,
|
||
duration=avg_duration,
|
||
config={
|
||
"material_type": seg.material_type or "",
|
||
"template_segment_id": seg.id,
|
||
},
|
||
)
|
||
logger.info("自动兜底: plan=%s 从旧模型 template_segments 复制了 %d 个片段", plan_id, len(segments))
|
||
|
||
# ── 自动兜底 3: 为没有素材的片段分配素材 ──────────────────────────
|
||
# 如果 plan.config.asset_ids 有素材,但 clips 没有 asset_id,自动按顺序分配
|
||
all_clips = svc.list_clips(plan_id)
|
||
clips_without_asset = [c for c in all_clips if not c.asset_id]
|
||
config_asset_ids = (plan_check.config or {}).get("asset_ids", [])
|
||
material_mode = (plan_check.config or {}).get("material_mode", "manual")
|
||
|
||
if clips_without_asset and config_asset_ids:
|
||
logger.info(
|
||
"自动兜底3: plan=%s 为 %d 个无素材片段分配 %d 个指定素材",
|
||
plan_id,
|
||
len(clips_without_asset),
|
||
len(config_asset_ids),
|
||
)
|
||
for i, clip in enumerate(clips_without_asset):
|
||
asset_idx = i % len(config_asset_ids)
|
||
svc.assign_asset(clip.id, config_asset_ids[asset_idx])
|
||
logger.info("自动兜底3: plan=%s 素材分配完成", plan_id)
|
||
clips_without_asset = [] # 已分配完
|
||
|
||
# ── 自动兜底 4: 自动素材模式 → 从项目默认视频素材库选取 ────────────
|
||
if clips_without_asset and material_mode == "auto" and plan_check.project_id:
|
||
import random
|
||
|
||
logger.info(
|
||
"自动兜底4: plan=%s 自动素材模式,从项目素材库选取素材 (%d 个片段需要)",
|
||
plan_id,
|
||
len(clips_without_asset),
|
||
)
|
||
# 找到项目的视频素材库
|
||
libs = asset_library_repo.find_by_project(plan_check.project_id)
|
||
video_lib = None
|
||
for lib in libs:
|
||
lib_kind = lib.kind.value if hasattr(lib.kind, "value") else lib.kind
|
||
if lib_kind == "video":
|
||
video_lib = lib
|
||
break
|
||
|
||
if video_lib:
|
||
assets = asset_repo.find_by_library(video_lib.id)
|
||
# 筛选 ready 状态的视频素材
|
||
ready_videos = [
|
||
a
|
||
for a in assets
|
||
if (a.status.value if hasattr(a.status, "value") else a.status) == "ready"
|
||
and a.mime_type
|
||
and a.mime_type.startswith("video")
|
||
]
|
||
if ready_videos:
|
||
# 随机选取,按片段数轮询分配
|
||
random.shuffle(ready_videos)
|
||
for i, clip in enumerate(clips_without_asset):
|
||
asset = ready_videos[i % len(ready_videos)]
|
||
svc.assign_asset(clip.id, asset.id)
|
||
logger.info(
|
||
"自动兜底4: plan=%s 从素材库 %s 分配了 %d 个素材给 %d 个片段",
|
||
plan_id,
|
||
video_lib.name,
|
||
len(ready_videos),
|
||
len(clips_without_asset),
|
||
)
|
||
else:
|
||
logger.warning("自动兜底4: plan=%s 素材库无可用视频素材", plan_id)
|
||
else:
|
||
logger.warning("自动兜底4: plan=%s 项目无视频素材库", plan_id)
|
||
|
||
# 检查是否可生成
|
||
try:
|
||
can_gen, reason = svc.can_generate(plan_id)
|
||
except ValueError as exc:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_404_NOT_FOUND,
|
||
detail=str(exc),
|
||
)
|
||
if not can_gen:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_400_BAD_REQUEST,
|
||
detail=reason,
|
||
)
|
||
|
||
# 核心生成流程:捕获异常返回明确错误信息,避免裸 500
|
||
try:
|
||
# 将 pending 片段标记为 ready
|
||
clip_count = svc.mark_clips_ready(plan_id)
|
||
|
||
# 创建 GenerationTask
|
||
gen_task_repo = SQLAlchemyGenerationTaskRepository(db)
|
||
gen_task_use_case = CreateGenerationTaskUseCase(gen_task_repo)
|
||
plan = svc.get_plan_or_raise(plan_id)
|
||
gen_task = gen_task_use_case.execute(
|
||
CreateGenerationTaskCommand(
|
||
project_id="",
|
||
template_id=plan.template_id,
|
||
created_by_user_id=current_user.user.id,
|
||
source_edit_plan_id=plan_id,
|
||
)
|
||
)
|
||
|
||
# 将 generation_task_id 存入 plan config
|
||
svc.update_plan_config(plan_id, {"generation_task_id": gen_task.id})
|
||
|
||
# 流转状态为 rendering
|
||
svc.transition_status(plan_id, EditPlanStatus.RENDERING)
|
||
|
||
# 调度 Celery 任务
|
||
celery_app.send_task("worker.render_edit_plan", args=[plan_id])
|
||
|
||
# 获取最新状态
|
||
updated_plan = svc.get_plan_or_raise(plan_id)
|
||
|
||
logger.info(
|
||
"触发剪辑计划生成: plan_id=%s gen_task_id=%s clips=%d by user=%s",
|
||
plan_id,
|
||
gen_task.id,
|
||
clip_count,
|
||
current_user.user.id,
|
||
)
|
||
|
||
return EditPlanGenerateResponse(
|
||
plan_id=plan_id,
|
||
plan_status=updated_plan.status.value if hasattr(updated_plan.status, "value") else updated_plan.status,
|
||
generation_task_id=gen_task.id,
|
||
clip_count=clip_count,
|
||
)
|
||
except HTTPException:
|
||
# 已处理的 HTTP 异常直接透传
|
||
raise
|
||
except Exception as exc:
|
||
logger.exception("触发剪辑计划生成失败: plan_id=%s", plan_id)
|
||
# 尝试将计划标记为失败(RENDERING → FAILED 是合法的状态流转)
|
||
try:
|
||
svc.transition_status(plan_id, EditPlanStatus.FAILED)
|
||
except Exception:
|
||
logger.warning("标记计划失败状态时异常: plan_id=%s", plan_id)
|
||
raise HTTPException(
|
||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||
detail="生成失败,请稍后重试",
|
||
)
|
||
|
||
|
||
@router.get(
|
||
"/{plan_id}/generation-status",
|
||
response_model=EditPlanGenerationStatusResponse,
|
||
)
|
||
def get_generation_status(
|
||
plan_id: str,
|
||
db: Session = Depends(get_db_session),
|
||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||
project_repository: Any = Depends(get_project_repository),
|
||
) -> EditPlanGenerationStatusResponse:
|
||
"""查询剪辑计划生成进度
|
||
|
||
返回计划状态、关联的 GenerationTask ID、以及每个片段的状态。
|
||
"""
|
||
svc = EditPlanService(db)
|
||
try:
|
||
gen_status = svc.get_generation_status(plan_id)
|
||
except ValueError as exc:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_404_NOT_FOUND,
|
||
detail=str(exc),
|
||
)
|
||
|
||
plan = gen_status["plan"]
|
||
# 项目鉴权
|
||
if plan.project_id:
|
||
_check_project_access(plan.project_id, current_user.user.id, project_repository)
|
||
clips = gen_status["clips"]
|
||
|
||
clip_items = [
|
||
ClipStatusItem(
|
||
clip_id=c.id,
|
||
clip_type=c.clip_type,
|
||
order=c.order,
|
||
status=c.status.value if hasattr(c.status, "value") else c.status,
|
||
asset_id=c.asset_id or "",
|
||
text_content=c.text_content or "",
|
||
duration=c.duration,
|
||
)
|
||
for c in clips
|
||
]
|
||
|
||
return EditPlanGenerationStatusResponse(
|
||
plan_id=plan_id,
|
||
plan_status=plan.status.value if hasattr(plan.status, "value") else plan.status,
|
||
generation_task_id=gen_status["generation_task_id"],
|
||
clips=clip_items,
|
||
)
|
||
|
||
|
||
@router.get(
|
||
"/{plan_id}/generations",
|
||
response_model=EditPlanGenerationsResponse,
|
||
)
|
||
def list_plan_generations(
|
||
plan_id: str,
|
||
db: Session = Depends(get_db_session),
|
||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||
project_repository: Any = Depends(get_project_repository),
|
||
) -> EditPlanGenerationsResponse:
|
||
"""查询剪辑计划关联的所有生成记录
|
||
|
||
返回该剪辑计划触发的所有 GenerationTask,按创建时间倒序。
|
||
"""
|
||
svc = EditPlanService(db)
|
||
# 验证计划存在 + 项目鉴权
|
||
plan = svc.get_plan_or_raise(plan_id)
|
||
if plan.project_id:
|
||
_check_project_access(plan.project_id, current_user.user.id, project_repository)
|
||
|
||
gen_task_repo = SQLAlchemyGenerationTaskRepository(db)
|
||
tasks = gen_task_repo.list_by_source_edit_plan(plan_id)
|
||
items = [
|
||
GenerationTaskResponse(
|
||
id=t.id,
|
||
project_id=t.project_id,
|
||
asset_library_id=t.asset_library_id,
|
||
strategy_id=t.strategy_id,
|
||
voice_library_id=t.voice_library_id,
|
||
template_id=t.template_id,
|
||
asset_ids=t.asset_ids,
|
||
title_ids=t.title_ids,
|
||
voice_ids=t.voice_ids,
|
||
source_edit_plan_id=t.source_edit_plan_id or "",
|
||
status=t.status.value if hasattr(t.status, "value") else t.status,
|
||
progress=t.progress,
|
||
result_count=t.result_count,
|
||
error_message=t.error_message,
|
||
)
|
||
for t in tasks
|
||
]
|
||
return EditPlanGenerationsResponse(items=items, total=len(items))
|
||
|
||
|
||
# ── AI 推荐 & 封面生成端点(任务 3.09) ────────────────────────────────────────
|
||
|
||
|
||
@router.post(
|
||
"/{plan_id}/ai-recommend",
|
||
response_model=AIRecommendResponse,
|
||
)
|
||
def ai_recommend_clips(
|
||
plan_id: str,
|
||
body: AIRecommendRequest,
|
||
db: Session = Depends(get_db_session),
|
||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||
project_repository: Any = Depends(get_project_repository),
|
||
) -> AIRecommendResponse:
|
||
"""AI 推荐片段方案
|
||
|
||
调用 AI 服务分析素材,自动生成片段编排方案并写入剪辑计划。
|
||
|
||
流程:
|
||
1. 验证计划存在且状态为 draft/editing
|
||
2. 调用 AI 推荐服务(当前为 stub,后续接入真实 AI)
|
||
3. 清除计划现有片段,按推荐方案重新创建
|
||
4. 更新计划 config(cover/title/subtitle/bgm)和 total_duration
|
||
5. 返回推荐方案详情
|
||
|
||
前端对接:
|
||
- 请求体只需传 asset_ids(必填),editing_mode 和 target_duration 可选
|
||
- 返回的 clips 可直接渲染到时间线
|
||
- 返回的 config 包含推荐的封面/标题/字幕/BGM 配置
|
||
"""
|
||
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),
|
||
)
|
||
|
||
# 项目鉴权
|
||
if plan.project_id:
|
||
_check_project_access(plan.project_id, current_user.user.id, project_repository)
|
||
|
||
# 验证状态:只允许 draft 或 editing
|
||
plan_status = plan.status.value if hasattr(plan.status, "value") else plan.status
|
||
if plan_status not in ("draft", "editing"):
|
||
raise HTTPException(
|
||
status_code=status.HTTP_400_BAD_REQUEST,
|
||
detail="当前计划状态不支持AI推荐,请先创建或编辑计划后再试",
|
||
)
|
||
|
||
# 调用 AI 推荐服务(同步调用 stub,后续改为 Celery 异步)
|
||
from apps.worker.worker_app.tasks.ai_tasks import run_ai_recommend
|
||
|
||
result = run_ai_recommend(
|
||
plan_id=plan_id,
|
||
template_id=plan.template_id,
|
||
asset_ids=body.asset_ids,
|
||
editing_mode=body.editing_mode,
|
||
target_duration=body.target_duration,
|
||
)
|
||
|
||
# ── 事务保护:清除 → 重建 → 更新 必须在同一逻辑事务中 ──
|
||
# TODO: 当前各 repo 方法内部 commit(),无法真正回滚。
|
||
# 后续重构 repo 为 flush() 模式后,此处改为统一 commit。
|
||
try:
|
||
# 清除现有片段
|
||
svc.delete_all_clips(plan_id)
|
||
|
||
# 按推荐方案创建新片段
|
||
for clip_data in result["clips"]:
|
||
svc.create_clip(
|
||
plan_id=plan_id,
|
||
clip_type=clip_data["clip_type"],
|
||
order=clip_data["order"],
|
||
text_content=clip_data.get("text_content", ""),
|
||
duration=clip_data["duration"],
|
||
transition_effect=clip_data.get("transition_effect", "cut"),
|
||
asset_id=clip_data.get("asset_id", ""),
|
||
start_time=clip_data.get("start_time", 0.0),
|
||
config=clip_data.get("config", {}),
|
||
)
|
||
|
||
# 更新计划 config 和 total_duration
|
||
normalized_config = normalize_plan_config(result.get("config", {}))
|
||
svc.update_plan(
|
||
plan_id,
|
||
config=normalized_config,
|
||
total_duration=result["total_duration"],
|
||
)
|
||
except Exception as exc:
|
||
logger.exception("AI 推荐写入失败,plan_id=%s 数据可能不一致", plan_id)
|
||
# 尝试回滚未提交的变更
|
||
try:
|
||
db.rollback()
|
||
except Exception:
|
||
pass
|
||
raise HTTPException(
|
||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||
detail="AI推荐结果保存失败,请稍后重试",
|
||
)
|
||
|
||
logger.info(
|
||
"AI 推荐片段方案: plan_id=%s clips=%d duration=%.1f by user=%s",
|
||
plan_id,
|
||
len(result["clips"]),
|
||
result["total_duration"],
|
||
current_user.user.id,
|
||
)
|
||
|
||
return AIRecommendResponse(
|
||
plan_id=plan_id,
|
||
clips=[
|
||
AIRecommendClipItem(
|
||
clip_type=c["clip_type"],
|
||
order=c["order"],
|
||
text_content=c.get("text_content", ""),
|
||
duration=c["duration"],
|
||
transition_effect=c.get("transition_effect", "cut"),
|
||
asset_id=c.get("asset_id", ""),
|
||
start_time=c.get("start_time", 0.0),
|
||
config=c.get("config", {}),
|
||
)
|
||
for c in result["clips"]
|
||
],
|
||
config=normalized_config,
|
||
total_duration=result["total_duration"],
|
||
confidence=result["confidence"],
|
||
)
|
||
|
||
|
||
@router.post(
|
||
"/{plan_id}/generate-cover",
|
||
response_model=GenerateCoverResponse,
|
||
)
|
||
def generate_cover(
|
||
plan_id: str,
|
||
body: GenerateCoverRequest,
|
||
db: Session = Depends(get_db_session),
|
||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||
project_repository: Any = Depends(get_project_repository),
|
||
) -> GenerateCoverResponse:
|
||
"""AI 生成封面
|
||
|
||
调用 AI 服务从视频中选帧或生成封面图,并更新计划 config.cover。
|
||
|
||
流程:
|
||
1. 验证计划存在
|
||
2. 调用 AI 封面生成服务(当前为 stub,后续接入真实 AI)
|
||
3. 更新 plan.config["cover"] 为生成的封面数据
|
||
4. 返回封面数据
|
||
|
||
前端对接:
|
||
- cover_type=ai_frame: AI 智能选帧(默认)
|
||
- cover_type=manual: 手动选帧,需传 frame_time
|
||
- cover_type=upload: 用户上传,接口返回空 image_url,前端自行上传后更新
|
||
- cover_type=ai_regenerate: AI 重新生成
|
||
"""
|
||
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),
|
||
)
|
||
|
||
# 项目鉴权
|
||
if plan.project_id:
|
||
_check_project_access(plan.project_id, current_user.user.id, project_repository)
|
||
|
||
# 调用 AI 封面生成服务
|
||
from apps.worker.worker_app.tasks.ai_tasks import run_generate_cover
|
||
|
||
cover_data = run_generate_cover(
|
||
plan_id=plan_id,
|
||
asset_ids=body.asset_ids,
|
||
cover_type=body.cover_type,
|
||
frame_time=body.frame_time,
|
||
)
|
||
|
||
# 更新 plan.config["cover"]
|
||
current_config = dict(plan.config)
|
||
current_config["cover"] = cover_data
|
||
normalized = normalize_plan_config(current_config)
|
||
svc.update_plan_config(plan_id, {"cover": normalized["cover"]})
|
||
|
||
logger.info(
|
||
"AI 封面生成: plan_id=%s type=%s by user=%s",
|
||
plan_id,
|
||
body.cover_type,
|
||
current_user.user.id,
|
||
)
|
||
|
||
return GenerateCoverResponse(
|
||
plan_id=plan_id,
|
||
cover=cover_data,
|
||
)
|
||
|
||
|
||
# ── Timeline / Scene 端点(P2-6) ─────────────────────────────────────────────
|
||
|
||
|
||
class TimelineSceneResponse(BaseModel):
|
||
"""时间线场景"""
|
||
|
||
scene: str = Field(..., description="场景描述")
|
||
time: str = Field(..., description='时间范围,如 "0:00 - 0:05"')
|
||
duration: float = Field(..., ge=0, description="时长(秒)")
|
||
color: str = Field(..., description="展示颜色")
|
||
clip_id: str = Field(default="", description="关联的片段 ID")
|
||
clip_type: str = Field(default="", description="片段类型")
|
||
|
||
|
||
class TimelineResponse(BaseModel):
|
||
"""时间线响应"""
|
||
|
||
plan_id: str
|
||
total_duration: float
|
||
scenes: List[TimelineSceneResponse]
|
||
|
||
|
||
# clip_type → 颜色映射
|
||
_CLIP_TYPE_COLORS = {
|
||
"intro": "#6366f1",
|
||
"title": "#6366f1",
|
||
"product": "#818cf8",
|
||
"showcase": "#10b981",
|
||
"scene": "#10b981",
|
||
"subtitle": "#f59e0b",
|
||
"text": "#f59e0b",
|
||
"cta": "#ef4444",
|
||
"outro": "#ef4444",
|
||
"voiceover": "#8b5cf6",
|
||
"transition": "#64748b",
|
||
}
|
||
|
||
_DEFAULT_COLOR = "#6366f1"
|
||
|
||
|
||
def _format_time(seconds: float) -> str:
|
||
"""将秒数格式化为 M:SS"""
|
||
m = int(seconds) // 60
|
||
s = int(seconds) % 60
|
||
return f"{m}:{s:02d}"
|
||
|
||
|
||
def _clip_type_to_scene_label(clip_type: str, text_content: str) -> str:
|
||
"""根据 clip_type 和 text_content 生成场景描述"""
|
||
type_labels = {
|
||
"intro": "开场",
|
||
"title": "标题",
|
||
"product": "产品展示",
|
||
"showcase": "场景展示",
|
||
"scene": "场景",
|
||
"subtitle": "字幕",
|
||
"text": "文字",
|
||
"cta": "结尾 CTA",
|
||
"outro": "结尾",
|
||
"voiceover": "配音",
|
||
"transition": "转场",
|
||
}
|
||
label = type_labels.get(clip_type, clip_type or "片段")
|
||
if text_content:
|
||
# 截取前 20 个字符作为副标题
|
||
short = text_content[:20].strip()
|
||
if short:
|
||
return f"{label} - {short}"
|
||
return label
|
||
|
||
|
||
@router.get(
|
||
"/{plan_id}/timeline",
|
||
response_model=TimelineResponse,
|
||
)
|
||
def get_plan_timeline(
|
||
plan_id: str,
|
||
db: Session = Depends(get_db_session),
|
||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||
project_repository: Any = Depends(get_project_repository),
|
||
) -> TimelineResponse:
|
||
"""获取剪辑计划的时间线场景数据
|
||
|
||
返回按计划片段排序的时间线场景列表,供前端 GeneratePage 渲染使用。
|
||
"""
|
||
svc = EditPlanService(db)
|
||
plan = svc.get_plan_or_raise(plan_id)
|
||
# 项目鉴权
|
||
if plan.project_id:
|
||
_check_project_access(plan.project_id, current_user.user.id, project_repository)
|
||
|
||
clips = svc.list_clips(plan_id=plan_id, skip=0, limit=200)
|
||
# 按 order 排序
|
||
clips.sort(key=lambda c: c.order)
|
||
|
||
scenes: List[TimelineSceneResponse] = []
|
||
current_time = 0.0
|
||
|
||
for clip in clips:
|
||
start = current_time
|
||
end = start + clip.duration
|
||
color = _CLIP_TYPE_COLORS.get(clip.clip_type, _DEFAULT_COLOR)
|
||
scene_label = _clip_type_to_scene_label(clip.clip_type, clip.text_content)
|
||
|
||
scenes.append(
|
||
TimelineSceneResponse(
|
||
scene=scene_label,
|
||
time=f"{_format_time(start)} - {_format_time(end)}",
|
||
duration=clip.duration,
|
||
color=color,
|
||
clip_id=clip.id,
|
||
clip_type=clip.clip_type,
|
||
)
|
||
)
|
||
current_time = end
|
||
|
||
total_duration = sum(s.duration for s in scenes) or plan.total_duration
|
||
|
||
return TimelineResponse(
|
||
plan_id=plan_id,
|
||
total_duration=total_duration,
|
||
scenes=scenes,
|
||
)
|
||
|
||
|
||
# ── 基于模板生成剪辑计划 ─────────────────────────────────────────────────────
|
||
|
||
|
||
@router.post(
|
||
"/generate-from-template",
|
||
response_model=GenerateFromTemplateResponse,
|
||
status_code=status.HTTP_201_CREATED,
|
||
)
|
||
def generate_from_template(
|
||
body: GenerateFromTemplateRequest,
|
||
db: Session = Depends(get_db_session),
|
||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||
project_repository: Any = Depends(get_project_repository),
|
||
) -> GenerateFromTemplateResponse:
|
||
"""基于模板 + 素材自动生成剪辑计划
|
||
|
||
流程:
|
||
1. 获取模板及其片段配置
|
||
2. 调用 PlanGeneratorService 生成 EditPlan + EditPlanClips
|
||
3. 返回完整的计划和片段列表
|
||
"""
|
||
from app.services import EditTemplateService
|
||
|
||
# 项目鉴权
|
||
if body.project_id:
|
||
_check_project_access(body.project_id, current_user.user.id, project_repository)
|
||
|
||
template_svc = EditTemplateService(db)
|
||
|
||
# 获取模板
|
||
try:
|
||
template = template_svc.get_template_or_raise(body.template_id)
|
||
except ValueError as exc:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_404_NOT_FOUND,
|
||
detail=str(exc),
|
||
)
|
||
|
||
# 获取模板片段配置
|
||
clip_configs = template_svc.list_clip_configs(body.template_id, skip=0, limit=200)
|
||
|
||
# 调用 PlanGeneratorService 生成计划
|
||
generator = PlanGeneratorService(db)
|
||
result = generator.generate_from_template(
|
||
template=template,
|
||
clip_configs=clip_configs,
|
||
asset_ids=body.asset_ids,
|
||
project_id=body.project_id,
|
||
created_by_user_id=current_user.user.id,
|
||
name=body.name,
|
||
)
|
||
|
||
plan = result["plan"]
|
||
clips = result["clips"]
|
||
|
||
logger.info(
|
||
"基于模板生成剪辑计划: plan_id=%s template_id=%s clips=%d by user=%s",
|
||
plan.id,
|
||
body.template_id,
|
||
len(clips),
|
||
current_user.user.id,
|
||
)
|
||
|
||
return GenerateFromTemplateResponse(
|
||
plan=_to_response(plan),
|
||
clips=[
|
||
_PlanClipItem(
|
||
id=c.id,
|
||
clip_type=c.clip_type,
|
||
order=c.order,
|
||
asset_id=c.asset_id,
|
||
text_content=c.text_content,
|
||
start_time=c.start_time,
|
||
duration=c.duration,
|
||
transition_effect=c.transition_effect,
|
||
status=c.status.value if hasattr(c.status, "value") else c.status,
|
||
config=c.config,
|
||
created_at=c.created_at,
|
||
updated_at=c.updated_at,
|
||
)
|
||
for c in clips
|
||
],
|
||
)
|