feat(phase8): 任务 2.06 — EditTemplateService + EditPlanService 服务层 #155
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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">
|
||||
|
||||
@@ -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"
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user