09d2b12ea8
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Successful in 57s
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 3m7s
CI/CD Pipeline / Unit Tests (push) Successful in 3m13s
CI/CD Pipeline / Integration Tests (push) Successful in 1m22s
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m32s
CI Build & Deploy Pipeline / Build Staging API Image (push) Successful in 18m38s
CI Build & Deploy Pipeline / Build Staging Web Image (push) Successful in 19s
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Successful in 8m7s
CI Build & Deploy Pipeline / Build Production API Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Production Web Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Production Worker Image (push) Has been skipped
CI Build & Deploy Pipeline / Deploy Production (push) Has been skipped
CI Build & Deploy Pipeline / Production Browser E2E (push) Has been skipped
CI Build & Deploy Pipeline / Staging E2E Tests (push) Successful in 2m16s
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Successful in 4m35s
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com> Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
1111 lines
38 KiB
Python
1111 lines
38 KiB
Python
"""EditPlanService — 剪辑计划管理业务逻辑.
|
||
|
||
封装 EditPlan 和 EditPlanClip 的 CRUD 操作、状态机流转、
|
||
以及渲染生成流程,提供统一的业务接口供 API 路由层调用。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
from typing import Any, Dict, List, Optional
|
||
|
||
from sqlalchemy.orm import Session
|
||
|
||
from packages.adapters.sqlalchemy_impl import (
|
||
SQLAlchemyEditPlanClipRepository,
|
||
SQLAlchemyEditPlanRepository,
|
||
SQLAlchemyGenerationTaskRepository,
|
||
)
|
||
from packages.domain.edit_plan import EditPlan, EditPlanStatus
|
||
from packages.domain.edit_plan_clip import EditPlanClip, EditPlanClipStatus
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
class EditPlanService:
|
||
"""剪辑计划管理服务
|
||
|
||
职责:
|
||
- 剪辑计划 CRUD(创建、查询、更新、删除)
|
||
- 剪辑片段管理(增删改查、分配素材)
|
||
- 状态机流转(draft → editing → rendering → completed/failed)
|
||
- 渲染生成流程(触发 Celery 任务、查询进度)
|
||
"""
|
||
|
||
def __init__(self, db: Session) -> None:
|
||
self._plan_repo = SQLAlchemyEditPlanRepository(db)
|
||
self._clip_repo = SQLAlchemyEditPlanClipRepository(db)
|
||
self._generation_task_repo = SQLAlchemyGenerationTaskRepository(db)
|
||
|
||
# ── 剪辑计划 CRUD ──────────────────────────────────────────────────────
|
||
|
||
def list_plans(
|
||
self,
|
||
*,
|
||
template_id: Optional[str] = None,
|
||
project_id: Optional[str] = None,
|
||
status: Optional[EditPlanStatus] = None,
|
||
skip: int = 0,
|
||
limit: int = 50,
|
||
) -> List[EditPlan]:
|
||
"""列出剪辑计划
|
||
|
||
Args:
|
||
template_id: 按模板 ID 筛选
|
||
project_id: 按项目 ID 筛选
|
||
status: 按状态筛选
|
||
skip: 分页偏移
|
||
limit: 每页数量
|
||
"""
|
||
if project_id:
|
||
return self._plan_repo.list_by_project(
|
||
project_id,
|
||
status=status,
|
||
skip=skip,
|
||
limit=limit,
|
||
)
|
||
if template_id:
|
||
return self._plan_repo.list_by_template(
|
||
template_id,
|
||
status=status,
|
||
skip=skip,
|
||
limit=limit,
|
||
)
|
||
return self._plan_repo.list_all(status=status, skip=skip, limit=limit)
|
||
|
||
def count_plans(
|
||
self,
|
||
*,
|
||
template_id: Optional[str] = None,
|
||
project_id: Optional[str] = None,
|
||
status: Optional[EditPlanStatus] = None,
|
||
) -> int:
|
||
"""统计计划数量
|
||
|
||
Note:
|
||
当指定 template_id/project_id 时,通过全量查询计算 total(repo 限制)。
|
||
"""
|
||
if project_id:
|
||
all_matching = self._plan_repo.list_by_project(
|
||
project_id,
|
||
status=status,
|
||
skip=0,
|
||
limit=10000,
|
||
)
|
||
return len(all_matching)
|
||
if template_id:
|
||
all_matching = self._plan_repo.list_by_template(
|
||
template_id,
|
||
status=status,
|
||
skip=0,
|
||
limit=10000,
|
||
)
|
||
return len(all_matching)
|
||
return self._plan_repo.count(status=status)
|
||
|
||
def get_plan(self, plan_id: str) -> Optional[EditPlan]:
|
||
"""获取计划详情"""
|
||
return self._plan_repo.get(plan_id)
|
||
|
||
def get_plan_or_raise(self, plan_id: str) -> EditPlan:
|
||
"""获取计划,不存在则抛出 ValueError"""
|
||
plan = self._plan_repo.get(plan_id)
|
||
if plan is None:
|
||
raise ValueError(f"剪辑计划不存在: {plan_id}")
|
||
return plan
|
||
|
||
def create_plan(
|
||
self,
|
||
template_id: str,
|
||
name: str,
|
||
*,
|
||
config: Optional[dict[str, Any]] = None,
|
||
total_duration: float = 0.0,
|
||
project_id: str = "",
|
||
created_by_user_id: str = "",
|
||
) -> EditPlan:
|
||
"""创建剪辑计划
|
||
|
||
Raises:
|
||
ValueError: 参数校验失败
|
||
"""
|
||
plan = EditPlan.create(
|
||
template_id=template_id,
|
||
name=name,
|
||
config=config,
|
||
total_duration=total_duration,
|
||
project_id=project_id,
|
||
created_by_user_id=created_by_user_id,
|
||
)
|
||
created = self._plan_repo.create(plan)
|
||
logger.info("创建剪辑计划: id=%s name=%s", created.id, created.name)
|
||
return created
|
||
|
||
def _auto_resume_editing(self, plan_id: str) -> None:
|
||
"""如果计划处于 completed/failed 状态,自动切回 editing(编辑操作前置)"""
|
||
plan = self._plan_repo.get(plan_id)
|
||
if plan is None:
|
||
return
|
||
if plan.status in (EditPlanStatus.COMPLETED, EditPlanStatus.FAILED):
|
||
try:
|
||
plan.resume_editing()
|
||
self._plan_repo.update(plan)
|
||
logger.info("自动重新编辑: plan_id=%s", plan_id)
|
||
except ValueError:
|
||
pass
|
||
|
||
def update_plan(
|
||
self,
|
||
plan_id: str,
|
||
*,
|
||
name: Optional[str] = None,
|
||
config: Optional[dict[str, Any]] = None,
|
||
total_duration: Optional[float] = None,
|
||
) -> EditPlan:
|
||
"""更新计划基础字段
|
||
|
||
Raises:
|
||
ValueError: 计划不存在
|
||
"""
|
||
existing = self.get_plan_or_raise(plan_id)
|
||
|
||
# 自动从 completed/failed 切回 editing
|
||
self._auto_resume_editing(plan_id)
|
||
existing = self.get_plan_or_raise(plan_id)
|
||
|
||
updated = EditPlan(
|
||
id=existing.id,
|
||
template_id=existing.template_id,
|
||
name=name.strip() if name is not None else existing.name,
|
||
status=existing.status,
|
||
total_duration=total_duration if total_duration is not None else existing.total_duration,
|
||
source_edit_plan_id=existing.source_edit_plan_id,
|
||
project_id=existing.project_id,
|
||
created_by_user_id=existing.created_by_user_id,
|
||
config=config if config is not None else existing.config,
|
||
created_at=existing.created_at,
|
||
updated_at=existing.updated_at,
|
||
)
|
||
result = self._plan_repo.update(updated)
|
||
logger.info("更新剪辑计划: id=%s", plan_id)
|
||
return result
|
||
|
||
def delete_plan(self, plan_id: str) -> bool:
|
||
"""删除剪辑计划及其所有片段
|
||
|
||
Returns:
|
||
bool: 是否删除成功
|
||
"""
|
||
existing = self._plan_repo.get(plan_id)
|
||
if existing is None:
|
||
return False
|
||
|
||
# 先删除所有片段
|
||
self._clip_repo.delete_by_plan(plan_id)
|
||
# 再删除计划
|
||
self._plan_repo.delete(plan_id)
|
||
logger.info("删除剪辑计划: id=%s", plan_id)
|
||
return True
|
||
|
||
# ── 状态机流转 ──────────────────────────────────────────────────────────
|
||
|
||
def transition_status(self, plan_id: str, target_status: EditPlanStatus) -> EditPlan:
|
||
"""流转计划状态
|
||
|
||
状态流转规则:
|
||
- draft → editing (start_editing)
|
||
- editing → rendering (start_rendering)
|
||
- rendering → completed (mark_completed)
|
||
- rendering → failed (mark_failed)
|
||
- failed → draft (reset_to_draft)
|
||
|
||
Raises:
|
||
ValueError: 计划不存在或状态流转非法
|
||
"""
|
||
plan = self.get_plan_or_raise(plan_id)
|
||
|
||
# 如果已是目标状态,直接返回
|
||
if plan.status == target_status:
|
||
return plan
|
||
|
||
# 根据目标状态调用对应的状态机方法
|
||
# EDITING 支持从 draft / completed / failed 进入
|
||
if target_status == EditPlanStatus.EDITING:
|
||
if plan.status == EditPlanStatus.DRAFT:
|
||
plan.start_editing()
|
||
elif plan.status in (EditPlanStatus.COMPLETED, EditPlanStatus.FAILED):
|
||
plan.resume_editing()
|
||
else:
|
||
raise ValueError(f"无法从 {plan.status} 切换到 {target_status}")
|
||
result = self._plan_repo.update(plan)
|
||
logger.info(
|
||
"状态流转: plan_id=%s %s → %s",
|
||
plan_id,
|
||
plan.status,
|
||
target_status,
|
||
)
|
||
return result
|
||
|
||
transition_map = {
|
||
EditPlanStatus.RENDERING: plan.start_rendering,
|
||
EditPlanStatus.COMPLETED: plan.mark_completed,
|
||
EditPlanStatus.FAILED: plan.mark_failed,
|
||
EditPlanStatus.DRAFT: plan.reset_to_draft,
|
||
}
|
||
|
||
transition_fn = transition_map.get(target_status)
|
||
if transition_fn is None:
|
||
raise ValueError(f"无效的目标状态: {target_status}")
|
||
|
||
transition_fn()
|
||
result = self._plan_repo.update(plan)
|
||
logger.info(
|
||
"状态流转: plan_id=%s %s → %s",
|
||
plan_id,
|
||
plan.status,
|
||
target_status,
|
||
)
|
||
return result
|
||
|
||
# ── 剪辑片段管理 ────────────────────────────────────────────────────────
|
||
|
||
def list_clips(
|
||
self,
|
||
plan_id: str,
|
||
*,
|
||
status: Optional[EditPlanClipStatus] = None,
|
||
skip: int = 0,
|
||
limit: int = 100,
|
||
) -> List[EditPlanClip]:
|
||
"""列出计划的片段"""
|
||
# 确保计划存在
|
||
self.get_plan_or_raise(plan_id)
|
||
return self._clip_repo.list_by_plan(plan_id, status=status, skip=skip, limit=limit)
|
||
|
||
def count_clips(
|
||
self,
|
||
plan_id: str,
|
||
*,
|
||
status: Optional[EditPlanClipStatus] = None,
|
||
) -> int:
|
||
"""统计片段数量"""
|
||
return self._clip_repo.count(plan_id=plan_id, status=status)
|
||
|
||
def get_clip(self, clip_id: str) -> Optional[EditPlanClip]:
|
||
"""获取片段详情"""
|
||
return self._clip_repo.get(clip_id)
|
||
|
||
def get_clip_or_raise(self, clip_id: str) -> EditPlanClip:
|
||
"""获取片段,不存在则抛出 ValueError"""
|
||
clip = self._clip_repo.get(clip_id)
|
||
if clip is None:
|
||
raise ValueError(f"片段不存在: {clip_id}")
|
||
return clip
|
||
|
||
def create_clip(
|
||
self,
|
||
plan_id: str,
|
||
clip_type: str,
|
||
order: int,
|
||
*,
|
||
template_clip_config_id: str = "",
|
||
asset_id: str = "",
|
||
text_content: str = "",
|
||
start_time: float = 0.0,
|
||
duration: float = 0.0,
|
||
transition_effect: str = "cut",
|
||
transition_duration: float = 0.0,
|
||
playback_speed: float = 1.0,
|
||
config: Optional[dict[str, Any]] = None,
|
||
) -> EditPlanClip:
|
||
"""创建片段
|
||
|
||
Raises:
|
||
ValueError: 计划不存在或参数校验失败
|
||
"""
|
||
# 确保计划存在
|
||
self.get_plan_or_raise(plan_id)
|
||
# 自动从 completed/failed 切回 editing
|
||
self._auto_resume_editing(plan_id)
|
||
|
||
clip = EditPlanClip.create(
|
||
plan_id=plan_id,
|
||
clip_type=clip_type,
|
||
order=order,
|
||
template_clip_config_id=template_clip_config_id,
|
||
asset_id=asset_id,
|
||
text_content=text_content,
|
||
start_time=start_time,
|
||
duration=duration,
|
||
transition_effect=transition_effect,
|
||
transition_duration=transition_duration,
|
||
playback_speed=playback_speed,
|
||
config=config,
|
||
)
|
||
created = self._clip_repo.create(clip)
|
||
logger.info(
|
||
"创建片段: id=%s plan_id=%s clip_type=%s order=%d",
|
||
created.id,
|
||
plan_id,
|
||
created.clip_type,
|
||
created.order,
|
||
)
|
||
return created
|
||
|
||
def update_clip(
|
||
self,
|
||
clip_id: str,
|
||
*,
|
||
clip_type: Optional[str] = None,
|
||
order: Optional[int] = None,
|
||
asset_id: Optional[str] = None,
|
||
text_content: Optional[str] = None,
|
||
start_time: Optional[float] = None,
|
||
duration: Optional[float] = None,
|
||
transition_effect: Optional[str] = None,
|
||
transition_duration: Optional[float] = None,
|
||
playback_speed: Optional[float] = None,
|
||
config: Optional[dict[str, Any]] = None,
|
||
) -> EditPlanClip:
|
||
"""更新片段
|
||
|
||
Raises:
|
||
ValueError: 片段不存在
|
||
"""
|
||
existing = self.get_clip_or_raise(clip_id)
|
||
|
||
# 自动从 completed/failed 切回 editing
|
||
self._auto_resume_editing(existing.plan_id)
|
||
|
||
# 速度边界钳制
|
||
if playback_speed is not None:
|
||
if playback_speed <= 0:
|
||
playback_speed = 1.0
|
||
elif playback_speed < 0.25:
|
||
playback_speed = 0.25
|
||
elif playback_speed > 4.0:
|
||
playback_speed = 4.0
|
||
|
||
updated = EditPlanClip(
|
||
id=existing.id,
|
||
plan_id=existing.plan_id,
|
||
clip_type=clip_type.strip() if clip_type is not None else existing.clip_type,
|
||
order=order if order is not None else existing.order,
|
||
template_clip_config_id=existing.template_clip_config_id,
|
||
asset_id=asset_id.strip() if asset_id is not None else existing.asset_id,
|
||
text_content=text_content.strip() if text_content is not None else existing.text_content,
|
||
start_time=start_time if start_time is not None else existing.start_time,
|
||
duration=duration if duration is not None else existing.duration,
|
||
transition_effect=(
|
||
transition_effect.strip() if transition_effect is not None else existing.transition_effect
|
||
),
|
||
transition_duration=(
|
||
transition_duration if transition_duration is not None else existing.transition_duration
|
||
),
|
||
playback_speed=playback_speed if playback_speed is not None else existing.playback_speed,
|
||
status=existing.status,
|
||
config=config if config is not None else existing.config,
|
||
created_at=existing.created_at,
|
||
updated_at=existing.updated_at,
|
||
)
|
||
result = self._clip_repo.update(updated)
|
||
logger.info("更新片段: id=%s", clip_id)
|
||
return result
|
||
|
||
def assign_asset(self, clip_id: str, asset_id: str) -> EditPlanClip:
|
||
"""为片段分配素材
|
||
|
||
Raises:
|
||
ValueError: 片段不存在或 asset_id 为空
|
||
"""
|
||
clip = self.get_clip_or_raise(clip_id)
|
||
# 自动从 completed/failed 切回 editing
|
||
self._auto_resume_editing(clip.plan_id)
|
||
clip.assign_asset(asset_id)
|
||
result = self._clip_repo.update(clip)
|
||
logger.info("分配素材: clip_id=%s asset_id=%s", clip_id, asset_id)
|
||
return result
|
||
|
||
def delete_clip(self, clip_id: str) -> bool:
|
||
"""删除片段
|
||
|
||
Returns:
|
||
bool: 是否删除成功
|
||
"""
|
||
deleted = self._clip_repo.delete(clip_id)
|
||
if deleted:
|
||
logger.info("删除片段: id=%s", clip_id)
|
||
return deleted
|
||
|
||
def delete_all_clips(self, plan_id: str) -> int:
|
||
"""删除计划下所有片段
|
||
|
||
Returns:
|
||
int: 删除的片段数量
|
||
"""
|
||
count = self._clip_repo.delete_by_plan(plan_id)
|
||
logger.info("删除所有片段: plan_id=%s count=%d", plan_id, count)
|
||
return count
|
||
|
||
def create_clips_from_assets(
|
||
self,
|
||
plan_id: str,
|
||
asset_ids: list[str],
|
||
*,
|
||
clip_type: str = "main",
|
||
) -> list[EditPlanClip]:
|
||
"""从素材批量创建片段(追加到时间线末尾)。
|
||
|
||
Args:
|
||
plan_id: 计划 ID
|
||
asset_ids: 素材 ID 列表(按顺序追加)
|
||
clip_type: 片段类型
|
||
|
||
Returns:
|
||
list[EditPlanClip]: 创建的片段列表
|
||
"""
|
||
if not asset_ids:
|
||
return []
|
||
|
||
# 确保计划存在 + 自动回退状态
|
||
self.get_plan_or_raise(plan_id)
|
||
self._auto_resume_editing(plan_id)
|
||
|
||
# 查询素材信息(取 duration)
|
||
from packages.adapters.sqlalchemy_impl.models import AssetModel
|
||
|
||
session = self._clip_repo.session # type: ignore[attr-defined]
|
||
assets = session.query(AssetModel).filter(AssetModel.id.in_(asset_ids)).all()
|
||
asset_map = {a.id: a for a in assets}
|
||
|
||
# 从现有片段数量开始追加
|
||
existing_count = self._clip_repo.count(plan_id=plan_id)
|
||
|
||
# 批量创建片段
|
||
created: list[EditPlanClip] = []
|
||
for i, asset_id in enumerate(asset_ids):
|
||
asset = asset_map.get(asset_id)
|
||
duration = asset.duration if asset and asset.duration else 0.0
|
||
|
||
clip = self.create_clip(
|
||
plan_id=plan_id,
|
||
clip_type=clip_type,
|
||
order=existing_count + i,
|
||
asset_id=asset_id,
|
||
duration=duration,
|
||
)
|
||
created.append(clip)
|
||
|
||
logger.info(
|
||
"从素材批量创建片段: plan_id=%s count=%d",
|
||
plan_id,
|
||
len(created),
|
||
)
|
||
return created
|
||
|
||
# ── 渲染生成流程 ────────────────────────────────────────────────────────
|
||
# ── 片段分割与合并 ──────────────────────────────────────────────────────
|
||
|
||
def split_clip(self, clip_id: str, split_time: float) -> Dict[str, Any]:
|
||
"""将一个片段从指定位置分割为两个片段
|
||
|
||
Args:
|
||
clip_id: 要分割的片段 ID
|
||
split_time: 分割点(相对于片段起始的秒数),必须在 (0, duration) 范围内
|
||
|
||
Returns:
|
||
dict: {"left_clip": EditPlanClip, "right_clip": EditPlanClip}
|
||
|
||
Raises:
|
||
ValueError: 片段不存在、分割时间越界
|
||
"""
|
||
clip = self.get_clip_or_raise(clip_id)
|
||
plan_id = clip.plan_id
|
||
|
||
if split_time <= 0 or split_time >= clip.duration:
|
||
raise ValueError(f"分割时间必须在 (0, {clip.duration:.3f}) 范围内,当前: {split_time}")
|
||
|
||
self._auto_resume_editing(plan_id)
|
||
|
||
original_duration = clip.duration
|
||
left_duration = round(split_time, 3)
|
||
right_duration = round(original_duration - split_time, 3)
|
||
original_order = clip.order
|
||
|
||
# 更新左半部分(原片段)
|
||
clip.duration = left_duration
|
||
left_clip = self._clip_repo.update(clip)
|
||
|
||
# 后面片段的 order 全部 +1(给右半部分腾位置)
|
||
all_clips = self._clip_repo.list_by_plan(plan_id)
|
||
for c in all_clips:
|
||
if c.order > original_order and c.id != clip_id:
|
||
c.order += 1
|
||
self._clip_repo.update(c)
|
||
|
||
# 创建右半部分新片段(继承原片段的大部分属性)
|
||
right_config = dict(clip.config) if clip.config else {}
|
||
# 素材裁剪信息
|
||
if clip.asset_id:
|
||
# 右半部分从 split_time 开始播放
|
||
right_config["trim_start"] = left_duration
|
||
# 左半部分在 split_time 处结束
|
||
left_config = dict(left_clip.config) if left_clip.config else {}
|
||
left_config["trim_end"] = right_duration
|
||
left_clip.config = left_config
|
||
left_clip = self._clip_repo.update(left_clip)
|
||
|
||
right_clip = EditPlanClip.create(
|
||
plan_id=plan_id,
|
||
clip_type=clip.clip_type,
|
||
order=original_order + 1,
|
||
template_clip_config_id=clip.template_clip_config_id,
|
||
asset_id=clip.asset_id,
|
||
text_content=clip.text_content,
|
||
start_time=clip.start_time + left_duration,
|
||
duration=right_duration,
|
||
transition_effect=clip.transition_effect,
|
||
transition_duration=clip.transition_duration,
|
||
playback_speed=clip.playback_speed,
|
||
config=right_config,
|
||
)
|
||
created_right = self._clip_repo.create(right_clip)
|
||
|
||
logger.info(
|
||
"分割片段: clip_id=%s plan_id=%s split_time=%.3fs left_dur=%.3fs right_dur=%.3fs",
|
||
clip_id,
|
||
plan_id,
|
||
split_time,
|
||
left_duration,
|
||
right_duration,
|
||
)
|
||
|
||
return {
|
||
"left_clip": left_clip,
|
||
"right_clip": created_right,
|
||
}
|
||
|
||
def merge_clips(self, clip_ids: List[str]) -> EditPlanClip:
|
||
"""合并多个连续片段为一个片段
|
||
|
||
Args:
|
||
clip_ids: 要合并的片段 ID 列表(至少2个),必须属于同一个计划且 order 连续
|
||
|
||
Returns:
|
||
EditPlanClip: 合并后的新片段
|
||
|
||
Raises:
|
||
ValueError: 数量不足、不属于同一计划、不连续、类型不一致
|
||
"""
|
||
if len(clip_ids) < 2:
|
||
raise ValueError("至少需要 2 个片段才能合并")
|
||
|
||
# 读取所有片段
|
||
clips = []
|
||
for cid in clip_ids:
|
||
clip = self.get_clip_or_raise(cid)
|
||
clips.append(clip)
|
||
|
||
# 校验:同一计划
|
||
plan_id = clips[0].plan_id
|
||
for c in clips[1:]:
|
||
if c.plan_id != plan_id:
|
||
raise ValueError("只能合并同一计划下的片段")
|
||
|
||
# 按 order 排序
|
||
clips.sort(key=lambda c: c.order)
|
||
|
||
# 校验:order 连续
|
||
for i in range(1, len(clips)):
|
||
if clips[i].order != clips[i - 1].order + 1:
|
||
raise ValueError(f"片段不连续:order {clips[i-1].order} → {clips[i].order}")
|
||
|
||
# 校验:类型一致
|
||
clip_type = clips[0].clip_type
|
||
for c in clips[1:]:
|
||
if c.clip_type != clip_type:
|
||
raise ValueError("只能合并相同类型的片段")
|
||
|
||
self._auto_resume_editing(plan_id)
|
||
|
||
# 计算合并后的属性
|
||
first_clip = clips[0]
|
||
total_duration = round(sum(c.duration for c in clips), 3)
|
||
first_order = first_clip.order
|
||
|
||
# 合并文案(用换行连接)
|
||
merged_text = "\n".join(c.text_content for c in clips if c.text_content.strip())
|
||
|
||
# 合并 config(后面的覆盖前面的)
|
||
merged_config: Dict[str, Any] = {}
|
||
for c in clips:
|
||
if c.config:
|
||
merged_config.update(c.config)
|
||
# 清理 trim 相关字段(合并后就是完整片段了)
|
||
merged_config.pop("trim_start", None)
|
||
merged_config.pop("trim_end", None)
|
||
|
||
# 更新第一个片段(保留它作为合并结果)
|
||
first_clip.duration = total_duration
|
||
first_clip.text_content = merged_text
|
||
first_clip.config = merged_config
|
||
# 转场保留第一个的(合并后的入点转场)
|
||
# playback_speed 取第一个的
|
||
merged_clip = self._clip_repo.update(first_clip)
|
||
|
||
# 删除其余片段
|
||
for c in clips[1:]:
|
||
self._clip_repo.delete(c.id)
|
||
|
||
# 后面的片段 order 前移 (len - 1) 位
|
||
shift = len(clips) - 1
|
||
all_clips = self._clip_repo.list_by_plan(plan_id)
|
||
for c in all_clips:
|
||
if c.order > first_order and c.id != merged_clip.id:
|
||
c.order -= shift
|
||
self._clip_repo.update(c)
|
||
|
||
logger.info(
|
||
"合并片段: plan_id=%s count=%d total_duration=%.3fs",
|
||
plan_id,
|
||
len(clips),
|
||
total_duration,
|
||
)
|
||
|
||
return merged_clip
|
||
|
||
# ── 字幕管理 ──────────────────────────────────────────────────────────
|
||
|
||
def list_subtitles(self, clip_id: str) -> List[Dict[str, Any]]:
|
||
"""获取片段的所有字幕
|
||
|
||
Returns:
|
||
List[dict]: 字幕列表,按 start 时间排序
|
||
"""
|
||
clip = self.get_clip_or_raise(clip_id)
|
||
config = clip.config or {}
|
||
subtitles = config.get("subtitles", [])
|
||
# 按开始时间排序
|
||
subtitles.sort(key=lambda s: s.get("start", 0))
|
||
return subtitles
|
||
|
||
def get_subtitle(self, clip_id: str, subtitle_id: str) -> Optional[Dict[str, Any]]:
|
||
"""获取单条字幕"""
|
||
subtitles = self.list_subtitles(clip_id)
|
||
for s in subtitles:
|
||
if s.get("id") == subtitle_id:
|
||
return s
|
||
return None
|
||
|
||
def add_subtitle(
|
||
self,
|
||
clip_id: str,
|
||
start: float,
|
||
end: float,
|
||
text: str,
|
||
*,
|
||
style: Optional[Dict[str, Any]] = None,
|
||
) -> Dict[str, Any]:
|
||
"""添加一条字幕
|
||
|
||
Args:
|
||
clip_id: 片段 ID
|
||
start: 开始时间(秒,相对于片段)
|
||
end: 结束时间(秒)
|
||
text: 字幕文本
|
||
style: 样式配置(字体、大小、颜色、位置等)
|
||
|
||
Returns:
|
||
dict: 新增的字幕条目
|
||
|
||
Raises:
|
||
ValueError: 时间非法或文本为空
|
||
"""
|
||
clip = self.get_clip_or_raise(clip_id)
|
||
|
||
if start < 0 or end <= start:
|
||
raise ValueError(f"字幕时间非法: start={start}, end={end}")
|
||
if not text.strip():
|
||
raise ValueError("字幕文本不能为空")
|
||
if end > clip.duration + 0.001:
|
||
raise ValueError(f"字幕结束时间不能超过片段时长: end={end:.3f}, duration={clip.duration:.3f}")
|
||
|
||
self._auto_resume_editing(clip.plan_id)
|
||
|
||
from uuid import uuid4
|
||
|
||
config = dict(clip.config) if clip.config else {}
|
||
subtitles = list(config.get("subtitles", []))
|
||
|
||
subtitle = {
|
||
"id": uuid4().hex,
|
||
"start": round(start, 3),
|
||
"end": round(end, 3),
|
||
"text": text.strip(),
|
||
"style": style or {},
|
||
}
|
||
subtitles.append(subtitle)
|
||
subtitles.sort(key=lambda s: s.get("start", 0))
|
||
|
||
config["subtitles"] = subtitles
|
||
clip.config = config
|
||
self._clip_repo.update(clip)
|
||
|
||
logger.info(
|
||
"添加字幕: clip_id=%s subtitle_id=%s start=%.3fs end=%.3fs",
|
||
clip_id,
|
||
subtitle["id"],
|
||
start,
|
||
end,
|
||
)
|
||
|
||
return subtitle
|
||
|
||
def update_subtitle(
|
||
self,
|
||
clip_id: str,
|
||
subtitle_id: str,
|
||
*,
|
||
start: Optional[float] = None,
|
||
end: Optional[float] = None,
|
||
text: Optional[str] = None,
|
||
style: Optional[Dict[str, Any]] = None,
|
||
) -> Dict[str, Any]:
|
||
"""更新一条字幕
|
||
|
||
Returns:
|
||
dict: 更新后的字幕条目
|
||
|
||
Raises:
|
||
ValueError: 字幕不存在或参数非法
|
||
"""
|
||
clip = self.get_clip_or_raise(clip_id)
|
||
config = dict(clip.config) if clip.config else {}
|
||
subtitles = list(config.get("subtitles", []))
|
||
|
||
found = False
|
||
for i, s in enumerate(subtitles):
|
||
if s.get("id") == subtitle_id:
|
||
# 更新字段
|
||
updated_s = dict(s)
|
||
if start is not None:
|
||
updated_s["start"] = round(start, 3)
|
||
if end is not None:
|
||
updated_s["end"] = round(end, 3)
|
||
if text is not None:
|
||
if not text.strip():
|
||
raise ValueError("字幕文本不能为空")
|
||
updated_s["text"] = text.strip()
|
||
if style is not None:
|
||
updated_s["style"] = style
|
||
|
||
# 校验时间
|
||
if updated_s["start"] < 0 or updated_s["end"] <= updated_s["start"]:
|
||
raise ValueError(f"字幕时间非法: start={updated_s['start']}, end={updated_s['end']}")
|
||
if updated_s["end"] > clip.duration + 0.001:
|
||
raise ValueError("字幕结束时间不能超过片段时长")
|
||
|
||
subtitles[i] = updated_s
|
||
found = True
|
||
break
|
||
|
||
if not found:
|
||
raise ValueError(f"字幕不存在: {subtitle_id}")
|
||
|
||
self._auto_resume_editing(clip.plan_id)
|
||
|
||
subtitles.sort(key=lambda s: s.get("start", 0))
|
||
config["subtitles"] = subtitles
|
||
clip.config = config
|
||
self._clip_repo.update(clip)
|
||
|
||
logger.info("更新字幕: clip_id=%s subtitle_id=%s", clip_id, subtitle_id)
|
||
|
||
return subtitles[next(i for i, s in enumerate(subtitles) if s["id"] == subtitle_id)]
|
||
|
||
def delete_subtitle(self, clip_id: str, subtitle_id: str) -> bool:
|
||
"""删除一条字幕
|
||
|
||
Returns:
|
||
bool: 是否删除成功
|
||
"""
|
||
clip = self.get_clip_or_raise(clip_id)
|
||
config = dict(clip.config) if clip.config else {}
|
||
subtitles = list(config.get("subtitles", []))
|
||
|
||
new_subtitles = [s for s in subtitles if s.get("id") != subtitle_id]
|
||
if len(new_subtitles) == len(subtitles):
|
||
return False
|
||
|
||
self._auto_resume_editing(clip.plan_id)
|
||
|
||
config["subtitles"] = new_subtitles
|
||
clip.config = config
|
||
self._clip_repo.update(clip)
|
||
|
||
logger.info("删除字幕: clip_id=%s subtitle_id=%s", clip_id, subtitle_id)
|
||
return True
|
||
|
||
def batch_update_subtitles(
|
||
self,
|
||
clip_id: str,
|
||
subtitles: List[Dict[str, Any]],
|
||
) -> List[Dict[str, Any]]:
|
||
"""批量更新字幕(全量替换,用于批量编辑或导入)
|
||
|
||
Args:
|
||
clip_id: 片段 ID
|
||
subtitles: 字幕列表,每条需包含 start/end/text,已有 id 则保留
|
||
|
||
Returns:
|
||
List[dict]: 更新后的字幕列表
|
||
"""
|
||
clip = self.get_clip_or_raise(clip_id)
|
||
|
||
from uuid import uuid4
|
||
|
||
validated = []
|
||
for s in subtitles:
|
||
start = float(s.get("start", 0))
|
||
end = float(s.get("end", 0))
|
||
text = str(s.get("text", ""))
|
||
|
||
if start < 0 or end <= start:
|
||
raise ValueError(f"字幕时间非法: start={start}, end={end}")
|
||
if not text.strip():
|
||
continue # 跳过空字幕
|
||
if end > clip.duration + 0.001:
|
||
raise ValueError(f"字幕结束时间不能超过片段时长: end={end}")
|
||
|
||
subtitle_id = s.get("id") or uuid4().hex
|
||
validated.append(
|
||
{
|
||
"id": subtitle_id,
|
||
"start": round(start, 3),
|
||
"end": round(end, 3),
|
||
"text": text.strip(),
|
||
"style": s.get("style", {}),
|
||
}
|
||
)
|
||
|
||
validated.sort(key=lambda s: s["start"])
|
||
|
||
self._auto_resume_editing(clip.plan_id)
|
||
|
||
config = dict(clip.config) if clip.config else {}
|
||
config["subtitles"] = validated
|
||
clip.config = config
|
||
self._clip_repo.update(clip)
|
||
|
||
logger.info(
|
||
"批量更新字幕: clip_id=%s count=%d",
|
||
clip_id,
|
||
len(validated),
|
||
)
|
||
|
||
return validated
|
||
|
||
def get_plan_with_clips(self, plan_id: str) -> Dict[str, Any]:
|
||
"""获取计划及其所有片段
|
||
|
||
Returns:
|
||
dict: {"plan": EditPlan, "clips": List[EditPlanClip]}
|
||
"""
|
||
plan = self.get_plan_or_raise(plan_id)
|
||
clips = self._clip_repo.list_by_plan(plan_id)
|
||
return {
|
||
"plan": plan,
|
||
"clips": clips,
|
||
}
|
||
|
||
def get_generation_status(self, plan_id: str) -> Dict[str, Any]:
|
||
"""获取渲染进度状态
|
||
|
||
Returns:
|
||
dict: {
|
||
"plan": EditPlan,
|
||
"clips": List[EditPlanClip],
|
||
"generation_task_id": Optional[str],
|
||
"generation_task_status": Optional[str],
|
||
"progress": float,
|
||
"error_message": str,
|
||
}
|
||
|
||
Raises:
|
||
ValueError: 计划不存在
|
||
"""
|
||
plan = self.get_plan_or_raise(plan_id)
|
||
clips = self._clip_repo.list_by_plan(plan_id)
|
||
|
||
# 从 plan.config 中获取 generation_task_id
|
||
generation_task_id = plan.config.get("generation_task_id")
|
||
generation_task_status = None
|
||
progress = 0.0
|
||
error_message = ""
|
||
|
||
if generation_task_id:
|
||
task = self._generation_task_repo.get(generation_task_id)
|
||
if task:
|
||
generation_task_status = task.status.value if hasattr(task.status, "value") else task.status
|
||
progress = getattr(task, "progress", 0.0) or 0.0
|
||
error_message = getattr(task, "error_message", "") or ""
|
||
|
||
return {
|
||
"plan": plan,
|
||
"clips": clips,
|
||
"generation_task_id": generation_task_id,
|
||
"generation_task_status": generation_task_status,
|
||
"progress": progress,
|
||
"error_message": error_message,
|
||
}
|
||
|
||
def can_generate(self, plan_id: str) -> tuple[bool, str]:
|
||
"""检查是否可以触发渲染
|
||
|
||
Returns:
|
||
tuple: (can_generate, reason)
|
||
"""
|
||
plan = self.get_plan_or_raise(plan_id)
|
||
|
||
# 检查状态
|
||
if plan.status != EditPlanStatus.EDITING:
|
||
return False, "请先编辑并保存模板后再生成视频"
|
||
|
||
# 检查是否有片段
|
||
clips = self._clip_repo.list_by_plan(plan_id)
|
||
if not clips:
|
||
return False, "请先添加片段后再生成视频"
|
||
|
||
return True, ""
|
||
|
||
def mark_clips_ready(self, plan_id: str) -> int:
|
||
"""将所有 pending 状态的片段标记为 ready
|
||
|
||
Returns:
|
||
int: 标记的片段数量
|
||
"""
|
||
clips = self._clip_repo.list_by_plan(
|
||
plan_id,
|
||
status=EditPlanClipStatus.PENDING,
|
||
)
|
||
count = 0
|
||
for clip in clips:
|
||
clip.mark_ready()
|
||
self._clip_repo.update(clip)
|
||
count += 1
|
||
logger.info("标记片段就绪: plan_id=%s count=%d", plan_id, count)
|
||
return count
|
||
|
||
def update_plan_config(self, plan_id: str, config_updates: Dict[str, Any]) -> EditPlan:
|
||
"""更新计划配置(合并更新)
|
||
|
||
Args:
|
||
plan_id: 计划 ID
|
||
config_updates: 要合并的配置
|
||
|
||
Returns:
|
||
更新后的计划
|
||
"""
|
||
plan = self.get_plan_or_raise(plan_id)
|
||
# 自动从 completed/failed 切回 editing
|
||
self._auto_resume_editing(plan_id)
|
||
plan = self.get_plan_or_raise(plan_id)
|
||
new_config = {**plan.config, **config_updates}
|
||
|
||
updated = EditPlan(
|
||
id=plan.id,
|
||
template_id=plan.template_id,
|
||
name=plan.name,
|
||
status=plan.status,
|
||
total_duration=plan.total_duration,
|
||
source_edit_plan_id=plan.source_edit_plan_id,
|
||
project_id=plan.project_id,
|
||
created_by_user_id=plan.created_by_user_id,
|
||
config=new_config,
|
||
created_at=plan.created_at,
|
||
updated_at=plan.updated_at,
|
||
)
|
||
return self._plan_repo.update(updated)
|
||
|
||
# ── 复制计划 ────────────────────────────────────────────────────────────
|
||
|
||
def copy_plan(
|
||
self,
|
||
plan_id: str,
|
||
*,
|
||
new_name: Optional[str] = None,
|
||
project_id: Optional[str] = None,
|
||
) -> EditPlan:
|
||
"""复制一个剪辑计划(含所有片段配置)。
|
||
|
||
新计划状态为 editing,不含生成任务和结果记录。
|
||
|
||
Args:
|
||
plan_id: 源计划 ID
|
||
new_name: 新计划名称,不传则为「原名 - 副本」
|
||
project_id: 新计划的项目 ID,不传则复用源计划
|
||
|
||
Returns:
|
||
EditPlan: 新创建的计划
|
||
|
||
Raises:
|
||
ValueError: 源计划不存在
|
||
"""
|
||
source = self.get_plan_or_raise(plan_id)
|
||
source_clips = self._clip_repo.list_by_plan(plan_id)
|
||
|
||
# 新计划名称
|
||
name = new_name or f"{source.name} - 副本"
|
||
new_project_id = project_id if project_id is not None else source.project_id
|
||
|
||
# 复制 plan 配置(去除渲染结果相关字段)
|
||
new_config = dict(source.config)
|
||
new_config.pop("rendered_url", None)
|
||
new_config.pop("rendered_storage_key", None)
|
||
new_config.pop("generation_task_id", None)
|
||
|
||
# 创建新计划
|
||
new_plan = EditPlan.create(
|
||
template_id=source.template_id,
|
||
name=name,
|
||
config=new_config,
|
||
total_duration=source.total_duration,
|
||
project_id=new_project_id,
|
||
created_by_user_id=source.created_by_user_id,
|
||
source_edit_plan_id=plan_id,
|
||
)
|
||
# 强制切到 editing 状态
|
||
if new_plan.status != EditPlanStatus.EDITING:
|
||
try:
|
||
new_plan.start_editing()
|
||
except ValueError:
|
||
pass
|
||
|
||
created_plan = self._plan_repo.create(new_plan)
|
||
logger.info(
|
||
"复制剪辑计划: source=%s target=%s name=%s clips=%d",
|
||
plan_id,
|
||
created_plan.id,
|
||
name,
|
||
len(source_clips),
|
||
)
|
||
|
||
# 复制所有片段
|
||
for clip in source_clips:
|
||
new_clip = self.create_clip(
|
||
plan_id=created_plan.id,
|
||
clip_type=clip.clip_type,
|
||
order=clip.order,
|
||
asset_id=clip.asset_id or "",
|
||
text_content=clip.text_content or "",
|
||
start_time=clip.start_time,
|
||
duration=clip.duration,
|
||
transition_effect=clip.transition_effect or "cut",
|
||
transition_duration=clip.transition_duration or 0.0,
|
||
playback_speed=clip.playback_speed or 1.0,
|
||
config=dict(clip.config) if clip.config else None,
|
||
)
|
||
logger.debug("复制片段: source=%s target=%s order=%d", clip.id, new_clip.id, clip.order)
|
||
|
||
return self.get_plan_or_raise(created_plan.id)
|