"""EditTemplateService — 模板管理业务逻辑. 封装 EditTemplate 和 TemplateClipConfig 的 CRUD 操作, 提供统一的业务接口供 API 路由层调用。 """ from __future__ import annotations import logging from typing import Any, List, Optional from sqlalchemy.orm import Session from packages.adapters.sqlalchemy_impl import ( SQLAlchemyEditPlanClipRepository, SQLAlchemyEditPlanRepository, SQLAlchemyEditTemplateRepository, SQLAlchemyTemplateClipConfigRepository, ) from packages.domain.edit_template import EditTemplate, EditTemplateStatus from packages.domain.template_clip_config import ( ClipType, TemplateClipConfig, TransitionEffect, ) logger = logging.getLogger(__name__) class EditTemplateService: """模板管理服务 职责: - 模板 CRUD(创建、查询、更新、软删除) - 模板片段配置管理(增删改查) - 业务校验(名称去重、状态合法性等) """ def __init__(self, db: Session) -> None: self._template_repo = SQLAlchemyEditTemplateRepository(db) self._clip_config_repo = SQLAlchemyTemplateClipConfigRepository(db) self._plan_repo = SQLAlchemyEditPlanRepository(db) self._plan_clip_repo = SQLAlchemyEditPlanClipRepository(db) self._db = db # ── 模板 CRUD ────────────────────────────────────────────────────────── def list_templates( self, *, template_type: Optional[str] = None, status: Optional[EditTemplateStatus] = None, active_only: bool = False, skip: int = 0, limit: int = 50, ) -> List[EditTemplate]: """列出模板 Args: template_type: 按类型筛选 status: 按状态筛选 active_only: 仅返回激活模板 skip: 分页偏移 limit: 每页数量 """ if active_only: return self._template_repo.list_active( template_type=template_type, skip=skip, limit=limit, ) return self._template_repo.list_all( template_type=template_type, status=status, skip=skip, limit=limit, ) def count_templates( self, *, template_type: Optional[str] = None, status: Optional[EditTemplateStatus] = None, ) -> int: """统计模板数量""" return self._template_repo.count( template_type=template_type, status=status, ) def get_template(self, template_id: str) -> Optional[EditTemplate]: """获取模板详情""" return self._template_repo.get(template_id) def get_template_or_raise(self, template_id: str) -> EditTemplate: """获取模板,不存在则抛出 ValueError""" template = self._template_repo.get(template_id) if template is None: raise ValueError(f"模板不存在: {template_id}") return template def create_template( self, name: str, *, description: str = "", template_type: str = "default", editing_mode: str = "one_take", config: Optional[dict[str, Any]] = None, preview_url: str = "", sort_weight: int = 0, ) -> EditTemplate: """创建模板 Raises: ValueError: 名称为空或重复 """ # 名称校验 clean_name = name.strip() if not clean_name: raise ValueError("模板名称不能为空") # 名称重复检查 existing = self._template_repo.list_all(skip=0, limit=1000) for t in existing: if t.name == clean_name and t.status == EditTemplateStatus.ACTIVE: raise ValueError(f"模板名称已存在: {clean_name}") template = EditTemplate.create( name=clean_name, description=description, template_type=template_type, editing_mode=editing_mode, config=config, preview_url=preview_url, sort_weight=sort_weight, ) created = self._template_repo.create(template) logger.info("创建模板: id=%s name=%s", created.id, created.name) return created def update_template( self, template_id: str, *, name: Optional[str] = None, description: Optional[str] = None, template_type: Optional[str] = None, editing_mode: Optional[str] = None, config: Optional[dict[str, Any]] = None, preview_url: Optional[str] = None, sort_weight: Optional[int] = None, status: Optional[EditTemplateStatus] = None, ) -> EditTemplate: """更新模板 Raises: ValueError: 模板不存在或名称重复 """ existing = self.get_template_or_raise(template_id) # 名称重复检查(排除自身) new_name = name.strip() if name is not None else existing.name if name is not None and new_name != existing.name: all_templates = self._template_repo.list_all(skip=0, limit=1000) for t in all_templates: if t.id != template_id and t.name == new_name and t.status == EditTemplateStatus.ACTIVE: raise ValueError(f"模板名称已存在: {new_name}") # 构建更新后的实体 updated = EditTemplate( id=existing.id, name=new_name, description=description.strip() if description is not None else existing.description, template_type=template_type.strip() if template_type is not None else existing.template_type, editing_mode=editing_mode.strip() if editing_mode is not None else existing.editing_mode, config=config if config is not None else existing.config, preview_url=preview_url.strip() if preview_url is not None else existing.preview_url, sort_weight=sort_weight if sort_weight is not None else existing.sort_weight, status=status if status is not None else existing.status, created_at=existing.created_at, updated_at=existing.updated_at, ) result = self._template_repo.update(updated) logger.info("更新模板: id=%s", template_id) return result def deactivate_template(self, template_id: str) -> EditTemplate: """软删除模板(设为 inactive) Raises: ValueError: 模板不存在 """ existing = self.get_template_or_raise(template_id) existing.deactivate() result = self._template_repo.update(existing) logger.info("停用模板: id=%s", template_id) return result # ── 模板片段配置管理 ──────────────────────────────────────────────────── def list_clip_configs( self, template_id: str, *, clip_type: Optional[ClipType] = None, skip: int = 0, limit: int = 100, ) -> List[TemplateClipConfig]: """列出模板的片段配置""" # 确保模板存在 self.get_template_or_raise(template_id) return self._clip_config_repo.list_by_template( template_id, clip_type=clip_type, skip=skip, limit=limit, ) def get_clip_config(self, config_id: str) -> Optional[TemplateClipConfig]: """获取片段配置详情""" return self._clip_config_repo.get(config_id) def get_clip_config_or_raise(self, config_id: str) -> TemplateClipConfig: """获取片段配置,不存在则抛出 ValueError""" config = self._clip_config_repo.get(config_id) if config is None: raise ValueError(f"片段配置不存在: {config_id}") return config def create_clip_config( self, template_id: str, clip_type: ClipType | str, order: int, *, min_duration: float = 0.0, max_duration: float = 0.0, text_template: str = "", material_requirements: Optional[dict[str, Any]] = None, transition_effect: TransitionEffect | str = TransitionEffect.CUT, config: Optional[dict[str, Any]] = None, ) -> TemplateClipConfig: """创建片段配置 Raises: ValueError: 模板不存在或参数校验失败 """ # 确保模板存在 self.get_template_or_raise(template_id) clip_config = TemplateClipConfig.create( template_id=template_id, clip_type=clip_type, order=order, min_duration=min_duration, max_duration=max_duration, text_template=text_template, material_requirements=material_requirements, transition_effect=transition_effect, config=config, ) created = self._clip_config_repo.create(clip_config) logger.info( "创建片段配置: id=%s template_id=%s clip_type=%s order=%d", created.id, template_id, created.clip_type, created.order, ) return created def update_clip_config( self, config_id: str, *, clip_type: Optional[ClipType | str] = None, order: Optional[int] = None, min_duration: Optional[float] = None, max_duration: Optional[float] = None, text_template: Optional[str] = None, material_requirements: Optional[dict[str, Any]] = None, transition_effect: Optional[TransitionEffect | str] = None, config: Optional[dict[str, Any]] = None, ) -> TemplateClipConfig: """更新片段配置 Raises: ValueError: 配置不存在或参数校验失败 """ existing = self.get_clip_config_or_raise(config_id) # 解析枚举类型 new_clip_type = ClipType(clip_type) if clip_type is not None else existing.clip_type new_transition = ( TransitionEffect(transition_effect) if transition_effect is not None else existing.transition_effect ) updated = TemplateClipConfig( id=existing.id, template_id=existing.template_id, clip_type=new_clip_type, order=order if order is not None else existing.order, min_duration=min_duration if min_duration is not None else existing.min_duration, max_duration=max_duration if max_duration is not None else existing.max_duration, text_template=text_template.strip() if text_template is not None else existing.text_template, material_requirements=( material_requirements if material_requirements is not None else existing.material_requirements ), transition_effect=new_transition, config=config if config is not None else existing.config, created_at=existing.created_at, updated_at=existing.updated_at, ) result = self._clip_config_repo.update(updated) logger.info("更新片段配置: id=%s", config_id) return result def delete_clip_config(self, config_id: str) -> bool: """删除片段配置 Returns: bool: 是否删除成功 """ deleted = self._clip_config_repo.delete(config_id) if deleted: logger.info("删除片段配置: id=%s", config_id) return deleted def reorder_clip_configs( self, template_id: str, config_ids: List[str], ) -> List[TemplateClipConfig]: """重新排序片段配置 Args: template_id: 模板 ID config_ids: 按新顺序排列的配置 ID 列表 Returns: 更新后的配置列表 Raises: ValueError: 模板不存在或配置 ID 不匹配 """ # 确保模板存在 self.get_template_or_raise(template_id) # 获取当前配置 current_configs = self._clip_config_repo.list_by_template(template_id) current_ids = {c.id for c in current_configs} # 校验 ID 列表 if set(config_ids) != current_ids: raise ValueError("配置 ID 列表与模板下的配置不匹配") # 更新 order results = [] for new_order, config_id in enumerate(config_ids): config = self._clip_config_repo.get(config_id) if config is None: continue updated = TemplateClipConfig( id=config.id, template_id=config.template_id, clip_type=config.clip_type, order=new_order, min_duration=config.min_duration, max_duration=config.max_duration, text_template=config.text_template, material_requirements=config.material_requirements, transition_effect=config.transition_effect, config=config.config, created_at=config.created_at, updated_at=config.updated_at, ) results.append(self._clip_config_repo.update(updated)) logger.info( "重排序片段配置: template_id=%s count=%d", template_id, len(config_ids), ) return results def get_template_with_configs( self, template_id: str, ) -> dict: """获取模板及其所有片段配置 Returns: dict: {"template": EditTemplate, "clip_configs": List[TemplateClipConfig]} """ template = self.get_template_or_raise(template_id) clip_configs = self._clip_config_repo.list_by_template(template_id) return { "template": template, "clip_configs": clip_configs, } # ── 从剪辑计划保存为模板 ────────────────────────────────────────────── def save_plan_as_template( self, plan_id: str, name: str, *, description: str = "", template_type: str = "custom", preview_url: str = "", ) -> dict[str, Any]: """将剪辑计划保存为模板 将指定剪辑计划的配置和片段结构另存为一个新模板, 方便后续基于该模板快速创建新的剪辑计划。 转换规则: - 计划名称 → 模板名称(调用方传入,支持自定义) - 计划 config → 模板 config(整体迁移) - 计划 editing_mode 从 config 中提取,默认 one_take - 每个片段转换为模板片段配置: - clip_type 直接映射 - order 保持不变 - duration → min_duration = max_duration = duration(固定时长) - text_content → text_template - transition_effect 直接映射 - playback_speed 等播放参数存入 config - 不保留 asset_id(模板不绑定具体素材) Args: plan_id: 源剪辑计划 ID name: 新模板名称 description: 模板描述 template_type: 模板类型,默认 custom(用户自定义) preview_url: 预览图 URL Returns: dict: {"template": EditTemplate, "clip_configs": List[TemplateClipConfig]} Raises: ValueError: 计划不存在或名称为空/重复 """ # 1. 读取源计划 plan = self._plan_repo.get(plan_id) if plan is None: raise ValueError(f"剪辑计划不存在: {plan_id}") # 2. 读取所有片段(按 order 排序) clips = self._plan_clip_repo.list_by_plan(plan_id) clips.sort(key=lambda c: c.order) # 3. 提取 editing_mode editing_mode = plan.config.get("editing_mode", "one_take") if plan.config else "one_take" # 4. 创建模板(复用 create_template 的校验逻辑,但手动构建避免重复查询) clean_name = name.strip() if not clean_name: raise ValueError("模板名称不能为空") # 名称重复检查 existing = self._template_repo.list_all(skip=0, limit=1000) for t in existing: if t.name == clean_name and t.status == EditTemplateStatus.ACTIVE: raise ValueError(f"模板名称已存在: {clean_name}") # 从计划 config 中提取模板级配置,去掉运行时/素材相关字段 plan_config = plan.config or {} template_config: dict[str, Any] = {} for key, value in plan_config.items(): # 跳过明显的运行时/实例字段,保留风格/模式类配置 if key not in {"asset_ids", "source_edit_plan_id", "generation_task_id"}: template_config[key] = value template = EditTemplate.create( name=clean_name, description=description, template_type=template_type, editing_mode=editing_mode, config=template_config, preview_url=preview_url, ) created_template = self._template_repo.create(template) logger.info( "从剪辑计划创建模板: plan_id=%s template_id=%s name=%s clip_count=%d", plan_id, created_template.id, clean_name, len(clips), ) # 5. 转换每个片段为模板片段配置 created_configs: List[TemplateClipConfig] = [] for clip in clips: clip_config: dict[str, Any] = {} # 播放速度存入 config if clip.playback_speed and clip.playback_speed != 1.0: clip_config["playback_speed"] = clip.playback_speed # 片段自有 config 合并(优先级:clip.config 覆盖上面的) if clip.config: clip_config.update(clip.config) # 去掉素材相关字段 clip_config.pop("asset_info", None) clip_config.pop("source_asset_id", None) # 转场效果兼容校验 try: transition = TransitionEffect(clip.transition_effect) except ValueError: transition = TransitionEffect.CUT # 片段类型兼容校验 try: clip_type = ClipType(clip.clip_type) except ValueError: clip_type = ClipType.MAIN clip_config_obj = TemplateClipConfig.create( template_id=created_template.id, clip_type=clip_type, order=clip.order, min_duration=clip.duration, max_duration=clip.duration, text_template=clip.text_content or "", transition_effect=transition, config=clip_config, ) created = self._clip_config_repo.create(clip_config_obj) created_configs.append(created) return { "template": created_template, "clip_configs": created_configs, }