"""Template use cases.""" from __future__ import annotations import uuid from dataclasses import dataclass, field from typing import List, Optional from packages.application.template.commands import ( CopyTemplateCommand, CreateCategoryCommand, CreateTemplateCommand, ListTemplatesFilter, UpdateTemplateCommand, ValidateTemplateCommand, ) from packages.domain.editing_mode import EditingMode from packages.domain.template import Template, TemplateCategory, TemplateSegment from packages.ports.template_repository import TemplateRepositoryPort class NotFoundError(Exception): pass class ValidationError(Exception): """业务规则校验失败.""" pass VALID_MODES = {m.value for m in EditingMode} VALID_MATERIAL_TYPES = {"人物", "场景"} @dataclass class GenerateWarning: """生成时的警告信息.""" code: str # voiceover_duration_mismatch / missing_material_type / ... message: str details: dict = field(default_factory=dict) @dataclass class ValidateResult: """模板校验结果.""" template: Template warnings: List[GenerateWarning] = field(default_factory=list) # ── Template CRUD ── class CreateTemplateUseCase: def __init__(self, repository: TemplateRepositoryPort) -> None: self.repository = repository def execute(self, command: CreateTemplateCommand) -> Template: if command.mode not in VALID_MODES: raise ValidationError(f"无效的剪辑模式: {command.mode},可选值: {VALID_MODES}") template_id = uuid.uuid4().hex template = Template( id=template_id, user_id=command.user_id, name=command.name, mode=command.mode, category=command.category, tags=command.tags, title_config=command.title_config, subtitle_config=command.subtitle_config, bgm_config=command.bgm_config, estimated_duration=command.estimated_duration, ) template = self.repository.create(template) # 始终调用 create_segments 以确保在同一事务中提交 segments = [ TemplateSegment( id=uuid.uuid4().hex, template_id=template.id, segment_order=seg.segment_order, duration_min=seg.duration_min, duration_max=seg.duration_max, material_type=seg.material_type, ) for seg in command.segments ] self.repository.create_segments(segments) template.segments = segments return template class ListTemplatesUseCase: def __init__(self, repository: TemplateRepositoryPort) -> None: self.repository = repository def execute( self, user_id: str, *, skip: int = 0, limit: int = 50, filter: Optional[ListTemplatesFilter] = None, ) -> List[Template]: if filter is None: return self.repository.list_by_user(user_id, skip=skip, limit=limit) return self.repository.list_by_user( user_id, skip=skip, limit=limit, category=filter.category, tag=filter.tag, keyword=filter.keyword, mode=filter.mode, ) class CountTemplatesUseCase: def __init__(self, repository: TemplateRepositoryPort) -> None: self.repository = repository def execute( self, user_id: str, *, filter: Optional[ListTemplatesFilter] = None, ) -> int: if filter is None: return self.repository.count_by_user(user_id) return self.repository.count_by_user( user_id, category=filter.category, tag=filter.tag, keyword=filter.keyword, mode=filter.mode, ) class GetTemplateUseCase: def __init__(self, repository: TemplateRepositoryPort) -> None: self.repository = repository def execute(self, template_id: str, user_id: str) -> Optional[Template]: return self.repository.get(template_id, user_id) class UpdateTemplateUseCase: def __init__(self, repository: TemplateRepositoryPort) -> None: self.repository = repository def execute(self, command: UpdateTemplateCommand) -> Template: existing = self.repository.get(command.template_id, command.user_id) if existing is None: raise NotFoundError(f"Template {command.template_id} not found") if command.mode is not None and command.mode not in VALID_MODES: raise ValidationError(f"无效的剪辑模式: {command.mode}") if command.name is not None: existing.name = command.name if command.mode is not None: existing.mode = command.mode if command.category is not None: existing.category = command.category if command.tags is not None: existing.tags = command.tags if command.title_config is not None: existing.title_config = command.title_config if command.subtitle_config is not None: existing.subtitle_config = command.subtitle_config if command.bgm_config is not None: existing.bgm_config = command.bgm_config if command.estimated_duration is not None: existing.estimated_duration = command.estimated_duration self.repository.update(existing) # Replace segments if provided if command.segments is not None: self.repository.delete_segments_by_template(existing.id) segments = [ TemplateSegment( id=uuid.uuid4().hex, template_id=existing.id, segment_order=seg.segment_order, duration_min=seg.duration_min, duration_max=seg.duration_max, material_type=seg.material_type, ) for seg in command.segments ] self.repository.create_segments(segments) existing.segments = segments else: existing.segments = self.repository.list_segments(existing.id) return existing class DeleteTemplateUseCase: def __init__(self, repository: TemplateRepositoryPort) -> None: self.repository = repository def execute(self, template_id: str, user_id: str) -> bool: return self.repository.delete(template_id, user_id) class CopyTemplateUseCase: def __init__(self, repository: TemplateRepositoryPort) -> None: self.repository = repository def execute(self, command: CopyTemplateCommand) -> Template: existing = self.repository.get(command.template_id, command.user_id) if existing is None: raise NotFoundError(f"Template {command.template_id} not found") if not command.new_name or not command.new_name.strip(): raise ValidationError("新模板名称不能为空") return self.repository.copy_template( command.template_id, command.user_id, command.new_name.strip(), ) # ── Validate template ── class ValidateTemplateUseCase: """校验模板业务规则.""" def __init__(self, repository: TemplateRepositoryPort) -> None: self.repository = repository def execute(self, command: ValidateTemplateCommand) -> ValidateResult: template = self.repository.get(command.template_id, command.user_id) if template is None: raise NotFoundError(f"Template {command.template_id} not found") warnings: List[GenerateWarning] = [] # 业务规则 1: one_take 必须恰好 1 个片段 if template.mode == EditingMode.ONE_TAKE.value: if len(template.segments) != 1: raise ValidationError(f"一镜到底模式必须恰好有 1 个片段,当前有 {len(template.segments)} 个") # 业务规则 2: voice_over 每个片段必须有 material_type if template.mode == EditingMode.VOICE_OVER.value: for seg in template.segments: if not seg.material_type or seg.material_type not in VALID_MATERIAL_TYPES: raise ValidationError( f"口播+B-roll模式下每个片段必须指定 material_type(人物/场景)," f"片段 {seg.segment_order} 的 material_type 无效: {seg.material_type}" ) # 业务规则 3: 配音时长偏差 ±30% 警告 if command.voiceover_duration is not None and template.estimated_duration > 0: ratio = command.voiceover_duration / template.estimated_duration if ratio < 0.7 or ratio > 1.3: warnings.append( GenerateWarning( code="voiceover_duration_mismatch", message=( f"配音时长 ({command.voiceover_duration:.1f}s) " f"与预估时长 ({template.estimated_duration:.1f}s) " f"偏差超过 ±30%,可能影响剪辑效果" ), details={ "voiceover_duration": command.voiceover_duration, "estimated_duration": template.estimated_duration, "ratio": round(ratio, 3), }, ) ) return ValidateResult(template=template, warnings=warnings) # ── Category CRUD ── class CreateCategoryUseCase: def __init__(self, repository: TemplateRepositoryPort) -> None: self.repository = repository def execute(self, command: CreateCategoryCommand) -> TemplateCategory: category = TemplateCategory( id=uuid.uuid4().hex, user_id=command.user_id, name=command.name, ) return self.repository.create_category(category) class ListCategoriesUseCase: def __init__(self, repository: TemplateRepositoryPort) -> None: self.repository = repository def execute(self, user_id: str) -> List[TemplateCategory]: return self.repository.list_categories(user_id) class DeleteCategoryUseCase: def __init__(self, repository: TemplateRepositoryPort) -> None: self.repository = repository def execute(self, category_id: str, user_id: str) -> bool: return self.repository.delete_category(category_id, user_id) # ── Tags ── class ListTagsUseCase: def __init__(self, repository: TemplateRepositoryPort) -> None: self.repository = repository def execute(self, user_id: str) -> List[str]: return self.repository.list_tags(user_id) # ── Usage Stats ── class GetTemplateUsageUseCase: def __init__(self, repository: TemplateRepositoryPort) -> None: self.repository = repository def execute(self, template_id: str) -> int: return self.repository.get_usage_count(template_id)