feat(phase8): 任务 2.06 — EditTemplateService + EditPlanService 服务层 #155

Merged
xiaoxia merged 1 commits from feature/phase8-task206-service-layer into develop 2026-07-01 19:32:17 +08:00
11 changed files with 2880 additions and 204 deletions
+69 -142
View File
@@ -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,
)
+35 -49
View File
@@ -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)
+9
View File
@@ -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",
]
+473
View File
@@ -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)
@@ -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,
}
@@ -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}
/>
{/* 中间:时间线 */}
<TimelinePanel
clips={clips}
selectedClipId={selectedClipId}
onSelectClip={handleSelectClip}
onRemoveClip={handleRemoveClip}
onReorderClips={handleReorderClips}
onAssetDrop={handleAssetDrop}
onBatchAssetDrop={handleBatchAddAssets}
onAddClip={handleAddClip}
totalDuration={totalDuration}
/>
{/* 中间:预览播放器 + 时间线 */}
<div className="ep-center">
<PreviewPlayer
clips={clips}
totalDuration={totalDuration}
selectedClipId={selectedClipId}
onSelectClip={handleSelectClip}
/>
<TimelinePanel
clips={clips}
selectedClipId={selectedClipId}
onSelectClip={handleSelectClip}
onRemoveClip={handleRemoveClip}
onReorderClips={handleReorderClips}
onAssetDrop={handleAssetDrop}
onBatchAssetDrop={handleBatchAddAssets}
onAddClip={handleAddClip}
totalDuration={totalDuration}
/>
</div>
{/* 右侧:片段属性 */}
<ClipPropertiesPanel
@@ -0,0 +1,426 @@
/**
* 预览播放器样式 — V21 设计系统
* 任务 2.16
*/
/* ============================================================
预览播放器容器
============================================================ */
.ep-preview {
flex-shrink: 0;
display: flex;
flex-direction: column;
border-bottom: 1px solid var(--border-color);
background: var(--bg-primary);
}
/* ── 预览画面 ── */
.ep-preview-screen {
position: relative;
width: 100%;
aspect-ratio: 16 / 9;
max-height: 280px;
overflow: hidden;
background: #0f0f14;
border-radius: 0;
}
.ep-preview-visual {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--space-md);
transition: background 0.4s ease;
}
/* 片段类型大图标 */
.ep-preview-type-icon {
font-size: 56px;
opacity: 0.7;
filter: drop-shadow(0 2px 8px rgba(0, 0, 0, 0.3));
animation: ep-preview-float 3s ease-in-out infinite;
}
@keyframes ep-preview-float {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-6px); }
}
/* 文案字幕 */
.ep-preview-subtitle {
max-width: 80%;
padding: 8px 20px;
background: rgba(0, 0, 0, 0.55);
backdrop-filter: blur(8px);
border-radius: var(--radius-md);
color: #fff;
font-size: 14px;
line-height: 1.6;
text-align: center;
letter-spacing: 0.02em;
}
/* 片段序号角标 */
.ep-preview-clip-badge {
position: absolute;
top: 12px;
left: 12px;
padding: 3px 10px;
border-radius: var(--radius-sm);
color: #fff;
font-size: 12px;
font-weight: 700;
letter-spacing: 0.04em;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.25);
}
/* 素材类型标签 */
.ep-preview-material-tag {
position: absolute;
top: 12px;
right: 12px;
padding: 3px 10px;
border-radius: var(--radius-sm);
background: rgba(0, 0, 0, 0.45);
backdrop-filter: blur(6px);
color: rgba(255, 255, 255, 0.85);
font-size: 11px;
text-transform: capitalize;
}
/* 空状态 */
.ep-preview-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
color: rgba(255, 255, 255, 0.4);
}
.ep-preview-empty-icon {
font-size: 48px;
margin-bottom: var(--space-sm);
opacity: 0.5;
}
.ep-preview-empty p {
margin: 0;
font-size: var(--font-size-sm);
}
/* ============================================================
控制栏
============================================================ */
.ep-preview-controls {
display: flex;
align-items: center;
gap: var(--space-md);
padding: 8px var(--space-lg);
border-top: 1px solid rgba(255, 255, 255, 0.06);
background: #16161e;
}
/* 时间显示 */
.ep-preview-time {
display: flex;
align-items: center;
gap: 4px;
font-size: 12px;
font-variant-numeric: tabular-nums;
min-width: 90px;
}
.ep-preview-time-current {
color: #fff;
font-weight: 600;
}
.ep-preview-time-sep {
color: rgba(255, 255, 255, 0.3);
}
.ep-preview-time-total {
color: rgba(255, 255, 255, 0.5);
}
/* 播放按钮组 */
.ep-preview-buttons {
display: flex;
align-items: center;
gap: 4px;
flex: 1;
justify-content: center;
}
.ep-preview-btn {
width: 32px;
height: 32px;
border: none;
background: transparent;
color: rgba(255, 255, 255, 0.7);
font-size: 14px;
cursor: pointer;
border-radius: var(--radius-sm);
display: flex;
align-items: center;
justify-content: center;
transition: var(--transition-all);
}
.ep-preview-btn:hover {
background: rgba(255, 255, 255, 0.1);
color: #fff;
}
.ep-preview-btn-play {
width: 40px;
height: 40px;
background: var(--primary-color);
color: #fff;
font-size: 16px;
border-radius: 50%;
box-shadow: 0 2px 8px rgba(79, 70, 229, 0.4);
}
.ep-preview-btn-play:hover {
background: var(--primary-hover);
transform: scale(1.05);
box-shadow: 0 4px 12px rgba(79, 70, 229, 0.5);
}
/* 片段信息 */
.ep-preview-clip-info {
display: flex;
align-items: center;
gap: var(--space-sm);
font-size: 12px;
min-width: 90px;
justify-content: flex-end;
}
.ep-preview-clip-idx {
color: rgba(255, 255, 255, 0.6);
}
.ep-preview-clip-dur {
color: var(--primary-color);
font-weight: 600;
background: rgba(79, 70, 229, 0.15);
padding: 1px 8px;
border-radius: 999px;
}
/* ============================================================
进度条(可拖拽)
============================================================ */
.ep-preview-progress {
position: relative;
height: 20px;
padding: 7px var(--space-lg);
cursor: pointer;
user-select: none;
background: #16161e;
}
.ep-preview-progress-track {
position: relative;
height: 6px;
display: flex;
background: rgba(255, 255, 255, 0.08);
border-radius: 3px;
overflow: hidden;
}
.ep-preview-progress-segment {
height: 100%;
transition: opacity 0.2s ease;
}
.ep-preview-progress-fill {
position: absolute;
top: 0;
left: 0;
height: 100%;
background: rgba(255, 255, 255, 0.25);
border-radius: 3px;
pointer-events: none;
}
.ep-preview-progress-handle {
position: absolute;
top: 50%;
width: 14px;
height: 14px;
background: #fff;
border-radius: 50%;
transform: translate(-50%, -50%);
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.4);
transition: transform 0.1s ease;
pointer-events: none;
z-index: 2;
}
.ep-preview-progress:hover .ep-preview-progress-handle {
transform: translate(-50%, -50%) scale(1.2);
}
/* ============================================================
迷你时间线
============================================================ */
.ep-preview-timeline {
position: relative;
display: flex;
height: 28px;
gap: 2px;
padding: 0 var(--space-lg);
background: #12121a;
overflow: hidden;
}
.ep-preview-timeline-seg {
height: 100%;
border-radius: 3px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: var(--transition-all);
min-width: 8px;
position: relative;
}
.ep-preview-timeline-seg:hover {
opacity: 0.85;
transform: scaleY(1.08);
}
.ep-preview-timeline-seg.active {
opacity: 1;
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.3);
}
.ep-preview-timeline-seg.selected {
box-shadow: 0 0 0 2px var(--primary-color);
}
.ep-preview-timeline-seg-label {
font-size: 10px;
opacity: 0.8;
pointer-events: none;
}
/* 播放头 */
.ep-preview-playhead {
position: absolute;
top: 0;
width: 2px;
height: 100%;
background: #fff;
transform: translateX(-50%);
pointer-events: none;
z-index: 3;
box-shadow: 0 0 4px rgba(255, 255, 255, 0.5);
}
/* ============================================================
片段内进度条
============================================================ */
.ep-preview-clip-progress {
height: 3px;
background: rgba(255, 255, 255, 0.06);
}
.ep-preview-clip-progress-fill {
height: 100%;
transition: width 0.1s linear;
border-radius: 0 2px 2px 0;
}
/* ============================================================
响应式
============================================================ */
@media (max-width: 1200px) {
.ep-preview-screen {
max-height: 240px;
}
}
@media (max-width: 900px) {
.ep-preview-screen {
max-height: 200px;
}
.ep-preview-type-icon {
font-size: 40px;
}
.ep-preview-subtitle {
font-size: 13px;
padding: 6px 14px;
}
.ep-preview-time {
min-width: 70px;
font-size: 11px;
}
.ep-preview-clip-info {
min-width: 70px;
font-size: 11px;
}
}
@media (max-width: 768px) {
.ep-preview-screen {
max-height: 180px;
}
.ep-preview-controls {
padding: 6px var(--space-md);
gap: var(--space-sm);
}
.ep-preview-btn-play {
width: 36px;
height: 36px;
font-size: 14px;
}
.ep-preview-timeline {
height: 22px;
padding: 0 var(--space-md);
}
}
@media (max-width: 480px) {
.ep-preview-screen {
max-height: 140px;
}
.ep-preview-type-icon {
font-size: 32px;
}
.ep-preview-subtitle {
font-size: 12px;
max-width: 90%;
}
.ep-preview-clip-badge,
.ep-preview-material-tag {
font-size: 10px;
padding: 2px 6px;
}
.ep-preview-clip-info {
display: none;
}
.ep-preview-time {
min-width: auto;
}
}
@@ -0,0 +1,415 @@
/**
* 预览播放器 — V21 设计系统
* 模拟播放 EditPlan 片段序列,支持播放/暂停、进度条拖拽、时间线点击跳转
* 任务 2.16
*/
import React, { useState, useRef, useCallback, useEffect } from "react";
import "./PreviewPlayer.css";
import type { EditPlanClip } from "@/api/editPlans";
import { MATERIAL_TYPE_ICONS } from "@/api/editPlans";
interface PreviewPlayerProps {
clips: EditPlanClip[];
totalDuration: number;
selectedClipId: string | null;
onSelectClip: (clipId: string | null) => 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<PreviewPlayerProps> = ({
clips,
totalDuration,
selectedClipId,
onSelectClip,
}) => {
const [isPlaying, setIsPlaying] = useState(false);
const [currentTime, setCurrentTime] = useState(0);
const [isDragging, setIsDragging] = useState(false);
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const progressRef = useRef<HTMLDivElement>(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 (
<div className="ep-preview">
<div className="ep-preview-screen">
<div className="ep-preview-empty">
<div className="ep-preview-empty-icon">🎬</div>
<p></p>
</div>
</div>
</div>
);
}
return (
<div className="ep-preview">
{/* ── 预览画面 ── */}
<div className="ep-preview-screen">
{/* 背景渐变(模拟视频画面) */}
<div
className="ep-preview-visual"
style={{
background: `linear-gradient(135deg, ${getClipColor(currentClipIndex)}33, ${getClipColor(currentClipIndex)}11)`,
}}
>
{/* 片段类型图标 */}
<div className="ep-preview-type-icon">
{currentClip ? MATERIAL_TYPE_ICONS[currentClip.material_type] || "📄" : "🎬"}
</div>
{/* 文案字幕 */}
{currentClip?.script_text && (
<div className="ep-preview-subtitle">{currentClip.script_text}</div>
)}
{/* 片段序号角标 */}
<div
className="ep-preview-clip-badge"
style={{ backgroundColor: getClipColor(currentClipIndex) }}
>
#{currentClipIndex + 1}
</div>
{/* 素材类型标签 */}
{currentClip && (
<div className="ep-preview-material-tag">
{MATERIAL_TYPE_ICONS[currentClip.material_type]}{" "}
{currentClip.material_type}
</div>
)}
</div>
</div>
{/* ── 控制栏 ── */}
<div className="ep-preview-controls">
{/* 左侧:时间 */}
<div className="ep-preview-time">
<span className="ep-preview-time-current">
{formatTime(currentTime)}
</span>
<span className="ep-preview-time-sep">/</span>
<span className="ep-preview-time-total">
{formatTime(totalDuration)}
</span>
</div>
{/* 中间:播放控制按钮 */}
<div className="ep-preview-buttons">
<button
className="ep-preview-btn"
onClick={handlePrevClip}
title="上一段"
>
</button>
<button
className="ep-preview-btn ep-preview-btn-play"
onClick={togglePlay}
title={isPlaying ? "暂停" : "播放"}
>
{isPlaying ? "⏸" : "▶"}
</button>
<button
className="ep-preview-btn"
onClick={handleStop}
title="停止"
>
</button>
<button
className="ep-preview-btn"
onClick={handleNextClip}
title="下一段"
>
</button>
</div>
{/* 右侧:片段信息 */}
<div className="ep-preview-clip-info">
{currentClip && (
<>
<span className="ep-preview-clip-idx">
{currentClipIndex + 1}/{clips.length}
</span>
<span className="ep-preview-clip-dur">
{currentClip.duration}s
</span>
</>
)}
</div>
</div>
{/* ── 进度条(可拖拽) ── */}
<div
className="ep-preview-progress"
ref={progressRef}
onMouseDown={handleProgressMouseDown}
>
<div className="ep-preview-progress-track">
{/* 片段色块背景 */}
{clips.map((clip, idx) => (
<div
key={clip.id}
className="ep-preview-progress-segment"
style={{
width: `${(clip.duration / totalDuration) * 100}%`,
backgroundColor: getClipColor(idx),
opacity: idx === currentClipIndex ? 0.6 : 0.25,
}}
/>
))}
{/* 已播放覆盖层 */}
<div
className="ep-preview-progress-fill"
style={{ width: `${overallProgress}%` }}
/>
</div>
{/* 拖拽手柄 */}
<div
className="ep-preview-progress-handle"
style={{ left: `${overallProgress}%` }}
/>
</div>
{/* ── 迷你时间线(可点击跳转) ── */}
<div className="ep-preview-timeline">
{clips.map((clip, idx) => {
const isActive = idx === currentClipIndex;
const isSelected = clip.id === selectedClipId;
return (
<div
key={clip.id}
className={`ep-preview-timeline-seg${isActive ? " active" : ""}${isSelected ? " selected" : ""}`}
style={{
width: `${(clip.duration / totalDuration) * 100}%`,
backgroundColor: isActive
? getClipColor(idx)
: `${getClipColor(idx)}55`,
}}
onClick={() => handleTimelineSegmentClick(idx)}
title={`片段 ${idx + 1}: ${clip.duration}s`}
>
<span className="ep-preview-timeline-seg-label">
{MATERIAL_TYPE_ICONS[clip.material_type]}
</span>
</div>
);
})}
{/* 播放头指示器 */}
<div
className="ep-preview-playhead"
style={{ left: `${overallProgress}%` }}
/>
</div>
{/* ── 片段内进度 ── */}
{currentClip && (
<div className="ep-preview-clip-progress">
<div
className="ep-preview-clip-progress-fill"
style={{
width: `${clipProgress}%`,
backgroundColor: getClipColor(currentClipIndex),
}}
/>
</div>
)}
</div>
);
};
export default PreviewPlayer;
@@ -165,7 +165,7 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
const getClipColor = (idx: number) => clipColors[idx % clipColors.length];
return (
<div className="ep-center">
<div>
{/* 可视化时长条 */}
<div className="ep-timeline-bar">
<div className="ep-timeline-bar-label">
+569
View File
@@ -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"
+466
View File
@@ -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