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>
536 lines
19 KiB
Python
Executable File
536 lines
19 KiB
Python
Executable File
"""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,
|
||
}
|