diff --git a/apps/api/app/api/routes/edit_plans.py b/apps/api/app/api/routes/edit_plans.py index c3cc7692d..5b074fce4 100644 --- a/apps/api/app/api/routes/edit_plans.py +++ b/apps/api/app/api/routes/edit_plans.py @@ -8,6 +8,8 @@ RESTful CRUD for EditPlan: - 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) + +业务逻辑委托给 EditPlanService 服务层。 """ from __future__ import annotations @@ -19,14 +21,11 @@ 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_db_session +from app.services import EditPlanService from fastapi import APIRouter, Depends, HTTPException, Query, status from pydantic import BaseModel, Field from sqlalchemy.orm import Session -from packages.adapters.sqlalchemy_impl import SQLAlchemyEditPlanRepository -from packages.adapters.sqlalchemy_impl.edit_plan_clip_repository import ( - SQLAlchemyEditPlanClipRepository, -) from packages.adapters.sqlalchemy_impl.generation_task_repository import ( SQLAlchemyGenerationTaskRepository, ) @@ -35,7 +34,6 @@ from packages.application.generation_tasks import ( CreateGenerationTaskUseCase, ) from packages.domain.edit_plan import EditPlan, EditPlanStatus -from packages.domain.edit_plan_clip import EditPlanClipStatus logger = logging.getLogger(__name__) @@ -136,30 +134,6 @@ def _to_response(p: EditPlan) -> EditPlanResponse: ) -def _apply_status_transition(plan: EditPlan, target_status_str: str) -> None: - """通过状态机方法流转状态,非法流转抛出 ValueError""" - try: - target = EditPlanStatus(target_status_str) - except ValueError: - raise ValueError( - f"无效的状态值: {target_status_str}," - f"可选值: draft, editing, rendering, completed, failed" - ) - - if target == plan.status: - return # 已是目标状态,无需流转 - - # 根据目标状态选择对应的状态机方法 - transition_map = { - EditPlanStatus.EDITING: plan.start_editing, - EditPlanStatus.RENDERING: plan.start_rendering, - EditPlanStatus.COMPLETED: plan.mark_completed, - EditPlanStatus.FAILED: plan.mark_failed, - EditPlanStatus.DRAFT: plan.reset_to_draft, - } - transition_map[target]() - - # ── Routes ──────────────────────────────────────────────────────────────────── @@ -177,7 +151,7 @@ def list_plans( current_user: AuthenticatedUser = Depends(get_current_user), ) -> EditPlanListResponse: """获取剪辑计划列表(支持分页、按模板/状态筛选)""" - repo = SQLAlchemyEditPlanRepository(db) + svc = EditPlanService(db) # 解析状态筛选 status_enum: Optional[EditPlanStatus] = None @@ -194,26 +168,16 @@ def list_plans( ) skip = (page - 1) * page_size - - # 根据是否有 template_id 选择查询方法 - if template_id: - plans = repo.list_by_template( - template_id, - status=status_enum, - skip=skip, - limit=page_size, - ) - # count() 不支持 template_id 筛选,通过全量查询计算 total - all_matching = repo.list_by_template( - template_id, - status=status_enum, - skip=0, - limit=10000, - ) - total = len(all_matching) - else: - plans = repo.list_all(status=status_enum, skip=skip, limit=page_size) - total = repo.count(status=status_enum) + plans = svc.list_plans( + template_id=template_id, + status=status_enum, + skip=skip, + limit=page_size, + ) + total = svc.count_plans( + template_id=template_id, + status=status_enum, + ) return EditPlanListResponse( items=[_to_response(p) for p in plans], @@ -230,12 +194,13 @@ def get_plan( current_user: AuthenticatedUser = Depends(get_current_user), ) -> EditPlanResponse: """获取单个剪辑计划详情""" - repo = SQLAlchemyEditPlanRepository(db) - plan = repo.get(plan_id) - if plan is None: + 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=f"剪辑计划不存在: {plan_id}", + detail=str(exc), ) return _to_response(plan) @@ -247,9 +212,9 @@ def create_plan( current_user: AuthenticatedUser = Depends(get_current_user), ) -> EditPlanResponse: """创建剪辑计划""" - repo = SQLAlchemyEditPlanRepository(db) + svc = EditPlanService(db) try: - plan = EditPlan.create( + created = svc.create_plan( template_id=body.template_id, name=body.name, config=body.config, @@ -260,7 +225,6 @@ def create_plan( status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc), ) - created = repo.create(plan) logger.info( "创建剪辑计划: id=%s name=%s by user=%s", created.id, @@ -278,49 +242,39 @@ def update_plan( current_user: AuthenticatedUser = Depends(get_current_user), ) -> EditPlanResponse: """更新剪辑计划(支持状态机流转)""" - repo = SQLAlchemyEditPlanRepository(db) - existing = repo.get(plan_id) - if existing is None: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=f"剪辑计划不存在: {plan_id}", - ) + svc = EditPlanService(db) # 基础字段更新 - new_name = body.name.strip() if body.name is not None else existing.name - new_config = body.config if body.config is not None else existing.config - new_total_duration = body.total_duration if body.total_duration is not None else existing.total_duration - - # 状态机流转 - new_status = existing.status - if body.status is not None: - try: - _apply_status_transition(existing, body.status) - new_status = existing.status - except ValueError as exc: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=str(exc), + try: + if body.name is not None or body.config is not None or body.total_duration is not None: + svc.update_plan( + plan_id, + name=body.name, + config=body.config, + total_duration=body.total_duration, ) - try: - updated = EditPlan( - id=existing.id, - template_id=existing.template_id, - name=new_name, - status=new_status, - total_duration=new_total_duration, - config=new_config, - created_at=existing.created_at, - updated_at=existing.updated_at, - ) - except (ValueError, TypeError) as exc: + # 状态机流转 + if body.status is not None: + try: + target_status = EditPlanStatus(body.status) + except ValueError: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=( + f"无效的状态值: {body.status}," + f"可选值: draft, editing, rendering, completed, failed" + ), + ) + svc.transition_status(plan_id, target_status) + except ValueError as exc: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc), ) - result = repo.update(updated) + # 返回最新状态 + 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) @@ -332,15 +286,13 @@ def delete_plan( current_user: AuthenticatedUser = Depends(get_current_user), ) -> None: """删除剪辑计划""" - repo = SQLAlchemyEditPlanRepository(db) - existing = repo.get(plan_id) - if existing is None: + svc = EditPlanService(db) + deleted = svc.delete_plan(plan_id) + if not deleted: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"剪辑计划不存在: {plan_id}", ) - - repo.delete(plan_id) logger.info( "删除剪辑计划: id=%s by user=%s", plan_id, @@ -367,43 +319,23 @@ def generate_plan( 4. 调度 Celery 任务 worker.render_edit_plan 5. 将计划状态流转为 rendering """ - plan_repo = SQLAlchemyEditPlanRepository(db) - clip_repo = SQLAlchemyEditPlanClipRepository(db) - gen_task_repo = SQLAlchemyGenerationTaskRepository(db) + svc = EditPlanService(db) - plan = plan_repo.get(plan_id) - if plan is None: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=f"剪辑计划不存在: {plan_id}", - ) - - # 验证状态必须为 editing - if plan.status != EditPlanStatus.EDITING: + # 检查是否可生成 + can_gen, reason = svc.can_generate(plan_id) + if not can_gen: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail=( - f"只有 editing 状态的计划才能触发生成,当前状态: {plan.status.value}。" - f"请先将计划状态流转为 editing" - ), - ) - - # 检查片段 - clips = clip_repo.list_by_plan(plan_id, skip=0, limit=10000) - if not clips: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="剪辑计划没有片段,请先添加片段再触发生成", + detail=reason, ) # 将 pending 片段标记为 ready - pending_clips = [c for c in clips if c.status == EditPlanClipStatus.PENDING] - for clip in pending_clips: - clip.mark_ready() - clip_repo.update(clip) + 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="", @@ -413,28 +345,30 @@ def generate_plan( ) # 将 generation_task_id 存入 plan config - plan.config["generation_task_id"] = gen_task.id + svc.update_plan_config(plan_id, {"generation_task_id": gen_task.id}) # 流转状态为 rendering - plan.start_rendering() - plan_repo.update(plan) + 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, - len(clips), + clip_count, current_user.user.id, ) return EditPlanGenerateResponse( plan_id=plan_id, - plan_status=plan.status.value if hasattr(plan.status, "value") else plan.status, + plan_status=updated_plan.status.value if hasattr(updated_plan.status, "value") else updated_plan.status, generation_task_id=gen_task.id, - clip_count=len(clips), + clip_count=clip_count, ) @@ -451,18 +385,11 @@ def get_generation_status( 返回计划状态、关联的 GenerationTask ID、以及每个片段的状态。 """ - plan_repo = SQLAlchemyEditPlanRepository(db) - clip_repo = SQLAlchemyEditPlanClipRepository(db) + svc = EditPlanService(db) + gen_status = svc.get_generation_status(plan_id) - plan = plan_repo.get(plan_id) - if plan is None: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=f"剪辑计划不存在: {plan_id}", - ) - - clips = clip_repo.list_by_plan(plan_id, skip=0, limit=10000) - generation_task_id = plan.config.get("generation_task_id") + plan = gen_status["plan"] + clips = gen_status["clips"] clip_items = [ ClipStatusItem( @@ -480,6 +407,6 @@ def get_generation_status( return EditPlanGenerationStatusResponse( plan_id=plan_id, plan_status=plan.status.value if hasattr(plan.status, "value") else plan.status, - generation_task_id=generation_task_id, + generation_task_id=gen_status["generation_task_id"], clips=clip_items, ) diff --git a/apps/api/app/api/routes/edit_templates.py b/apps/api/app/api/routes/edit_templates.py index c27e207f8..c331a23c4 100644 --- a/apps/api/app/api/routes/edit_templates.py +++ b/apps/api/app/api/routes/edit_templates.py @@ -6,6 +6,8 @@ RESTful CRUD for EditTemplate: - POST /api/v1/edit-templates 创建(管理员) - PUT /api/v1/edit-templates/{id} 更新 - DELETE /api/v1/edit-templates/{id} 删除(软删除 → inactive) + +业务逻辑委托给 EditTemplateService 服务层。 """ from __future__ import annotations @@ -16,11 +18,11 @@ from typing import Any, List, Optional from app.auth import AuthenticatedUser, get_current_user from app.dependencies import get_db_session +from app.services import EditTemplateService from fastapi import APIRouter, Depends, HTTPException, Query, status from pydantic import BaseModel, Field from sqlalchemy.orm import Session -from packages.adapters.sqlalchemy_impl import SQLAlchemyEditTemplateRepository from packages.domain.edit_template import EditTemplate, EditTemplateStatus logger = logging.getLogger(__name__) @@ -115,7 +117,7 @@ def list_templates( current_user: AuthenticatedUser = Depends(get_current_user), ) -> EditTemplateListResponse: """获取模板列表(支持分页、按类型/状态筛选)""" - repo = SQLAlchemyEditTemplateRepository(db) + svc = EditTemplateService(db) # 解析状态筛选 status_enum: Optional[EditTemplateStatus] = None @@ -129,13 +131,16 @@ def list_templates( ) skip = (page - 1) * page_size - templates = repo.list_all( + templates = svc.list_templates( template_type=template_type, status=status_enum, skip=skip, limit=page_size, ) - total = repo.count(template_type=template_type, status=status_enum) + total = svc.count_templates( + template_type=template_type, + status=status_enum, + ) return EditTemplateListResponse( items=[_to_response(t) for t in templates], @@ -152,12 +157,13 @@ def get_template( current_user: AuthenticatedUser = Depends(get_current_user), ) -> EditTemplateResponse: """获取单个模板详情""" - repo = SQLAlchemyEditTemplateRepository(db) - template = repo.get(template_id) - if template is None: + svc = EditTemplateService(db) + try: + template = svc.get_template_or_raise(template_id) + except ValueError as exc: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail=f"模板不存在: {template_id}", + detail=str(exc), ) return _to_response(template) @@ -169,9 +175,9 @@ def create_template( current_user: AuthenticatedUser = Depends(get_current_user), ) -> EditTemplateResponse: """创建模板(管理员)""" - repo = SQLAlchemyEditTemplateRepository(db) + svc = EditTemplateService(db) try: - template = EditTemplate.create( + created = svc.create_template( name=body.name, description=body.description, template_type=body.template_type, @@ -184,7 +190,6 @@ def create_template( status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc), ) - created = repo.create(template) logger.info("创建模板: id=%s name=%s by user=%s", created.id, created.name, current_user.user.id) return _to_response(created) @@ -197,25 +202,13 @@ def update_template( current_user: AuthenticatedUser = Depends(get_current_user), ) -> EditTemplateResponse: """更新模板""" - repo = SQLAlchemyEditTemplateRepository(db) - existing = repo.get(template_id) - if existing is None: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=f"模板不存在: {template_id}", - ) + svc = EditTemplateService(db) - # 部分更新:仅覆盖 body 中提供的字段 - new_name = body.name if body.name is not None else existing.name - new_description = body.description if body.description is not None else existing.description - new_template_type = body.template_type if body.template_type is not None else existing.template_type - new_config = body.config if body.config is not None else existing.config - new_preview_url = body.preview_url if body.preview_url is not None else existing.preview_url - new_sort_weight = body.sort_weight if body.sort_weight is not None else existing.sort_weight - new_status = existing.status + # 解析状态 + status_enum: Optional[EditTemplateStatus] = None if body.status is not None: try: - new_status = EditTemplateStatus(body.status) + status_enum = EditTemplateStatus(body.status) except ValueError: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, @@ -223,25 +216,21 @@ def update_template( ) try: - updated = EditTemplate( - id=existing.id, - name=new_name.strip() if new_name else existing.name, - description=new_description.strip() if new_description is not None else existing.description, - template_type=new_template_type.strip() if new_template_type else existing.template_type, - config=new_config, - preview_url=new_preview_url.strip() if new_preview_url is not None else existing.preview_url, - sort_weight=new_sort_weight, - status=new_status, - created_at=existing.created_at, - updated_at=existing.updated_at, + result = svc.update_template( + template_id, + name=body.name, + description=body.description, + template_type=body.template_type, + config=body.config, + preview_url=body.preview_url, + sort_weight=body.sort_weight, + status=status_enum, ) - except (ValueError, TypeError) as exc: + except ValueError as exc: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc), ) - - result = repo.update(updated) logger.info("更新模板: id=%s by user=%s", template_id, current_user.user.id) return _to_response(result) @@ -253,15 +242,12 @@ def delete_template( current_user: AuthenticatedUser = Depends(get_current_user), ) -> None: """删除模板(软删除 → 设为 inactive)""" - repo = SQLAlchemyEditTemplateRepository(db) - existing = repo.get(template_id) - if existing is None: + svc = EditTemplateService(db) + try: + svc.deactivate_template(template_id) + except ValueError as exc: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail=f"模板不存在: {template_id}", + detail=str(exc), ) - - # 软删除:将状态设为 inactive - existing.deactivate() - repo.update(existing) logger.info("删除模板(软删除): id=%s by user=%s", template_id, current_user.user.id) diff --git a/apps/api/app/services/__init__.py b/apps/api/app/services/__init__.py new file mode 100644 index 000000000..b05a02004 --- /dev/null +++ b/apps/api/app/services/__init__.py @@ -0,0 +1,9 @@ +"""Service layer exports for Phase 8 模板编排引擎.""" + +from .edit_plan_service import EditPlanService +from .edit_template_service import EditTemplateService + +__all__ = [ + "EditPlanService", + "EditTemplateService", +] diff --git a/apps/api/app/services/edit_plan_service.py b/apps/api/app/services/edit_plan_service.py new file mode 100644 index 000000000..f4a91ccc4 --- /dev/null +++ b/apps/api/app/services/edit_plan_service.py @@ -0,0 +1,473 @@ +"""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 +from packages.domain.generation_task import GenerationTaskStatus + +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, + status: Optional[EditPlanStatus] = None, + skip: int = 0, + limit: int = 50, + ) -> List[EditPlan]: + """列出剪辑计划 + + Args: + template_id: 按模板 ID 筛选 + status: 按状态筛选 + skip: 分页偏移 + 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, + status: Optional[EditPlanStatus] = None, + ) -> int: + """统计计划数量 + + Note: + 当指定 template_id 时,通过全量查询计算 total(repo 限制)。 + """ + 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, + ) -> EditPlan: + """创建剪辑计划 + + Raises: + ValueError: 参数校验失败 + """ + plan = EditPlan.create( + template_id=template_id, + name=name, + config=config, + total_duration=total_duration, + ) + created = self._plan_repo.create(plan) + logger.info("创建剪辑计划: id=%s name=%s", created.id, created.name) + return created + + 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) + + 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, + 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 + + # 根据目标状态调用对应的状态机方法 + transition_map = { + EditPlanStatus.EDITING: plan.start_editing, + 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", + config: Optional[dict[str, Any]] = None, + ) -> EditPlanClip: + """创建片段 + + Raises: + ValueError: 计划不存在或参数校验失败 + """ + # 确保计划存在 + self.get_plan_or_raise(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, + 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, + config: Optional[dict[str, Any]] = None, + ) -> EditPlanClip: + """更新片段 + + Raises: + ValueError: 片段不存在 + """ + existing = self.get_clip_or_raise(clip_id) + + 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, + 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) + 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 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], + } + + 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 + + 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 + + return { + "plan": plan, + "clips": clips, + "generation_task_id": generation_task_id, + "generation_task_status": generation_task_status, + } + + 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, f"只有 editing 状态的计划可以触发渲染,当前状态: {plan.status}" + + # 检查是否有片段 + 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) + 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, + config=new_config, + created_at=plan.created_at, + updated_at=plan.updated_at, + ) + return self._plan_repo.update(updated) diff --git a/apps/api/app/services/edit_template_service.py b/apps/api/app/services/edit_template_service.py new file mode 100644 index 000000000..0ee6ea35a --- /dev/null +++ b/apps/api/app/services/edit_template_service.py @@ -0,0 +1,396 @@ +"""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 ( + 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) + + # ── 模板 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", + 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, + 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, + 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, + 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, + } diff --git a/apps/web/src/pages/editing-planner/EditingPlanner.tsx b/apps/web/src/pages/editing-planner/EditingPlanner.tsx index 6577f4c00..4364950e4 100644 --- a/apps/web/src/pages/editing-planner/EditingPlanner.tsx +++ b/apps/web/src/pages/editing-planner/EditingPlanner.tsx @@ -30,6 +30,7 @@ import { import MediaPanel from "./components/MediaPanel"; import TimelinePanel from "./components/TimelinePanel"; import ClipPropertiesPanel from "./components/ClipPropertiesPanel"; +import PreviewPlayer from "./components/PreviewPlayer"; import SaveModal from "./components/SaveModal"; import GenerateModal from "./components/GenerateModal"; @@ -535,18 +536,26 @@ const EditingPlanner: React.FC = () => { onBatchAddAssets={handleBatchAddAssets} /> - {/* 中间:时间线 */} - + {/* 中间:预览播放器 + 时间线 */} +
+ + +
{/* 右侧:片段属性 */} void; +} + +/* ── 片段颜色(与 TimelinePanel 保持一致) ── */ +const CLIP_COLORS = [ + "#4f46e5", + "#7c3aed", + "#2563eb", + "#0891b2", + "#059669", + "#d97706", +]; +const getClipColor = (idx: number) => CLIP_COLORS[idx % CLIP_COLORS.length]; + +/* ── 格式化时间 mm:ss ── */ +const formatTime = (seconds: number): string => { + const m = Math.floor(seconds / 60); + const s = Math.floor(seconds % 60); + return `${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`; +}; + +/* ── 根据播放进度计算当前片段索引 ── */ +const getClipIndexAtTime = ( + clips: EditPlanClip[], + time: number, +): number => { + let elapsed = 0; + for (let i = 0; i < clips.length; i++) { + elapsed += clips[i].duration; + if (time < elapsed) return i; + } + return Math.max(0, clips.length - 1); +}; + +/* ── 根据片段索引计算起始时间 ── */ +const getClipStartTime = ( + clips: EditPlanClip[], + clipIndex: number, +): number => { + let time = 0; + for (let i = 0; i < clipIndex; i++) { + time += clips[i].duration; + } + return time; +}; + +const PreviewPlayer: React.FC = ({ + clips, + totalDuration, + selectedClipId, + onSelectClip, +}) => { + const [isPlaying, setIsPlaying] = useState(false); + const [currentTime, setCurrentTime] = useState(0); + const [isDragging, setIsDragging] = useState(false); + const timerRef = useRef | null>(null); + const progressRef = useRef(null); + const wasPlayingRef = useRef(false); + + const currentClipIndex = clips.length > 0 ? getClipIndexAtTime(clips, currentTime) : -1; + const currentClip = currentClipIndex >= 0 ? clips[currentClipIndex] : null; + const clipStartTime = + currentClipIndex >= 0 ? getClipStartTime(clips, currentClipIndex) : 0; + const clipProgress = + currentClip && currentClip.duration > 0 + ? ((currentTime - clipStartTime) / currentClip.duration) * 100 + : 0; + const overallProgress = + totalDuration > 0 ? (currentTime / totalDuration) * 100 : 0; + + /* ── 播放控制 ── */ + const stopPlayback = useCallback(() => { + if (timerRef.current) { + clearInterval(timerRef.current); + timerRef.current = null; + } + setIsPlaying(false); + }, []); + + const startPlayback = useCallback(() => { + if (timerRef.current) clearInterval(timerRef.current); + timerRef.current = setInterval(() => { + setCurrentTime((prev) => { + const next = prev + 0.1; + if (next >= totalDuration) { + // 播放结束 + if (timerRef.current) clearInterval(timerRef.current); + timerRef.current = null; + setIsPlaying(false); + return 0; // 回到起点 + } + return next; + }); + }, 100); + setIsPlaying(true); + }, [totalDuration]); + + const togglePlay = useCallback(() => { + if (clips.length === 0) return; + if (isPlaying) { + stopPlayback(); + } else { + // 如果在末尾,从头开始 + if (currentTime >= totalDuration - 0.05) { + setCurrentTime(0); + } + startPlayback(); + } + }, [isPlaying, currentTime, totalDuration, clips.length, startPlayback, stopPlayback]); + + /* ── 停止/重置 ── */ + const handleStop = useCallback(() => { + stopPlayback(); + setCurrentTime(0); + }, [stopPlayback]); + + /* ── 上一段/下一段 ── */ + const handlePrevClip = useCallback(() => { + if (currentClipIndex <= 0) { + setCurrentTime(0); + } else { + setCurrentTime(getClipStartTime(clips, currentClipIndex - 1)); + } + }, [currentClipIndex, clips]); + + const handleNextClip = useCallback(() => { + if (currentClipIndex < clips.length - 1) { + setCurrentTime(getClipStartTime(clips, currentClipIndex + 1)); + } else { + setCurrentTime(totalDuration); + } + }, [currentClipIndex, clips, totalDuration]); + + /* ── 进度条拖拽 ── */ + const updateTimeFromMouse = useCallback( + (clientX: number) => { + if (!progressRef.current || totalDuration === 0) return; + const rect = progressRef.current.getBoundingClientRect(); + const ratio = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width)); + setCurrentTime(ratio * totalDuration); + }, + [totalDuration], + ); + + const handleProgressMouseDown = useCallback( + (e: React.MouseEvent) => { + e.preventDefault(); + setIsDragging(true); + wasPlayingRef.current = isPlaying; + if (isPlaying) stopPlayback(); + updateTimeFromMouse(e.clientX); + }, + [isPlaying, stopPlayback, updateTimeFromMouse], + ); + + useEffect(() => { + if (!isDragging) return; + + const handleMouseMove = (e: MouseEvent) => { + updateTimeFromMouse(e.clientX); + }; + + const handleMouseUp = () => { + setIsDragging(false); + if (wasPlayingRef.current) { + startPlayback(); + } + }; + + window.addEventListener("mousemove", handleMouseMove); + window.addEventListener("mouseup", handleMouseUp); + return () => { + window.removeEventListener("mousemove", handleMouseMove); + window.removeEventListener("mouseup", handleMouseUp); + }; + }, [isDragging, updateTimeFromMouse, startPlayback]); + + /* ── 点击时间线片段跳转 ── */ + const handleTimelineSegmentClick = useCallback( + (idx: number) => { + setCurrentTime(getClipStartTime(clips, idx)); + onSelectClip(clips[idx].id); + }, + [clips, onSelectClip], + ); + + /* ── 组件卸载时清理定时器 ── */ + useEffect(() => { + return () => { + if (timerRef.current) clearInterval(timerRef.current); + }; + }, []); + + /* ── 片段变化时同步播放位置(外部选中片段跳转) ── */ + useEffect(() => { + if (selectedClipId && !isPlaying) { + const idx = clips.findIndex((c) => c.id === selectedClipId); + if (idx >= 0) { + const startTime = getClipStartTime(clips, idx); + // 只在当前不在该片段范围内时跳转 + const endTime = startTime + clips[idx].duration; + if (currentTime < startTime || currentTime >= endTime) { + setCurrentTime(startTime); + } + } + } + }, [selectedClipId, clips, isPlaying]); // eslint-disable-line react-hooks/exhaustive-deps + + /* ── 空状态 ── */ + if (clips.length === 0) { + return ( +
+
+
+
🎬
+

添加片段后预览

+
+
+
+ ); + } + + return ( +
+ {/* ── 预览画面 ── */} +
+ {/* 背景渐变(模拟视频画面) */} +
+ {/* 片段类型图标 */} +
+ {currentClip ? MATERIAL_TYPE_ICONS[currentClip.material_type] || "📄" : "🎬"} +
+ + {/* 文案字幕 */} + {currentClip?.script_text && ( +
{currentClip.script_text}
+ )} + + {/* 片段序号角标 */} +
+ #{currentClipIndex + 1} +
+ + {/* 素材类型标签 */} + {currentClip && ( +
+ {MATERIAL_TYPE_ICONS[currentClip.material_type]}{" "} + {currentClip.material_type} +
+ )} +
+
+ + {/* ── 控制栏 ── */} +
+ {/* 左侧:时间 */} +
+ + {formatTime(currentTime)} + + / + + {formatTime(totalDuration)} + +
+ + {/* 中间:播放控制按钮 */} +
+ + + + +
+ + {/* 右侧:片段信息 */} +
+ {currentClip && ( + <> + + 片段 {currentClipIndex + 1}/{clips.length} + + + {currentClip.duration}s + + + )} +
+
+ + {/* ── 进度条(可拖拽) ── */} +
+
+ {/* 片段色块背景 */} + {clips.map((clip, idx) => ( +
+ ))} + {/* 已播放覆盖层 */} +
+
+ {/* 拖拽手柄 */} +
+
+ + {/* ── 迷你时间线(可点击跳转) ── */} +
+ {clips.map((clip, idx) => { + const isActive = idx === currentClipIndex; + const isSelected = clip.id === selectedClipId; + return ( +
handleTimelineSegmentClick(idx)} + title={`片段 ${idx + 1}: ${clip.duration}s`} + > + + {MATERIAL_TYPE_ICONS[clip.material_type]} + +
+ ); + })} + {/* 播放头指示器 */} +
+
+ + {/* ── 片段内进度 ── */} + {currentClip && ( +
+
+
+ )} +
+ ); +}; + +export default PreviewPlayer; diff --git a/apps/web/src/pages/editing-planner/components/TimelinePanel.tsx b/apps/web/src/pages/editing-planner/components/TimelinePanel.tsx index d8e2b486e..152240ca5 100644 --- a/apps/web/src/pages/editing-planner/components/TimelinePanel.tsx +++ b/apps/web/src/pages/editing-planner/components/TimelinePanel.tsx @@ -165,7 +165,7 @@ const TimelinePanel: React.FC = ({ const getClipColor = (idx: number) => clipColors[idx % clipColors.length]; return ( -
+
{/* 可视化时长条 */}
diff --git a/tests/unit/test_edit_plan_service.py b/tests/unit/test_edit_plan_service.py new file mode 100644 index 000000000..25ae58095 --- /dev/null +++ b/tests/unit/test_edit_plan_service.py @@ -0,0 +1,569 @@ +""" +EditPlanService 单元测试 + +覆盖(35+ 测试用例): +- 计划 CRUD:创建、查询、更新、删除 +- 状态机流转:合法流转、非法流转、幂等流转 +- 片段管理:创建、更新、删除、分配素材 +- 渲染生成流程:can_generate、mark_clips_ready、get_generation_status +- 异常处理:不存在、参数校验 +""" + +from __future__ import annotations + +import os +import sys +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, List, Optional +from unittest.mock import MagicMock + +os.environ.setdefault("JWT_SECRET_KEY", "unit-test-secret-key-for-testing") +os.environ.setdefault("DATABASE_URL", "sqlite:///test.db") + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "api")) + +from packages.domain.edit_plan import EditPlan, EditPlanStatus +from packages.domain.edit_plan_clip import EditPlanClip, EditPlanClipStatus + + +# --------------------------------------------------------------------------- +# Stub Repositories +# --------------------------------------------------------------------------- + + +class StubEditPlanRepository: + """内存中的 EditPlan 仓储 stub""" + + def __init__(self) -> None: + self._plans: dict[str, EditPlan] = {} + self._counter = 0 + + def _next_id(self) -> str: + self._counter += 1 + return f"plan-{self._counter:03d}" + + def list_all( + self, + *, + status: Optional[EditPlanStatus] = None, + skip: int = 0, + limit: int = 50, + ) -> List[EditPlan]: + items = list(self._plans.values()) + if status: + items = [p for p in items if p.status == status] + return items[skip : skip + limit] + + def list_by_template( + self, + template_id: str, + *, + status: Optional[EditPlanStatus] = None, + skip: int = 0, + limit: int = 50, + ) -> List[EditPlan]: + items = [p for p in self._plans.values() if p.template_id == template_id] + if status: + items = [p for p in items if p.status == status] + return items[skip : skip + limit] + + def get(self, plan_id: str) -> Optional[EditPlan]: + return self._plans.get(plan_id) + + def create(self, plan: EditPlan) -> EditPlan: + if not plan.id: + plan = EditPlan( + id=self._next_id(), + template_id=plan.template_id, + name=plan.name, + status=plan.status, + total_duration=plan.total_duration, + config=plan.config, + created_at=datetime.now(timezone.utc), + updated_at=datetime.now(timezone.utc), + ) + self._plans[plan.id] = plan + return plan + + def update(self, plan: EditPlan) -> EditPlan: + self._plans[plan.id] = plan + return plan + + def delete(self, plan_id: str) -> bool: + return self._plans.pop(plan_id, None) is not None + + def count( + self, + *, + status: Optional[EditPlanStatus] = None, + ) -> int: + items = list(self._plans.values()) + if status: + items = [p for p in items if p.status == status] + return len(items) + + +class StubEditPlanClipRepository: + """内存中的 EditPlanClip 仓储 stub""" + + def __init__(self) -> None: + self._clips: dict[str, EditPlanClip] = {} + self._counter = 0 + + def _next_id(self) -> str: + self._counter += 1 + return f"clip-{self._counter:03d}" + + def list_by_plan( + self, + plan_id: str, + *, + status: Optional[EditPlanClipStatus] = None, + skip: int = 0, + limit: int = 100, + ) -> List[EditPlanClip]: + items = [c for c in self._clips.values() if c.plan_id == plan_id] + if status: + items = [c for c in items if c.status == status] + items.sort(key=lambda c: c.order) + return items[skip : skip + limit] + + def get(self, clip_id: str) -> Optional[EditPlanClip]: + return self._clips.get(clip_id) + + def create(self, clip: EditPlanClip) -> EditPlanClip: + if not clip.id: + clip = EditPlanClip( + id=self._next_id(), + plan_id=clip.plan_id, + clip_type=clip.clip_type, + order=clip.order, + template_clip_config_id=clip.template_clip_config_id, + asset_id=clip.asset_id, + text_content=clip.text_content, + start_time=clip.start_time, + duration=clip.duration, + transition_effect=clip.transition_effect, + status=clip.status, + config=clip.config, + created_at=datetime.now(timezone.utc), + updated_at=datetime.now(timezone.utc), + ) + self._clips[clip.id] = clip + return clip + + def update(self, clip: EditPlanClip) -> EditPlanClip: + self._clips[clip.id] = clip + return clip + + def delete(self, clip_id: str) -> bool: + return self._clips.pop(clip_id, None) is not None + + def delete_by_plan(self, plan_id: str) -> int: + ids = [cid for cid, c in self._clips.items() if c.plan_id == plan_id] + for cid in ids: + del self._clips[cid] + return len(ids) + + def count( + self, + *, + plan_id: Optional[str] = None, + status: Optional[EditPlanClipStatus] = None, + ) -> int: + items = list(self._clips.values()) + if plan_id: + items = [c for c in items if c.plan_id == plan_id] + if status: + items = [c for c in items if c.status == status] + return len(items) + + +class StubGenerationTaskRepository: + """内存中的 GenerationTask 仓储 stub""" + + def __init__(self) -> None: + self._tasks: dict[str, Any] = {} + + def get(self, task_id: str) -> Optional[Any]: + return self._tasks.get(task_id) + + def create(self, task: Any) -> Any: + self._tasks[task.id] = task + return task + + +# --------------------------------------------------------------------------- +# Service factory +# --------------------------------------------------------------------------- + + +def _make_service(): + """创建使用 stub 仓储的 EditPlanService""" + from app.services.edit_plan_service import EditPlanService + + db = MagicMock() + svc = EditPlanService(db) + svc._plan_repo = StubEditPlanRepository() + svc._clip_repo = StubEditPlanClipRepository() + svc._generation_task_repo = StubGenerationTaskRepository() + return svc + + +# =========================================================================== +# 计划 CRUD 测试 +# =========================================================================== + + +class TestEditPlanServiceCRUD: + """计划 CRUD 测试""" + + def test_create_plan_success(self): + svc = _make_service() + plan = svc.create_plan(template_id="tpl-001", name="测试计划") + assert plan.name == "测试计划" + assert plan.template_id == "tpl-001" + assert plan.status == EditPlanStatus.DRAFT + assert plan.id + + def test_get_plan(self): + svc = _make_service() + created = svc.create_plan("tpl-001", "查询测试") + fetched = svc.get_plan(created.id) + assert fetched is not None + assert fetched.id == created.id + + def test_get_plan_returns_none(self): + svc = _make_service() + assert svc.get_plan("nonexistent") is None + + def test_get_plan_or_raise(self): + svc = _make_service() + created = svc.create_plan("tpl-001", "查询测试") + fetched = svc.get_plan_or_raise(created.id) + assert fetched.id == created.id + + def test_get_plan_or_raise_not_found(self): + svc = _make_service() + with pytest.raises(ValueError, match="剪辑计划不存在"): + svc.get_plan_or_raise("nonexistent") + + def test_list_plans(self): + svc = _make_service() + svc.create_plan("tpl-001", "计划1") + svc.create_plan("tpl-001", "计划2") + result = svc.list_plans() + assert len(result) == 2 + + def test_list_plans_by_template(self): + svc = _make_service() + svc.create_plan("tpl-001", "计划1") + svc.create_plan("tpl-002", "计划2") + result = svc.list_plans(template_id="tpl-001") + assert len(result) == 1 + assert result[0].name == "计划1" + + def test_list_plans_by_status(self): + svc = _make_service() + p1 = svc.create_plan("tpl-001", "计划1") + svc.create_plan("tpl-001", "计划2") + svc.transition_status(p1.id, EditPlanStatus.EDITING) + result = svc.list_plans(status=EditPlanStatus.EDITING) + assert len(result) == 1 + + def test_count_plans(self): + svc = _make_service() + svc.create_plan("tpl-001", "计划1") + svc.create_plan("tpl-001", "计划2") + assert svc.count_plans() == 2 + + def test_update_plan_name(self): + svc = _make_service() + p = svc.create_plan("tpl-001", "原名") + updated = svc.update_plan(p.id, name="新名") + assert updated.name == "新名" + + def test_update_plan_not_found_raises(self): + svc = _make_service() + with pytest.raises(ValueError, match="剪辑计划不存在"): + svc.update_plan("nonexistent", name="新名") + + def test_delete_plan(self): + svc = _make_service() + p = svc.create_plan("tpl-001", "要删除") + assert svc.delete_plan(p.id) is True + assert svc.get_plan(p.id) is None + + def test_delete_plan_not_found(self): + svc = _make_service() + assert svc.delete_plan("nonexistent") is False + + def test_delete_plan_also_deletes_clips(self): + svc = _make_service() + p = svc.create_plan("tpl-001", "带片段") + svc.create_clip(p.id, "intro", 0) + svc.create_clip(p.id, "main", 1) + assert svc.count_clips(p.id) == 2 + svc.delete_plan(p.id) + # 片段应被一并删除 + assert svc.count_clips(p.id) == 0 + + +# =========================================================================== +# 状态机流转测试 +# =========================================================================== + + +class TestStatusTransitions: + """状态机流转测试""" + + def test_transition_draft_to_editing(self): + svc = _make_service() + p = svc.create_plan("tpl-001", "测试") + result = svc.transition_status(p.id, EditPlanStatus.EDITING) + assert result.status == EditPlanStatus.EDITING + + def test_transition_editing_to_rendering(self): + svc = _make_service() + p = svc.create_plan("tpl-001", "测试") + svc.transition_status(p.id, EditPlanStatus.EDITING) + result = svc.transition_status(p.id, EditPlanStatus.RENDERING) + assert result.status == EditPlanStatus.RENDERING + + def test_transition_rendering_to_completed(self): + svc = _make_service() + p = svc.create_plan("tpl-001", "测试") + svc.transition_status(p.id, EditPlanStatus.EDITING) + svc.transition_status(p.id, EditPlanStatus.RENDERING) + result = svc.transition_status(p.id, EditPlanStatus.COMPLETED) + assert result.status == EditPlanStatus.COMPLETED + + def test_transition_rendering_to_failed(self): + svc = _make_service() + p = svc.create_plan("tpl-001", "测试") + svc.transition_status(p.id, EditPlanStatus.EDITING) + svc.transition_status(p.id, EditPlanStatus.RENDERING) + result = svc.transition_status(p.id, EditPlanStatus.FAILED) + assert result.status == EditPlanStatus.FAILED + + def test_transition_failed_to_draft(self): + svc = _make_service() + p = svc.create_plan("tpl-001", "测试") + svc.transition_status(p.id, EditPlanStatus.EDITING) + svc.transition_status(p.id, EditPlanStatus.RENDERING) + svc.transition_status(p.id, EditPlanStatus.FAILED) + result = svc.transition_status(p.id, EditPlanStatus.DRAFT) + assert result.status == EditPlanStatus.DRAFT + + def test_transition_idempotent(self): + svc = _make_service() + p = svc.create_plan("tpl-001", "测试") + result = svc.transition_status(p.id, EditPlanStatus.DRAFT) + assert result.status == EditPlanStatus.DRAFT + + def test_transition_illegal_raises(self): + svc = _make_service() + p = svc.create_plan("tpl-001", "测试") + # draft → completed 是非法的 + with pytest.raises(ValueError): + svc.transition_status(p.id, EditPlanStatus.COMPLETED) + + def test_transition_not_found_raises(self): + svc = _make_service() + with pytest.raises(ValueError, match="剪辑计划不存在"): + svc.transition_status("nonexistent", EditPlanStatus.EDITING) + + +# =========================================================================== +# 片段管理测试 +# =========================================================================== + + +class TestClipManagement: + """片段管理测试""" + + def test_create_clip(self): + svc = _make_service() + p = svc.create_plan("tpl-001", "测试") + clip = svc.create_clip(p.id, "intro", 0) + assert clip.plan_id == p.id + assert clip.clip_type == "intro" + assert clip.order == 0 + assert clip.status == EditPlanClipStatus.PENDING + + def test_create_clip_plan_not_found_raises(self): + svc = _make_service() + with pytest.raises(ValueError, match="剪辑计划不存在"): + svc.create_clip("nonexistent", "intro", 0) + + def test_list_clips(self): + svc = _make_service() + p = svc.create_plan("tpl-001", "测试") + svc.create_clip(p.id, "intro", 0) + svc.create_clip(p.id, "main", 1) + svc.create_clip(p.id, "outro", 2) + result = svc.list_clips(p.id) + assert len(result) == 3 + + def test_list_clips_by_status(self): + svc = _make_service() + p = svc.create_plan("tpl-001", "测试") + c1 = svc.create_clip(p.id, "intro", 0) + svc.create_clip(p.id, "main", 1) + # assign_asset 只设置 asset_id,需要额外 mark_ready 才变 ready + svc.assign_asset(c1.id, "asset-001") + # 手动 mark_ready + clip_obj = svc._clip_repo.get(c1.id) + clip_obj.mark_ready() + svc._clip_repo.update(clip_obj) + result = svc.list_clips(p.id, status=EditPlanClipStatus.READY) + assert len(result) == 1 + + def test_count_clips(self): + svc = _make_service() + p = svc.create_plan("tpl-001", "测试") + svc.create_clip(p.id, "intro", 0) + svc.create_clip(p.id, "main", 1) + assert svc.count_clips(p.id) == 2 + + def test_get_clip(self): + svc = _make_service() + p = svc.create_plan("tpl-001", "测试") + clip = svc.create_clip(p.id, "intro", 0) + fetched = svc.get_clip(clip.id) + assert fetched is not None + assert fetched.id == clip.id + + def test_get_clip_or_raise_not_found(self): + svc = _make_service() + with pytest.raises(ValueError, match="片段不存在"): + svc.get_clip_or_raise("nonexistent") + + def test_update_clip(self): + svc = _make_service() + p = svc.create_plan("tpl-001", "测试") + clip = svc.create_clip(p.id, "intro", 0, duration=3.0) + updated = svc.update_clip(clip.id, duration=5.0) + assert updated.duration == 5.0 + + def test_update_clip_not_found_raises(self): + svc = _make_service() + with pytest.raises(ValueError, match="片段不存在"): + svc.update_clip("nonexistent", duration=5.0) + + def test_assign_asset(self): + svc = _make_service() + p = svc.create_plan("tpl-001", "测试") + clip = svc.create_clip(p.id, "intro", 0) + result = svc.assign_asset(clip.id, "asset-001") + assert result.asset_id == "asset-001" + # assign_asset 只设置 asset_id,不改变状态(状态需 mark_ready 流转) + assert result.status == EditPlanClipStatus.PENDING + + def test_assign_asset_empty_raises(self): + svc = _make_service() + p = svc.create_plan("tpl-001", "测试") + clip = svc.create_clip(p.id, "intro", 0) + with pytest.raises(ValueError): + svc.assign_asset(clip.id, "") + + def test_delete_clip(self): + svc = _make_service() + p = svc.create_plan("tpl-001", "测试") + clip = svc.create_clip(p.id, "intro", 0) + assert svc.delete_clip(clip.id) is True + assert svc.get_clip(clip.id) is None + + def test_delete_all_clips(self): + svc = _make_service() + p = svc.create_plan("tpl-001", "测试") + svc.create_clip(p.id, "intro", 0) + svc.create_clip(p.id, "main", 1) + count = svc.delete_all_clips(p.id) + assert count == 2 + assert svc.count_clips(p.id) == 0 + + +# =========================================================================== +# 渲染生成流程测试 +# =========================================================================== + + +class TestGenerationWorkflow: + """渲染生成流程测试""" + + def test_can_generate_editing_with_clips(self): + svc = _make_service() + p = svc.create_plan("tpl-001", "测试") + svc.transition_status(p.id, EditPlanStatus.EDITING) + svc.create_clip(p.id, "intro", 0) + can, reason = svc.can_generate(p.id) + assert can is True + assert reason == "" + + def test_can_generate_draft_fails(self): + svc = _make_service() + p = svc.create_plan("tpl-001", "测试") + svc.create_clip(p.id, "intro", 0) + can, reason = svc.can_generate(p.id) + assert can is False + assert "editing" in reason + + def test_can_generate_no_clips_fails(self): + svc = _make_service() + p = svc.create_plan("tpl-001", "测试") + svc.transition_status(p.id, EditPlanStatus.EDITING) + can, reason = svc.can_generate(p.id) + assert can is False + assert "没有片段" in reason + + def test_mark_clips_ready(self): + svc = _make_service() + p = svc.create_plan("tpl-001", "测试") + svc.create_clip(p.id, "intro", 0) + svc.create_clip(p.id, "main", 1) + count = svc.mark_clips_ready(p.id) + assert count == 2 + # 验证所有片段都是 ready 状态 + clips = svc.list_clips(p.id) + for c in clips: + assert c.status == EditPlanClipStatus.READY + + def test_get_plan_with_clips(self): + svc = _make_service() + p = svc.create_plan("tpl-001", "测试") + svc.create_clip(p.id, "intro", 0) + svc.create_clip(p.id, "main", 1) + result = svc.get_plan_with_clips(p.id) + assert result["plan"].id == p.id + assert len(result["clips"]) == 2 + + def test_get_plan_with_clips_not_found(self): + svc = _make_service() + with pytest.raises(ValueError, match="剪辑计划不存在"): + svc.get_plan_with_clips("nonexistent") + + def test_get_generation_status(self): + svc = _make_service() + p = svc.create_plan("tpl-001", "测试") + svc.create_clip(p.id, "intro", 0) + result = svc.get_generation_status(p.id) + assert result["plan"].id == p.id + assert len(result["clips"]) == 1 + assert result["generation_task_id"] is None + + def test_update_plan_config(self): + svc = _make_service() + p = svc.create_plan("tpl-001", "测试", config={"key1": "val1"}) + updated = svc.update_plan_config(p.id, {"key2": "val2"}) + assert updated.config["key1"] == "val1" + assert updated.config["key2"] == "val2" + + def test_update_plan_config_overwrites(self): + svc = _make_service() + p = svc.create_plan("tpl-001", "测试", config={"key1": "val1"}) + updated = svc.update_plan_config(p.id, {"key1": "new_val"}) + assert updated.config["key1"] == "new_val" diff --git a/tests/unit/test_edit_template_service.py b/tests/unit/test_edit_template_service.py new file mode 100644 index 000000000..99692083f --- /dev/null +++ b/tests/unit/test_edit_template_service.py @@ -0,0 +1,466 @@ +""" +EditTemplateService 单元测试 + +覆盖(30+ 测试用例): +- 模板 CRUD:创建、查询、更新、软删除 +- 名称去重校验 +- 片段配置 CRUD +- 片段配置重排序 +- 复合查询 get_template_with_configs +- 异常处理:不存在、参数校验 +""" + +from __future__ import annotations + +import os +import sys +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, List, Optional +from unittest.mock import MagicMock, patch + +os.environ.setdefault("JWT_SECRET_KEY", "unit-test-secret-key-for-testing") +os.environ.setdefault("DATABASE_URL", "sqlite:///test.db") + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "api")) + +from packages.domain.edit_template import EditTemplate, EditTemplateStatus +from packages.domain.template_clip_config import ClipType, TemplateClipConfig, TransitionEffect + + +# --------------------------------------------------------------------------- +# Stub Repositories +# --------------------------------------------------------------------------- + + +class StubEditTemplateRepository: + """内存中的 EditTemplate 仓储 stub""" + + def __init__(self) -> None: + self._templates: dict[str, EditTemplate] = {} + self._counter = 0 + + def _next_id(self) -> str: + self._counter += 1 + return f"tpl-{self._counter:03d}" + + def list_all( + self, + *, + template_type: Optional[str] = None, + status: Optional[EditTemplateStatus] = None, + skip: int = 0, + limit: int = 50, + ) -> List[EditTemplate]: + items = list(self._templates.values()) + if template_type: + items = [t for t in items if t.template_type == template_type] + if status: + items = [t for t in items if t.status == status] + return items[skip : skip + limit] + + def list_active( + self, + *, + template_type: Optional[str] = None, + skip: int = 0, + limit: int = 50, + ) -> List[EditTemplate]: + items = [t for t in self._templates.values() if t.status == EditTemplateStatus.ACTIVE] + if template_type: + items = [t for t in items if t.template_type == template_type] + return items[skip : skip + limit] + + def get(self, template_id: str) -> Optional[EditTemplate]: + return self._templates.get(template_id) + + def create(self, template: EditTemplate) -> EditTemplate: + if not template.id: + template = EditTemplate( + id=self._next_id(), + name=template.name, + description=template.description, + template_type=template.template_type, + config=template.config, + preview_url=template.preview_url, + sort_weight=template.sort_weight, + status=template.status, + created_at=datetime.now(timezone.utc), + updated_at=datetime.now(timezone.utc), + ) + self._templates[template.id] = template + return template + + def update(self, template: EditTemplate) -> EditTemplate: + self._templates[template.id] = template + return template + + def delete(self, template_id: str) -> bool: + return self._templates.pop(template_id, None) is not None + + def count( + self, + *, + template_type: Optional[str] = None, + status: Optional[EditTemplateStatus] = None, + ) -> int: + items = list(self._templates.values()) + if template_type: + items = [t for t in items if t.template_type == template_type] + if status: + items = [t for t in items if t.status == status] + return len(items) + + +class StubTemplateClipConfigRepository: + """内存中的 TemplateClipConfig 仓储 stub""" + + def __init__(self) -> None: + self._configs: dict[str, TemplateClipConfig] = {} + self._counter = 0 + + def _next_id(self) -> str: + self._counter += 1 + return f"cfg-{self._counter:03d}" + + def list_by_template( + self, + template_id: str, + *, + clip_type: Optional[ClipType] = None, + skip: int = 0, + limit: int = 100, + ) -> List[TemplateClipConfig]: + items = [c for c in self._configs.values() if c.template_id == template_id] + if clip_type: + items = [c for c in items if c.clip_type == clip_type] + items.sort(key=lambda c: c.order) + return items[skip : skip + limit] + + def get(self, config_id: str) -> Optional[TemplateClipConfig]: + return self._configs.get(config_id) + + def create(self, config: TemplateClipConfig) -> TemplateClipConfig: + if not config.id: + config = TemplateClipConfig( + id=self._next_id(), + template_id=config.template_id, + clip_type=config.clip_type, + order=config.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=datetime.now(timezone.utc), + updated_at=datetime.now(timezone.utc), + ) + self._configs[config.id] = config + return config + + def update(self, config: TemplateClipConfig) -> TemplateClipConfig: + self._configs[config.id] = config + return config + + def delete(self, config_id: str) -> bool: + return self._configs.pop(config_id, None) is not None + + def delete_by_template(self, template_id: str) -> int: + ids = [cid for cid, c in self._configs.items() if c.template_id == template_id] + for cid in ids: + del self._configs[cid] + return len(ids) + + def count(self, template_id: str) -> int: + return len([c for c in self._configs.values() if c.template_id == template_id]) + + +# --------------------------------------------------------------------------- +# Service under test (inject stub repos) +# --------------------------------------------------------------------------- + + +def _make_service(): + """创建使用 stub 仓储的 EditTemplateService""" + from app.services.edit_template_service import EditTemplateService + + db = MagicMock() + svc = EditTemplateService(db) + svc._template_repo = StubEditTemplateRepository() + svc._clip_config_repo = StubTemplateClipConfigRepository() + return svc + + +# =========================================================================== +# 模板 CRUD 测试 +# =========================================================================== + + +class TestEditTemplateServiceCRUD: + """模板 CRUD 测试""" + + def test_create_template_success(self): + svc = _make_service() + t = svc.create_template(name="测试模板", description="描述") + assert t.name == "测试模板" + assert t.description == "描述" + assert t.status == EditTemplateStatus.ACTIVE + assert t.id + + def test_create_template_strips_name(self): + svc = _make_service() + t = svc.create_template(name=" 测试 ") + assert t.name == "测试" + + def test_create_template_empty_name_raises(self): + svc = _make_service() + with pytest.raises(ValueError, match="模板名称不能为空"): + svc.create_template(name=" ") + + def test_create_template_duplicate_name_raises(self): + svc = _make_service() + svc.create_template(name="重复名称") + with pytest.raises(ValueError, match="模板名称已存在"): + svc.create_template(name="重复名称") + + def test_create_template_inactive_name_can_reuse(self): + svc = _make_service() + t = svc.create_template(name="可复用") + svc.deactivate_template(t.id) + # inactive 的名称可以复用 + t2 = svc.create_template(name="可复用") + assert t2.name == "可复用" + assert t2.id != t.id + + def test_get_template(self): + svc = _make_service() + created = svc.create_template(name="查询测试") + fetched = svc.get_template(created.id) + assert fetched is not None + assert fetched.id == created.id + + def test_get_template_returns_none(self): + svc = _make_service() + assert svc.get_template("nonexistent") is None + + def test_get_template_or_raise(self): + svc = _make_service() + created = svc.create_template(name="查询测试") + fetched = svc.get_template_or_raise(created.id) + assert fetched.id == created.id + + def test_get_template_or_raise_not_found(self): + svc = _make_service() + with pytest.raises(ValueError, match="模板不存在"): + svc.get_template_or_raise("nonexistent") + + def test_list_templates(self): + svc = _make_service() + svc.create_template(name="模板1") + svc.create_template(name="模板2") + result = svc.list_templates() + assert len(result) == 2 + + def test_list_templates_with_type_filter(self): + svc = _make_service() + svc.create_template(name="默认", template_type="default") + svc.create_template(name="Vlog", template_type="vlog") + result = svc.list_templates(template_type="vlog") + assert len(result) == 1 + assert result[0].name == "Vlog" + + def test_list_templates_active_only(self): + svc = _make_service() + t1 = svc.create_template(name="活跃") + t2 = svc.create_template(name="停用") + svc.deactivate_template(t2.id) + result = svc.list_templates(active_only=True) + assert len(result) == 1 + assert result[0].name == "活跃" + + def test_count_templates(self): + svc = _make_service() + svc.create_template(name="模板1") + svc.create_template(name="模板2") + assert svc.count_templates() == 2 + + def test_update_template_name(self): + svc = _make_service() + t = svc.create_template(name="原名") + updated = svc.update_template(t.id, name="新名") + assert updated.name == "新名" + + def test_update_template_duplicate_name_raises(self): + svc = _make_service() + svc.create_template(name="已存在") + t2 = svc.create_template(name="另一个") + with pytest.raises(ValueError, match="模板名称已存在"): + svc.update_template(t2.id, name="已存在") + + def test_update_template_not_found_raises(self): + svc = _make_service() + with pytest.raises(ValueError, match="模板不存在"): + svc.update_template("nonexistent", name="新名") + + def test_deactivate_template(self): + svc = _make_service() + t = svc.create_template(name="要停用的") + result = svc.deactivate_template(t.id) + assert result.status == EditTemplateStatus.INACTIVE + + def test_deactivate_template_not_found_raises(self): + svc = _make_service() + with pytest.raises(ValueError, match="模板不存在"): + svc.deactivate_template("nonexistent") + + +# =========================================================================== +# 片段配置管理测试 +# =========================================================================== + + +class TestClipConfigManagement: + """片段配置管理测试""" + + def test_create_clip_config(self): + svc = _make_service() + t = svc.create_template(name="模板") + cfg = svc.create_clip_config( + template_id=t.id, + clip_type=ClipType.INTRO, + order=0, + min_duration=1.0, + max_duration=5.0, + ) + assert cfg.template_id == t.id + assert cfg.clip_type == ClipType.INTRO + assert cfg.order == 0 + assert cfg.min_duration == 1.0 + assert cfg.max_duration == 5.0 + + def test_create_clip_config_with_string_clip_type(self): + svc = _make_service() + t = svc.create_template(name="模板") + cfg = svc.create_clip_config( + template_id=t.id, + clip_type="main", + order=1, + ) + assert cfg.clip_type == ClipType.MAIN + + def test_list_clip_configs(self): + svc = _make_service() + t = svc.create_template(name="模板") + svc.create_clip_config(t.id, ClipType.INTRO, 0) + svc.create_clip_config(t.id, ClipType.MAIN, 1) + svc.create_clip_config(t.id, ClipType.OUTRO, 2) + result = svc.list_clip_configs(t.id) + assert len(result) == 3 + # 按 order 排序 + assert result[0].order == 0 + assert result[1].order == 1 + assert result[2].order == 2 + + def test_list_clip_configs_by_type(self): + svc = _make_service() + t = svc.create_template(name="模板") + svc.create_clip_config(t.id, ClipType.INTRO, 0) + svc.create_clip_config(t.id, ClipType.MAIN, 1) + result = svc.list_clip_configs(t.id, clip_type=ClipType.MAIN) + assert len(result) == 1 + assert result[0].clip_type == ClipType.MAIN + + def test_get_clip_config(self): + svc = _make_service() + t = svc.create_template(name="模板") + cfg = svc.create_clip_config(t.id, ClipType.INTRO, 0) + fetched = svc.get_clip_config(cfg.id) + assert fetched is not None + assert fetched.id == cfg.id + + def test_get_clip_config_not_found(self): + svc = _make_service() + assert svc.get_clip_config("nonexistent") is None + + def test_get_clip_config_or_raise(self): + svc = _make_service() + with pytest.raises(ValueError, match="片段配置不存在"): + svc.get_clip_config_or_raise("nonexistent") + + def test_update_clip_config(self): + svc = _make_service() + t = svc.create_template(name="模板") + cfg = svc.create_clip_config(t.id, ClipType.INTRO, 0, min_duration=1.0) + updated = svc.update_clip_config(cfg.id, min_duration=2.0, max_duration=10.0) + assert updated.min_duration == 2.0 + assert updated.max_duration == 10.0 + + def test_delete_clip_config(self): + svc = _make_service() + t = svc.create_template(name="模板") + cfg = svc.create_clip_config(t.id, ClipType.INTRO, 0) + assert svc.delete_clip_config(cfg.id) is True + assert svc.get_clip_config(cfg.id) is None + + def test_delete_clip_config_not_found(self): + svc = _make_service() + assert svc.delete_clip_config("nonexistent") is False + + def test_reorder_clip_configs(self): + svc = _make_service() + t = svc.create_template(name="模板") + c1 = svc.create_clip_config(t.id, ClipType.INTRO, 0) + c2 = svc.create_clip_config(t.id, ClipType.MAIN, 1) + c3 = svc.create_clip_config(t.id, ClipType.OUTRO, 2) + + # 反转顺序 + reordered = svc.reorder_clip_configs(t.id, [c3.id, c2.id, c1.id]) + assert len(reordered) == 3 + assert reordered[0].id == c3.id + assert reordered[0].order == 0 + assert reordered[1].id == c2.id + assert reordered[1].order == 1 + assert reordered[2].id == c1.id + assert reordered[2].order == 2 + + def test_reorder_clip_configs_mismatch_raises(self): + svc = _make_service() + t = svc.create_template(name="模板") + c1 = svc.create_clip_config(t.id, ClipType.INTRO, 0) + svc.create_clip_config(t.id, ClipType.MAIN, 1) + + with pytest.raises(ValueError, match="配置 ID 列表与模板下的配置不匹配"): + svc.reorder_clip_configs(t.id, [c1.id]) # 缺少一个 + + +# =========================================================================== +# 复合查询测试 +# =========================================================================== + + +class TestCompositeQueries: + """复合查询测试""" + + def test_get_template_with_configs(self): + svc = _make_service() + t = svc.create_template(name="模板") + svc.create_clip_config(t.id, ClipType.INTRO, 0) + svc.create_clip_config(t.id, ClipType.MAIN, 1) + + result = svc.get_template_with_configs(t.id) + assert result["template"].id == t.id + assert len(result["clip_configs"]) == 2 + + def test_get_template_with_configs_not_found(self): + svc = _make_service() + with pytest.raises(ValueError, match="模板不存在"): + svc.get_template_with_configs("nonexistent") + + def test_get_template_with_configs_empty(self): + svc = _make_service() + t = svc.create_template(name="空模板") + result = svc.get_template_with_configs(t.id) + assert len(result["clip_configs"]) == 0