feat: 扩展剪辑模板体系 + 实现剪辑计划生成器 #201

Merged
xiaoxia merged 4 commits from feature/template-and-plan-generator into develop 2026-07-09 21:21:23 +08:00
15 changed files with 1430 additions and 10 deletions
@@ -0,0 +1,26 @@
"""Add editing_mode to edit_templates
Revision ID: 035_editing_mode
Revises: 034_cms_enhance
Create Date: 2026-07-09
"""
import sqlalchemy as sa
from alembic import op
revision = "035_editing_mode"
down_revision = "034_cms_enhance"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"edit_templates",
sa.Column("editing_mode", sa.String(20), nullable=False, server_default="one_take"),
)
def downgrade() -> None:
op.drop_column("edit_templates", "editing_mode")
+126 -1
View File
@@ -10,6 +10,8 @@ RESTful CRUD for EditPlan:
- GET /api/v1/edit-plans/{id}/generation-status 查询生成进度(任务 2.05)
- POST /api/v1/edit-plans/{id}/ai-recommend AI 推荐片段方案(任务 3.09)
- POST /api/v1/edit-plans/{id}/generate-cover AI 生成封面(任务 3.09)
- GET /api/v1/edit-plans/{id}/timeline 时间线场景数据
- POST /api/v1/edit-plans/generate-from-template 基于模板+素材自动生成剪辑计划
业务逻辑委托给 EditPlanService 服务层。
"""
@@ -24,7 +26,7 @@ from app.auth import AuthenticatedUser, get_current_user
from app.core.celery_app import celery_app
from app.dependencies import get_asset_library_repository, get_asset_repository, get_db_session, get_project_repository
from app.schemas.generation_task import GenerationTaskResponse
from app.services import EditPlanService
from app.services import EditPlanService, PlanGeneratorService
from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
@@ -203,6 +205,44 @@ class GenerateCoverResponse(BaseModel):
cover: dict[str, Any] = Field(..., description="封面数据(type / image_url / frame_time 等)")
# ── 基于模板生成剪辑计划 Schemas ─────────────────────────────────────────────
class GenerateFromTemplateRequest(BaseModel):
"""基于模板生成剪辑计划请求体"""
template_id: str = Field(..., description="剪辑模板 ID")
asset_ids: List[str] = Field(default_factory=list, description="素材 ID 列表")
project_id: str = Field(default="", description="所属项目 ID")
name: str = Field(default="", description="计划名称(为空则自动取模板名)")
class _PlanClipItem(BaseModel):
"""片段响应体"""
id: str
clip_type: str
order: int
asset_id: str
text_content: str
start_time: float
duration: float
transition_effect: str
status: str
config: Optional[dict[str, Any]] = None
created_at: datetime
updated_at: datetime
model_config = {"from_attributes": True}
class GenerateFromTemplateResponse(BaseModel):
"""基于模板生成剪辑计划响应体"""
plan: EditPlanResponse
clips: List[_PlanClipItem]
# ── Helpers ───────────────────────────────────────────────────────────────────
@@ -1078,3 +1118,88 @@ def get_plan_timeline(
total_duration=total_duration,
scenes=scenes,
)
# ── 基于模板生成剪辑计划 ─────────────────────────────────────────────────────
@router.post(
"/generate-from-template",
response_model=GenerateFromTemplateResponse,
status_code=status.HTTP_201_CREATED,
)
def generate_from_template(
body: GenerateFromTemplateRequest,
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository),
) -> GenerateFromTemplateResponse:
"""基于模板 + 素材自动生成剪辑计划
流程:
1. 获取模板及其片段配置
2. 调用 PlanGeneratorService 生成 EditPlan + EditPlanClips
3. 返回完整的计划和片段列表
"""
from app.services import EditTemplateService
# 项目鉴权
if body.project_id:
_check_project_access(body.project_id, current_user.user.id, project_repository)
template_svc = EditTemplateService(db)
# 获取模板
try:
template = template_svc.get_template_or_raise(body.template_id)
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=str(exc),
)
# 获取模板片段配置
clip_configs = template_svc.list_clip_configs(body.template_id, skip=0, limit=200)
# 调用 PlanGeneratorService 生成计划
generator = PlanGeneratorService(db)
result = generator.generate_from_template(
template=template,
clip_configs=clip_configs,
asset_ids=body.asset_ids,
project_id=body.project_id,
created_by_user_id=current_user.user.id,
name=body.name,
)
plan = result["plan"]
clips = result["clips"]
logger.info(
"基于模板生成剪辑计划: plan_id=%s template_id=%s clips=%d by user=%s",
plan.id,
body.template_id,
len(clips),
current_user.user.id,
)
return GenerateFromTemplateResponse(
plan=_to_response(plan),
clips=[
_PlanClipItem(
id=c.id,
clip_type=c.clip_type,
order=c.order,
asset_id=c.asset_id,
text_content=c.text_content,
start_time=c.start_time,
duration=c.duration,
transition_effect=c.transition_effect,
status=c.status.value if hasattr(c.status, "value") else c.status,
config=c.config,
created_at=c.created_at,
updated_at=c.updated_at,
)
for c in clips
],
)
+10
View File
@@ -41,6 +41,9 @@ class EditTemplateCreateRequest(BaseModel):
name: str = Field(..., min_length=1, max_length=200, description="模板名称")
description: str = Field(default="", max_length=2000, description="模板描述")
template_type: str = Field(default="default", max_length=50, description="模板类型")
editing_mode: str = Field(
default="one_take", max_length=20, description="剪辑模式: one_take/pip/voice_over/voice_pip"
)
config: dict[str, Any] = Field(default_factory=dict, description="模板配置 (JSON)")
preview_url: str = Field(default="", max_length=500, description="预览地址")
sort_weight: int = Field(default=0, ge=0, le=9999, description="排序权重")
@@ -52,6 +55,9 @@ class EditTemplateUpdateRequest(BaseModel):
name: Optional[str] = Field(default=None, min_length=1, max_length=200, description="模板名称")
description: Optional[str] = Field(default=None, max_length=2000, description="模板描述")
template_type: Optional[str] = Field(default=None, max_length=50, description="模板类型")
editing_mode: Optional[str] = Field(
default=None, max_length=20, description="剪辑模式: one_take/pip/voice_over/voice_pip"
)
config: Optional[dict[str, Any]] = Field(default=None, description="模板配置 (JSON)")
preview_url: Optional[str] = Field(default=None, max_length=500, description="预览地址")
sort_weight: Optional[int] = Field(default=None, ge=0, le=9999, description="排序权重")
@@ -65,6 +71,7 @@ class EditTemplateResponse(BaseModel):
name: str
description: str
template_type: str
editing_mode: str
config: dict[str, Any]
preview_url: str
sort_weight: int
@@ -102,6 +109,7 @@ def _to_response(t: EditTemplate) -> EditTemplateResponse:
name=t.name,
description=t.description,
template_type=t.template_type,
editing_mode=t.editing_mode,
config=t.config,
preview_url=t.preview_url,
sort_weight=t.sort_weight,
@@ -195,6 +203,7 @@ def create_template(
name=body.name,
description=body.description,
template_type=body.template_type,
editing_mode=body.editing_mode,
config=normalized_config,
preview_url=body.preview_url,
sort_weight=body.sort_weight,
@@ -239,6 +248,7 @@ def update_template(
name=body.name,
description=body.description,
template_type=body.template_type,
editing_mode=body.editing_mode,
config=config_to_update,
preview_url=body.preview_url,
sort_weight=body.sort_weight,
+2
View File
@@ -4,6 +4,7 @@ from .auto_clip_service import AutoClipService
from .edit_plan_service import EditPlanService
from .edit_template_service import EditTemplateService
from .job_service import JobService
from .plan_generator_service import PlanGeneratorService
from .video_compose_service import VideoComposeService
__all__ = [
@@ -11,5 +12,6 @@ __all__ = [
"EditPlanService",
"EditTemplateService",
"JobService",
"PlanGeneratorService",
"VideoComposeService",
]
@@ -100,6 +100,7 @@ class EditTemplateService:
*,
description: str = "",
template_type: str = "default",
editing_mode: str = "one_take",
config: Optional[dict[str, Any]] = None,
preview_url: str = "",
sort_weight: int = 0,
@@ -124,6 +125,7 @@ class EditTemplateService:
name=clean_name,
description=description,
template_type=template_type,
editing_mode=editing_mode,
config=config,
preview_url=preview_url,
sort_weight=sort_weight,
@@ -139,6 +141,7 @@ class EditTemplateService:
name: Optional[str] = None,
description: Optional[str] = None,
template_type: Optional[str] = None,
editing_mode: Optional[str] = None,
config: Optional[dict[str, Any]] = None,
preview_url: Optional[str] = None,
sort_weight: Optional[int] = None,
@@ -165,6 +168,7 @@ class EditTemplateService:
name=new_name,
description=description.strip() if description is not None else existing.description,
template_type=template_type.strip() if template_type is not None else existing.template_type,
editing_mode=editing_mode.strip() if editing_mode is not None else existing.editing_mode,
config=config if config is not None else existing.config,
preview_url=preview_url.strip() if preview_url is not None else existing.preview_url,
sort_weight=sort_weight if sort_weight is not None else existing.sort_weight,
@@ -0,0 +1,391 @@
"""PlanGeneratorService — 基于模板+素材自动生成剪辑计划.
核心职责:
- 根据 EditTemplate 的 editing_mode 和 TemplateClipConfig 列表,
自动生成 EditPlan + EditPlanClip 列表
- 四种模式素材分配策略:
- ONE_TAKE: 素材顺序分配给 main 类型 clips
- PIP: 第1个素材→main(全屏背景),其余→overlay clips
- VOICE_OVER: 素材→main clips (B-roll),标记需要配音叠加
- VOICE_PIP: 第1个→background, 第2个→corner_voice, 其余→b_roll
"""
from __future__ import annotations
import logging
from typing import Any, List, Optional
from sqlalchemy.orm import Session
from packages.adapters.sqlalchemy_impl import (
SQLAlchemyEditPlanClipRepository,
SQLAlchemyEditPlanRepository,
)
from packages.domain.config_schemas import normalize_plan_config
from packages.domain.edit_plan import EditPlan
from packages.domain.edit_plan_clip import EditPlanClip
from packages.domain.edit_template import EditTemplate
from packages.domain.editing_mode import EditingMode
from packages.domain.template_clip_config import ClipType, TemplateClipConfig
logger = logging.getLogger(__name__)
# ── 默认片段时长(秒) ────────────────────────────────────────────────────────
_DEFAULT_CLIP_DURATION = 5.0
_DEFAULT_INTRO_DURATION = 3.0
_DEFAULT_OUTRO_DURATION = 3.0
class PlanGeneratorService:
"""剪辑计划生成器
基于模板 + 素材,自动生成 EditPlan 及 EditPlanClip 列表。
"""
def __init__(self, db: Session) -> None:
self._plan_repo = SQLAlchemyEditPlanRepository(db)
self._clip_repo = SQLAlchemyEditPlanClipRepository(db)
# ── 公开接口 ─────────────────────────────────────────────────────────────
def generate_from_template(
self,
template: EditTemplate,
clip_configs: List[TemplateClipConfig],
asset_ids: List[str],
*,
project_id: str = "",
created_by_user_id: str = "",
name: str = "",
) -> dict[str, Any]:
"""基于模板+素材生成剪辑计划
Args:
template: 剪辑模板实体
clip_configs: 模板片段配置列表(可为空,自动生成默认结构)
asset_ids: 素材 ID 列表
project_id: 所属项目 ID
created_by_user_id: 创建者用户 ID
name: 计划名称(为空则自动取模板名)
Returns:
dict: {"plan": EditPlan, "clips": List[EditPlanClip]}
"""
editing_mode = template.editing_mode or EditingMode.ONE_TAKE.value
plan_name = name.strip() or f"{template.name} - 剪辑计划"
# 1. 构建 plan config(继承模板的 title/subtitle/bgm,记录 editing_mode
plan_config = self._build_plan_config(template, editing_mode)
# 2. 创建 EditPlan
plan = EditPlan.create(
template_id=template.id,
name=plan_name,
config=plan_config,
total_duration=0.0,
project_id=project_id,
created_by_user_id=created_by_user_id,
)
plan = self._plan_repo.create(plan)
logger.info(
"生成剪辑计划: plan_id=%s template=%s mode=%s assets=%d",
plan.id,
template.id,
editing_mode,
len(asset_ids),
)
# 3. 生成片段列表
if clip_configs:
clips = self._create_clips_from_configs(plan.id, clip_configs)
else:
clips = self._generate_default_clips(plan.id, editing_mode, len(asset_ids))
# 4. 按 editing_mode 分配素材
if asset_ids:
self._distribute_assets(clips, asset_ids, editing_mode)
# 5. 持久化所有 clips 并计算总时长
created_clips: List[EditPlanClip] = []
total_duration = 0.0
for clip in clips:
saved = self._clip_repo.create(clip)
created_clips.append(saved)
total_duration += saved.duration
# 6. 更新 plan 的 total_duration
plan.total_duration = total_duration
plan = self._plan_repo.update(plan)
# 7. 流转到 editing 状态
try:
plan.start_editing()
plan = self._plan_repo.update(plan)
except ValueError as exc:
logger.warning("计划状态流转失败: plan_id=%s error=%s", plan.id, exc)
logger.info(
"剪辑计划生成完成: plan_id=%s clips=%d duration=%.1f",
plan.id,
len(created_clips),
total_duration,
)
return {"plan": plan, "clips": created_clips}
# ── 内部方法 ─────────────────────────────────────────────────────────────
def _build_plan_config(
self,
template: EditTemplate,
editing_mode: str,
) -> dict[str, Any]:
"""从模板配置构建 plan config"""
template_config = template.config or {}
plan_config: dict[str, Any] = {
"editing_mode": editing_mode,
}
# 继承模板的 cover/title/subtitle/bgm 配置
for key in ("cover", "title", "subtitle", "bgm"):
if key in template_config:
plan_config[key] = template_config[key]
return normalize_plan_config(plan_config)
def _create_clips_from_configs(
self,
plan_id: str,
clip_configs: List[TemplateClipConfig],
) -> List[EditPlanClip]:
"""从 TemplateClipConfig 列表创建 EditPlanClip 列表(未持久化)"""
clips: List[EditPlanClip] = []
# 按 order 排序
sorted_configs = sorted(clip_configs, key=lambda c: c.order)
for cfg in sorted_configs:
# 计算时长:取 min_duration 和 max_duration 的中间值
if cfg.min_duration > 0 and cfg.max_duration > 0:
duration = (cfg.min_duration + cfg.max_duration) / 2
elif cfg.min_duration > 0:
duration = cfg.min_duration
elif cfg.max_duration > 0:
duration = cfg.max_duration
else:
duration = _DEFAULT_CLIP_DURATION
# clip_type 可能是枚举或字符串
clip_type = cfg.clip_type.value if hasattr(cfg.clip_type, "value") else cfg.clip_type
# transition_effect 可能是枚举或字符串
transition = (
cfg.transition_effect.value if hasattr(cfg.transition_effect, "value") else cfg.transition_effect
)
clip = EditPlanClip.create(
plan_id=plan_id,
clip_type=clip_type,
order=cfg.order,
template_clip_config_id=cfg.id,
text_content=getattr(cfg, "text_template", "") or "",
duration=duration,
transition_effect=transition or "cut",
)
clips.append(clip)
return clips
def _generate_default_clips(
self,
plan_id: str,
editing_mode: str,
asset_count: int,
) -> List[EditPlanClip]:
"""无 clip_configs 时,根据 editing_mode 生成默认 clip 结构
- ONE_TAKE: N 个 main clipsN = asset_count,至少1个)
- PIP: 1 个 main + (N-1) 个 overlayN = asset_count
- VOICE_OVER: N 个 main clips + 标记需要配音
- VOICE_PIP: 1 个 background + 1 个 corner_voice + (N-2) 个 b_roll
"""
n = max(asset_count, 1)
clips: List[EditPlanClip] = []
order = 0
if editing_mode == EditingMode.PIP.value:
# 1 个 main(全屏背景)
clips.append(
EditPlanClip.create(
plan_id=plan_id,
clip_type=ClipType.MAIN.value,
order=order,
duration=_DEFAULT_CLIP_DURATION,
)
)
order += 1
# 剩余为 overlay
for i in range(1, n):
clips.append(
EditPlanClip.create(
plan_id=plan_id,
clip_type="overlay",
order=order,
duration=_DEFAULT_CLIP_DURATION,
)
)
order += 1
elif editing_mode == EditingMode.VOICE_OVER.value:
# N 个 main clipsB-roll
for i in range(n):
clips.append(
EditPlanClip.create(
plan_id=plan_id,
clip_type=ClipType.MAIN.value,
order=order,
duration=_DEFAULT_CLIP_DURATION,
config={"role": "b_roll"},
)
)
order += 1
elif editing_mode == EditingMode.VOICE_PIP.value:
# 1 个 background
clips.append(
EditPlanClip.create(
plan_id=plan_id,
clip_type="background",
order=order,
duration=_DEFAULT_CLIP_DURATION,
)
)
order += 1
# 1 个 corner_voice
clips.append(
EditPlanClip.create(
plan_id=plan_id,
clip_type="corner_voice",
order=order,
duration=_DEFAULT_CLIP_DURATION,
)
)
order += 1
# 剩余为 b_roll
for i in range(2, n):
clips.append(
EditPlanClip.create(
plan_id=plan_id,
clip_type="b_roll",
order=order,
duration=_DEFAULT_CLIP_DURATION,
)
)
order += 1
else:
# ONE_TAKE: N 个 main clips
for i in range(n):
clips.append(
EditPlanClip.create(
plan_id=plan_id,
clip_type=ClipType.MAIN.value,
order=order,
duration=_DEFAULT_CLIP_DURATION,
)
)
order += 1
return clips
def _distribute_assets(
self,
clips: List[EditPlanClip],
asset_ids: List[str],
editing_mode: str,
) -> None:
"""按 editing_mode 将素材分配到 clips(就地修改,未持久化)
分配策略:
- ONE_TAKE: 素材按顺序依次分配给 main 类型 clips
- PIP: 第1个素材→main(全屏背景),其余→交替分配给 overlay clips
- VOICE_OVER: 素材→main clips (B-roll)
- VOICE_PIP: 第1个→background, 第2个→corner_voice, 其余→b_roll
"""
if not asset_ids or not clips:
return
if editing_mode == EditingMode.ONE_TAKE.value:
self._distribute_one_take(clips, asset_ids)
elif editing_mode == EditingMode.PIP.value:
self._distribute_pip(clips, asset_ids)
elif editing_mode == EditingMode.VOICE_OVER.value:
self._distribute_voice_over(clips, asset_ids)
elif editing_mode == EditingMode.VOICE_PIP.value:
self._distribute_voice_pip(clips, asset_ids)
else:
# 未知模式,退化为 one_take
self._distribute_one_take(clips, asset_ids)
def _distribute_one_take(
self,
clips: List[EditPlanClip],
asset_ids: List[str],
) -> None:
"""ONE_TAKE: 素材按顺序依次分配给 main 类型 clips"""
main_clips = [c for c in clips if c.clip_type == ClipType.MAIN.value]
for i, clip in enumerate(main_clips):
if i < len(asset_ids):
clip.assign_asset(asset_ids[i])
def _distribute_pip(
self,
clips: List[EditPlanClip],
asset_ids: List[str],
) -> None:
"""PIP: 第1个素材→main(全屏背景),其余→overlay clips"""
# 第1个素材 → main clip
main_clips = [c for c in clips if c.clip_type == ClipType.MAIN.value]
if main_clips and asset_ids:
main_clips[0].assign_asset(asset_ids[0])
# 其余素材 → overlay clips
overlay_clips = [c for c in clips if c.clip_type == "overlay"]
remaining = asset_ids[1:]
for i, clip in enumerate(overlay_clips):
if i < len(remaining):
clip.assign_asset(remaining[i])
def _distribute_voice_over(
self,
clips: List[EditPlanClip],
asset_ids: List[str],
) -> None:
"""VOICE_OVER: 素材→main clips (B-roll)"""
main_clips = [c for c in clips if c.clip_type == ClipType.MAIN.value]
for i, clip in enumerate(main_clips):
if i < len(asset_ids):
clip.assign_asset(asset_ids[i])
def _distribute_voice_pip(
self,
clips: List[EditPlanClip],
asset_ids: List[str],
) -> None:
"""VOICE_PIP: 第1个→background, 第2个→corner_voice, 其余→b_roll"""
bg_clips = [c for c in clips if c.clip_type == "background"]
corner_clips = [c for c in clips if c.clip_type == "corner_voice"]
broll_clips = [c for c in clips if c.clip_type == "b_roll"]
# 第1个素材 → background
if bg_clips and len(asset_ids) > 0:
bg_clips[0].assign_asset(asset_ids[0])
# 第2个素材 → corner_voice
if corner_clips and len(asset_ids) > 1:
corner_clips[0].assign_asset(asset_ids[1])
# 其余素材 → b_roll
remaining = asset_ids[2:]
for i, clip in enumerate(broll_clips):
if i < len(remaining):
clip.assign_asset(remaining[i])
+8
View File
@@ -1098,6 +1098,14 @@
"type": "VARCHAR(50)",
"unique": false
},
{
"index": false,
"name": "editing_mode",
"nullable": false,
"primary_key": false,
"type": "VARCHAR(20)",
"unique": false
},
{
"index": false,
"name": "config",
@@ -71,6 +71,7 @@ class SQLAlchemyEditTemplateRepository:
name=template.name,
description=template.description,
template_type=template.template_type,
editing_mode=template.editing_mode,
config=template.config,
preview_url=template.preview_url,
sort_weight=template.sort_weight,
@@ -89,6 +90,7 @@ class SQLAlchemyEditTemplateRepository:
model.name = template.name
model.description = template.description
model.template_type = template.template_type
model.editing_mode = template.editing_mode
model.config = template.config
model.preview_url = template.preview_url
model.sort_weight = template.sort_weight
@@ -128,6 +130,7 @@ class SQLAlchemyEditTemplateRepository:
name=model.name,
description=model.description or "",
template_type=model.template_type or "default",
editing_mode=model.editing_mode or "one_take",
config=model.config or {},
preview_url=model.preview_url or "",
sort_weight=model.sort_weight or 0,
@@ -126,6 +126,7 @@ class EditTemplateModel(Base):
name = Column(String(120), nullable=False)
description = Column(Text, nullable=False, default="")
template_type = Column(String(50), nullable=False, default="default", index=True)
editing_mode = Column(String(20), nullable=False, default="one_take")
config = Column(JSON, nullable=False, default=dict)
preview_url = Column(String(1000), nullable=False, default="")
sort_weight = Column(Integer, nullable=False, default=0, index=True)
+44 -7
View File
@@ -8,6 +8,7 @@
from __future__ import annotations
import copy
from enum import Enum
from typing import List, Optional
@@ -129,24 +130,29 @@ class EditPlanConfigSchema(BaseModel):
用于 API 层校验和默认值填充。所有子结构均可选,
未传入时使用各自默认值。
editing_mode 记录计划使用的剪辑模式。
"""
cover: CoverConfig = Field(default_factory=CoverConfig, description="封面配置")
title: TitleConfig = Field(default_factory=TitleConfig, description="标题配置")
subtitle: SubtitleConfig = Field(default_factory=SubtitleConfig, description="字幕配置")
bgm: BGMConfig = Field(default_factory=BGMConfig, description="BGM 配置")
editing_mode: str = Field(default="one_take", description="剪辑模式")
class EditTemplateConfigSchema(BaseModel):
"""EditTemplate.config 完整结构
模板级别的默认配置,创建计划时可作为初始值继承。
editing_mode 指定模板对应的剪辑模式,transition_enabled 控制是否启用转场。
"""
cover: CoverConfig = Field(default_factory=CoverConfig, description="封面默认配置")
title: TitleConfig = Field(default_factory=TitleConfig, description="标题默认配置")
subtitle: SubtitleConfig = Field(default_factory=SubtitleConfig, description="字幕默认配置")
bgm: BGMConfig = Field(default_factory=BGMConfig, description="BGM 默认配置")
editing_mode: str = Field(default="one_take", description="剪辑模式")
transition_enabled: bool = Field(default=True, description="是否启用转场")
# ── 默认值常量 ────────────────────────────────────────────────────────────────
@@ -183,9 +189,13 @@ DEFAULT_EDIT_PLAN_CONFIG: dict = {
"asset_id": "",
"volume": 0.3,
},
"editing_mode": "one_take",
}
DEFAULT_EDIT_TEMPLATE_CONFIG: dict = DEFAULT_EDIT_PLAN_CONFIG.copy()
DEFAULT_EDIT_TEMPLATE_CONFIG: dict = {
**DEFAULT_EDIT_PLAN_CONFIG,
"transition_enabled": True,
}
# ── 工具函数 ──────────────────────────────────────────────────────────────────
@@ -197,9 +207,7 @@ def normalize_plan_config(raw: dict | None) -> dict:
用于创建/更新计划时确保 config 结构完整。
"""
if raw is None:
return DEFAULT_EDIT_PLAN_CONFIG.copy()
import copy
return copy.deepcopy(DEFAULT_EDIT_PLAN_CONFIG)
base = copy.deepcopy(DEFAULT_EDIT_PLAN_CONFIG)
@@ -209,14 +217,43 @@ def normalize_plan_config(raw: dict | None) -> dict:
base[section_key] = {}
base[section_key].update(raw[section_key])
# editing_mode 顶层字段
if "editing_mode" in raw and isinstance(raw["editing_mode"], str):
base["editing_mode"] = raw["editing_mode"]
# 保留非标准字段(如 generation_task_id
for key, value in raw.items():
if key not in ("cover", "title", "subtitle", "bgm"):
if key not in ("cover", "title", "subtitle", "bgm", "editing_mode"):
base[key] = value
return base
def normalize_template_config(raw: dict | None) -> dict:
"""将模板原始 config dict 标准化。逻辑同 normalize_plan_config。"""
return normalize_plan_config(raw)
"""将模板原始 config dict 标准化。
在 plan config 基础上额外支持 transition_enabled 字段。
"""
if raw is None:
return copy.deepcopy(DEFAULT_EDIT_TEMPLATE_CONFIG)
base = copy.deepcopy(DEFAULT_EDIT_TEMPLATE_CONFIG)
for section_key in ("cover", "title", "subtitle", "bgm"):
if section_key in raw and isinstance(raw[section_key], dict):
if section_key not in base:
base[section_key] = {}
base[section_key].update(raw[section_key])
# 顶层字段
if "editing_mode" in raw and isinstance(raw["editing_mode"], str):
base["editing_mode"] = raw["editing_mode"]
if "transition_enabled" in raw and isinstance(raw["transition_enabled"], bool):
base["transition_enabled"] = raw["transition_enabled"]
# 保留非标准字段
for key, value in raw.items():
if key not in ("cover", "title", "subtitle", "bgm", "editing_mode", "transition_enabled"):
base[key] = value
return base
+16
View File
@@ -18,6 +18,8 @@ else:
from typing import Any
from uuid import uuid4
from .editing_mode import EditingMode
class EditTemplateStatus(StrEnum):
"""模板状态"""
@@ -26,18 +28,25 @@ class EditTemplateStatus(StrEnum):
INACTIVE = "inactive"
_VALID_EDITING_MODES = {m.value for m in EditingMode}
@dataclass(slots=True)
class EditTemplate:
"""Phase 8 剪辑模板实体
全局模板库中的模板,定义剪辑风格、配置参数和预览信息。
不绑定到具体项目,可被多个 EditPlan 引用。
editing_mode 指定模板对应的剪辑模式(one_take / pip / voice_over / voice_pip),
决定剪辑计划生成时的片段结构。
"""
id: str
name: str
description: str = ""
template_type: str = "default"
editing_mode: str = EditingMode.ONE_TAKE.value
config: dict[str, Any] = field(default_factory=dict)
preview_url: str = ""
sort_weight: int = 0
@@ -52,6 +61,7 @@ class EditTemplate:
*,
description: str = "",
template_type: str = "default",
editing_mode: str = EditingMode.ONE_TAKE.value,
config: dict[str, Any] | None = None,
preview_url: str = "",
sort_weight: int = 0,
@@ -61,11 +71,17 @@ class EditTemplate:
clean_name = name.strip()
if not clean_name:
raise ValueError("模板名称不能为空")
clean_mode = editing_mode.strip() or EditingMode.ONE_TAKE.value
if clean_mode not in _VALID_EDITING_MODES:
raise ValueError(
f"无效的 editing_mode: {clean_mode}" f"允许值: {', '.join(sorted(_VALID_EDITING_MODES))}"
)
return cls(
id=uuid4().hex,
name=clean_name,
description=description.strip(),
template_type=template_type.strip() or "default",
editing_mode=clean_mode,
config=config or {},
preview_url=preview_url.strip(),
sort_weight=sort_weight,
@@ -105,11 +105,19 @@ class TestNormalizePlanConfig:
class TestNormalizeTemplateConfig:
def test_same_as_plan_config(self):
def test_same_as_plan_config_plus_template_fields(self):
"""template config 包含 plan config 的所有字段,外加 transition_enabled"""
from packages.domain.config_schemas import normalize_plan_config, normalize_template_config
raw = {"title": {"text": "模板标题"}}
assert normalize_template_config(raw) == normalize_plan_config(raw)
plan_cfg = normalize_plan_config(raw)
tpl_cfg = normalize_template_config(raw)
# plan config 的字段在 template config 中应一致
for key in plan_cfg:
assert tpl_cfg[key] == plan_cfg[key]
# template config 额外包含 transition_enabled
assert "transition_enabled" in tpl_cfg
assert tpl_cfg["transition_enabled"] is True
def test_none_returns_defaults(self):
from packages.domain.config_schemas import DEFAULT_EDIT_TEMPLATE_CONFIG, normalize_template_config
+1
View File
@@ -407,6 +407,7 @@ class TestResponseSchema:
"name",
"description",
"template_type",
"editing_mode",
"config",
"preview_url",
"sort_weight",
+598
View File
@@ -0,0 +1,598 @@
"""
PlanGeneratorService 单元测试
覆盖(6 组测试):
- ONE_TAKE 模式:素材顺序分配给 main clips
- PIP 模式:第1个素材→main,其余→overlay
- VOICE_OVER 模式:素材→main clips (B-roll)
- VOICE_PIP 模式:第1个→background, 第2个→corner_voice, 其余→b_roll
- 无 clip_configs 时自动生成默认结构
- 空素材列表时 clips 创建但无素材分配
"""
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_plan import EditPlan, EditPlanStatus
from packages.domain.edit_plan_clip import EditPlanClip, EditPlanClipStatus
from packages.domain.edit_template import EditTemplate, EditTemplateStatus
from packages.domain.editing_mode import EditingMode
from packages.domain.template_clip_config import ClipType, TemplateClipConfig, TransitionEffect
# ---------------------------------------------------------------------------
# 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 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,
source_edit_plan_id=plan.source_edit_plan_id,
project_id=plan.project_id,
created_by_user_id=plan.created_by_user_id,
config=plan.config,
created_at=plan.created_at,
updated_at=plan.updated_at,
)
self._plans[plan.id] = plan
return plan
def update(self, plan: EditPlan) -> EditPlan:
self._plans[plan.id] = plan
return plan
def list_all(self, **kwargs) -> List[EditPlan]:
return list(self._plans.values())
def count(self, **kwargs) -> int:
return len(self._plans)
def delete(self, plan_id: str) -> bool:
return self._plans.pop(plan_id, None) is not None
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 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,
template_clip_config_id=clip.template_clip_config_id,
clip_type=clip.clip_type,
order=clip.order,
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=clip.created_at,
updated_at=clip.updated_at,
)
self._clips[clip.id] = clip
return clip
def update(self, clip: EditPlanClip) -> EditPlanClip:
self._clips[clip.id] = clip
return clip
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]
items.sort(key=lambda c: c.order)
if status:
items = [c for c in items if c.status == status]
return items[skip : skip + limit]
def delete(self, clip_id: str) -> bool:
return self._clips.pop(clip_id, None) is not None
# ---------------------------------------------------------------------------
# Helper: 构建 PlanGeneratorServicepatch 仓储)
# ---------------------------------------------------------------------------
def _make_generator():
"""创建使用 stub 仓储的 PlanGeneratorService"""
from apps.api.app.services.plan_generator_service import PlanGeneratorService
plan_repo = StubEditPlanRepository()
clip_repo = StubEditPlanClipRepository()
with (
patch(
"apps.api.app.services.plan_generator_service.SQLAlchemyEditPlanRepository",
return_value=plan_repo,
),
patch(
"apps.api.app.services.plan_generator_service.SQLAlchemyEditPlanClipRepository",
return_value=clip_repo,
),
):
db = MagicMock()
svc = PlanGeneratorService(db)
# 替换为 stub
svc._plan_repo = plan_repo
svc._clip_repo = clip_repo
return svc, plan_repo, clip_repo
def _make_template(
editing_mode: str = "one_take",
config: Optional[dict] = None,
) -> EditTemplate:
"""创建测试用 EditTemplate"""
return EditTemplate(
id="tpl-001",
name="测试模板",
description="",
template_type="default",
editing_mode=editing_mode,
config=config or {},
preview_url="",
sort_weight=0,
status=EditTemplateStatus.ACTIVE,
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc),
)
def _make_clip_configs(
template_id: str = "tpl-001",
specs: Optional[List[dict]] = None,
) -> List[TemplateClipConfig]:
"""创建测试用 TemplateClipConfig 列表
specs 示例: [{"clip_type": ClipType.INTRO, "order": 0}, ...]
"""
if specs is None:
specs = [
{"clip_type": ClipType.INTRO, "order": 0, "min_duration": 2.0, "max_duration": 4.0},
{"clip_type": ClipType.MAIN, "order": 1, "min_duration": 3.0, "max_duration": 7.0},
{"clip_type": ClipType.OUTRO, "order": 2, "min_duration": 2.0, "max_duration": 4.0},
]
configs = []
for i, spec in enumerate(specs):
cfg = TemplateClipConfig(
id=f"cfg-{i:03d}",
template_id=template_id,
clip_type=spec.get("clip_type", ClipType.MAIN),
order=spec.get("order", i),
min_duration=spec.get("min_duration", 0.0),
max_duration=spec.get("max_duration", 0.0),
text_template=spec.get("text_template", ""),
material_requirements=spec.get("material_requirements"),
transition_effect=spec.get("transition_effect", TransitionEffect.CUT),
config=spec.get("config"),
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc),
)
configs.append(cfg)
return configs
# ---------------------------------------------------------------------------
# 测试:ONE_TAKE 模式
# ---------------------------------------------------------------------------
class TestGenerateOneTakePlan:
"""ONE_TAKE 模式:素材顺序分配给 main clips"""
def test_generate_one_take_plan(self):
"""3个clip_configs + 3个asset_ids → 按顺序分配"""
svc, plan_repo, clip_repo = _make_generator()
template = _make_template(editing_mode=EditingMode.ONE_TAKE.value)
clip_configs = _make_clip_configs()
asset_ids = ["asset-1", "asset-2", "asset-3"]
result = svc.generate_from_template(
template=template,
clip_configs=clip_configs,
asset_ids=asset_ids,
project_id="proj-001",
created_by_user_id="user-001",
)
plan = result["plan"]
clips = result["clips"]
assert plan.template_id == "tpl-001"
assert plan.config["editing_mode"] == "one_take"
assert plan.status == EditPlanStatus.EDITING
assert len(clips) == 3
# 按 order 排序后检查素材分配
sorted_clips = sorted(clips, key=lambda c: c.order)
# intro clip (order=0) 不是 main 类型,不分配素材
# main clip (order=1) → asset-2ONE_TAKE 只分配给 main clips
# outro clip (order=2) 不是 main 类型
main_clips = [c for c in sorted_clips if c.clip_type == ClipType.MAIN.value]
assert len(main_clips) == 1
assert main_clips[0].asset_id == "asset-1"
def test_one_take_plan_name_from_template(self):
"""name 为空时自动取模板名"""
svc, _, _ = _make_generator()
template = _make_template(editing_mode=EditingMode.ONE_TAKE.value)
result = svc.generate_from_template(
template=template,
clip_configs=[],
asset_ids=["asset-1"],
)
assert "测试模板" in result["plan"].name
def test_one_take_custom_name(self):
"""指定 name 时使用自定义名称"""
svc, _, _ = _make_generator()
template = _make_template(editing_mode=EditingMode.ONE_TAKE.value)
result = svc.generate_from_template(
template=template,
clip_configs=[],
asset_ids=["asset-1"],
name="我的剪辑",
)
assert result["plan"].name == "我的剪辑"
# ---------------------------------------------------------------------------
# 测试:PIP 模式
# ---------------------------------------------------------------------------
class TestGeneratePipPlan:
"""PIP 模式:第1个素材→main,其余→overlay"""
def test_generate_pip_plan(self):
"""4个素材 → 第1个→main,其余→overlay"""
svc, _, _ = _make_generator()
template = _make_template(editing_mode=EditingMode.PIP.value)
# 无 clip_configs,自动生成默认结构
asset_ids = ["bg-asset", "overlay-1", "overlay-2", "overlay-3"]
result = svc.generate_from_template(
template=template,
clip_configs=[],
asset_ids=asset_ids,
)
plan = result["plan"]
clips = result["clips"]
assert plan.config["editing_mode"] == "pip"
# 自动生成: 1 main + 3 overlay
assert len(clips) == 4
main_clips = [c for c in clips if c.clip_type == ClipType.MAIN.value]
overlay_clips = [c for c in clips if c.clip_type == "overlay"]
assert len(main_clips) == 1
assert len(overlay_clips) == 3
# 第1个素材 → main
assert main_clips[0].asset_id == "bg-asset"
# 其余 → overlay
assert overlay_clips[0].asset_id == "overlay-1"
assert overlay_clips[1].asset_id == "overlay-2"
assert overlay_clips[2].asset_id == "overlay-3"
# ---------------------------------------------------------------------------
# 测试:VOICE_OVER 模式
# ---------------------------------------------------------------------------
class TestGenerateVoiceOverPlan:
"""VOICE_OVER 模式:素材→main clips (B-roll)"""
def test_generate_voice_over_plan(self):
"""3个素材 → 3个 main clips,每个标记为 b_roll"""
svc, _, _ = _make_generator()
template = _make_template(editing_mode=EditingMode.VOICE_OVER.value)
asset_ids = ["video-1", "video-2", "video-3"]
result = svc.generate_from_template(
template=template,
clip_configs=[],
asset_ids=asset_ids,
)
plan = result["plan"]
clips = result["clips"]
assert plan.config["editing_mode"] == "voice_over"
assert len(clips) == 3
# 所有 clips 都是 main 类型
for clip in clips:
assert clip.clip_type == ClipType.MAIN.value
# 素材按顺序分配
assert clips[0].asset_id == "video-1"
assert clips[1].asset_id == "video-2"
assert clips[2].asset_id == "video-3"
# 每个 clip 的 config 标记为 b_roll
for clip in clips:
assert clip.config.get("role") == "b_roll"
# ---------------------------------------------------------------------------
# 测试:VOICE_PIP 模式
# ---------------------------------------------------------------------------
class TestGenerateVoicePipPlan:
"""VOICE_PIP 模式:第1个→background, 第2个→corner_voice, 其余→b_roll"""
def test_generate_voice_pip_plan(self):
"""4个素材 → background + corner_voice + 2 b_roll"""
svc, _, _ = _make_generator()
template = _make_template(editing_mode=EditingMode.VOICE_PIP.value)
asset_ids = ["bg-video", "corner-video", "broll-1", "broll-2"]
result = svc.generate_from_template(
template=template,
clip_configs=[],
asset_ids=asset_ids,
)
plan = result["plan"]
clips = result["clips"]
assert plan.config["editing_mode"] == "voice_pip"
assert len(clips) == 4
bg_clips = [c for c in clips if c.clip_type == "background"]
corner_clips = [c for c in clips if c.clip_type == "corner_voice"]
broll_clips = [c for c in clips if c.clip_type == "b_roll"]
assert len(bg_clips) == 1
assert len(corner_clips) == 1
assert len(broll_clips) == 2
# 素材分配
assert bg_clips[0].asset_id == "bg-video"
assert corner_clips[0].asset_id == "corner-video"
assert broll_clips[0].asset_id == "broll-1"
assert broll_clips[1].asset_id == "broll-2"
# ---------------------------------------------------------------------------
# 测试:无 clip_configs 时自动生成默认结构
# ---------------------------------------------------------------------------
class TestGenerateWithoutClipConfigs:
"""无 clip_configs 时根据 editing_mode 生成默认 clip 结构"""
def test_one_take_default_clips(self):
"""ONE_TAKE + 3个素材 → 3个 main clips"""
svc, _, _ = _make_generator()
template = _make_template(editing_mode=EditingMode.ONE_TAKE.value)
result = svc.generate_from_template(
template=template,
clip_configs=[],
asset_ids=["a1", "a2", "a3"],
)
clips = result["clips"]
assert len(clips) == 3
for clip in clips:
assert clip.clip_type == ClipType.MAIN.value
def test_pip_default_clips(self):
"""PIP + 3个素材 → 1 main + 2 overlay"""
svc, _, _ = _make_generator()
template = _make_template(editing_mode=EditingMode.PIP.value)
result = svc.generate_from_template(
template=template,
clip_configs=[],
asset_ids=["a1", "a2", "a3"],
)
clips = result["clips"]
assert len(clips) == 3
main_clips = [c for c in clips if c.clip_type == ClipType.MAIN.value]
overlay_clips = [c for c in clips if c.clip_type == "overlay"]
assert len(main_clips) == 1
assert len(overlay_clips) == 2
def test_voice_pip_default_clips(self):
"""VOICE_PIP + 4个素材 → 1 background + 1 corner_voice + 2 b_roll"""
svc, _, _ = _make_generator()
template = _make_template(editing_mode=EditingMode.VOICE_PIP.value)
result = svc.generate_from_template(
template=template,
clip_configs=[],
asset_ids=["a1", "a2", "a3", "a4"],
)
clips = result["clips"]
assert len(clips) == 4
# ---------------------------------------------------------------------------
# 测试:空素材列表
# ---------------------------------------------------------------------------
class TestGenerateEmptyAssets:
"""空素材列表时 clips 创建但无素材分配"""
def test_empty_assets(self):
"""空 asset_ids → clips 创建但 asset_id 为空"""
svc, _, _ = _make_generator()
template = _make_template(editing_mode=EditingMode.ONE_TAKE.value)
clip_configs = _make_clip_configs()
result = svc.generate_from_template(
template=template,
clip_configs=clip_configs,
asset_ids=[],
)
plan = result["plan"]
clips = result["clips"]
assert plan.total_duration > 0 # clips 有默认时长
assert len(clips) == 3
for clip in clips:
assert clip.asset_id == ""
def test_empty_assets_pip(self):
"""PIP 模式空素材 → 1个 main clip(至少1个)"""
svc, _, _ = _make_generator()
template = _make_template(editing_mode=EditingMode.PIP.value)
result = svc.generate_from_template(
template=template,
clip_configs=[],
asset_ids=[],
)
clips = result["clips"]
# 至少1个 main clipn = max(asset_count, 1) = 1
assert len(clips) == 1
assert clips[0].clip_type == ClipType.MAIN.value
# ---------------------------------------------------------------------------
# 测试:plan config 继承模板配置
# ---------------------------------------------------------------------------
class TestPlanConfigInheritance:
"""plan config 继承模板的 cover/title/subtitle/bgm"""
def test_inherit_template_config(self):
"""模板有 cover/title/bgm 配置 → plan 继承"""
svc, _, _ = _make_generator()
template_config = {
"editing_mode": "one_take",
"cover": {"type": "ai_frame"},
"title": {"text": "测试标题", "font": "思源黑体"},
"bgm": {"url": "https://example.com/bgm.mp3"},
}
template = _make_template(
editing_mode=EditingMode.ONE_TAKE.value,
config=template_config,
)
result = svc.generate_from_template(
template=template,
clip_configs=[],
asset_ids=["a1"],
)
plan_config = result["plan"].config
assert plan_config["editing_mode"] == "one_take"
assert plan_config["cover"]["type"] == "ai_frame"
assert plan_config["title"]["text"] == "测试标题"
assert plan_config["bgm"]["url"] == "https://example.com/bgm.mp3"
# ---------------------------------------------------------------------------
# 测试:total_duration 计算
# ---------------------------------------------------------------------------
class TestTotalDuration:
"""total_duration 正确计算"""
def test_duration_from_clip_configs(self):
"""有 clip_configs 时,duration 取 min/max 中间值"""
svc, _, _ = _make_generator()
template = _make_template(editing_mode=EditingMode.ONE_TAKE.value)
clip_configs = _make_clip_configs(
specs=[
{"clip_type": ClipType.INTRO, "order": 0, "min_duration": 2.0, "max_duration": 4.0},
{"clip_type": ClipType.MAIN, "order": 1, "min_duration": 4.0, "max_duration": 6.0},
{"clip_type": ClipType.OUTRO, "order": 2, "min_duration": 2.0, "max_duration": 4.0},
]
)
result = svc.generate_from_template(
template=template,
clip_configs=clip_configs,
asset_ids=["a1"],
)
# intro: (2+4)/2=3, main: (4+6)/2=5, outro: (2+4)/2=3 → total=11
assert result["plan"].total_duration == 11.0
def test_duration_from_default_clips(self):
"""无 clip_configs 时,每个 clip 默认 5 秒"""
svc, _, _ = _make_generator()
template = _make_template(editing_mode=EditingMode.ONE_TAKE.value)
result = svc.generate_from_template(
template=template,
clip_configs=[],
asset_ids=["a1", "a2", "a3"],
)
# 3 个 clips × 5 秒 = 15 秒
assert result["plan"].total_duration == 15.0
+190
View File
@@ -0,0 +1,190 @@
"""
EditTemplate editing_mode 字段单元测试
覆盖:
- EditTemplate.create() 带 editing_mode
- 默认值 "one_take"
- 无效 editing_mode 抛 ValueError
- EditingMode 枚举值完整性
- config_schemas 中 editing_mode 和 transition_enabled 字段
"""
from __future__ import annotations
import os
import sys
from datetime import datetime, timezone
from pathlib import Path
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.config_schemas import (
DEFAULT_EDIT_PLAN_CONFIG,
DEFAULT_EDIT_TEMPLATE_CONFIG,
normalize_plan_config,
normalize_template_config,
)
from packages.domain.edit_template import EditTemplate, EditTemplateStatus
from packages.domain.editing_mode import EditingMode
# ---------------------------------------------------------------------------
# 测试:EditTemplate.create() 带 editing_mode
# ---------------------------------------------------------------------------
class TestEditTemplateEditingMode:
"""EditTemplate editing_mode 字段测试"""
def test_create_with_editing_mode(self):
"""create() 指定 editing_mode"""
tpl = EditTemplate.create(
name="测试模板",
editing_mode=EditingMode.PIP.value,
)
assert tpl.editing_mode == "pip"
def test_create_default_editing_mode(self):
"""create() 不指定 editing_mode → 默认 one_take"""
tpl = EditTemplate.create(name="默认模式模板")
assert tpl.editing_mode == "one_take"
def test_create_all_editing_modes(self):
"""所有 EditingMode 枚举值均可创建"""
for mode in EditingMode:
tpl = EditTemplate.create(
name=f"模板-{mode.value}",
editing_mode=mode.value,
)
assert tpl.editing_mode == mode.value
def test_create_invalid_editing_mode(self):
"""无效 editing_mode 抛 ValueError"""
with pytest.raises(ValueError, match="无效的 editing_mode"):
EditTemplate.create(
name="无效模板",
editing_mode="invalid_mode",
)
def test_editing_mode_case_sensitive(self):
"""editing_mode 大小写敏感"""
with pytest.raises(ValueError):
EditTemplate.create(
name="大写模式",
editing_mode="ONE_TAKE", # 应小写
)
# ---------------------------------------------------------------------------
# 测试:EditingMode 枚举完整性
# ---------------------------------------------------------------------------
class TestEditingModeEnum:
"""EditingMode 枚举值测试"""
def test_enum_values(self):
"""枚举包含4种模式"""
assert EditingMode.ONE_TAKE.value == "one_take"
assert EditingMode.PIP.value == "pip"
assert EditingMode.VOICE_OVER.value == "voice_over"
assert EditingMode.VOICE_PIP.value == "voice_pip"
def test_enum_count(self):
"""枚举共4个成员"""
assert len(EditingMode) == 4
def test_str_enum(self):
"""EditingMode 是 StrEnum"""
assert isinstance(EditingMode.ONE_TAKE, str)
assert EditingMode.ONE_TAKE == "one_take"
# ---------------------------------------------------------------------------
# 测试:config_schemas 中的 editing_mode
# ---------------------------------------------------------------------------
class TestConfigSchemasEditingMode:
"""config_schemas editing_mode 和 transition_enabled 字段测试"""
def test_default_plan_config_has_editing_mode(self):
"""DEFAULT_EDIT_PLAN_CONFIG 包含 editing_mode"""
assert "editing_mode" in DEFAULT_EDIT_PLAN_CONFIG
assert DEFAULT_EDIT_PLAN_CONFIG["editing_mode"] == "one_take"
def test_default_template_config_has_editing_mode(self):
"""DEFAULT_EDIT_TEMPLATE_CONFIG 包含 editing_mode"""
assert "editing_mode" in DEFAULT_EDIT_TEMPLATE_CONFIG
assert DEFAULT_EDIT_TEMPLATE_CONFIG["editing_mode"] == "one_take"
def test_default_template_config_has_transition_enabled(self):
"""DEFAULT_EDIT_TEMPLATE_CONFIG 包含 transition_enabled"""
assert "transition_enabled" in DEFAULT_EDIT_TEMPLATE_CONFIG
assert DEFAULT_EDIT_TEMPLATE_CONFIG["transition_enabled"] is True
def test_normalize_plan_config_editing_mode(self):
"""normalize_plan_config 处理 editing_mode"""
config = normalize_plan_config({"editing_mode": "pip"})
assert config["editing_mode"] == "pip"
def test_normalize_plan_config_default_editing_mode(self):
"""normalize_plan_config 空输入 → 默认 editing_mode"""
config = normalize_plan_config({})
assert config["editing_mode"] == "one_take"
def test_normalize_template_config_editing_mode(self):
"""normalize_template_config 处理 editing_mode"""
config = normalize_template_config({"editing_mode": "voice_pip"})
assert config["editing_mode"] == "voice_pip"
def test_normalize_template_config_transition_enabled(self):
"""normalize_template_config 处理 transition_enabled"""
config = normalize_template_config({"transition_enabled": False})
assert config["transition_enabled"] is False
def test_normalize_template_config_defaults(self):
"""normalize_template_config 空输入 → 默认值"""
config = normalize_template_config({})
assert config["editing_mode"] == "one_take"
assert config["transition_enabled"] is True
# ---------------------------------------------------------------------------
# 测试:EditTemplate 实体直接构造
# ---------------------------------------------------------------------------
class TestEditTemplateEntityDirect:
"""直接构造 EditTemplate 实体测试 editing_mode"""
def test_direct_construction(self):
"""直接构造带 editing_mode 的实体"""
now = datetime.now(timezone.utc)
tpl = EditTemplate(
id="tpl-test",
name="直接构造",
description="",
template_type="default",
editing_mode="voice_over",
config={},
preview_url="",
sort_weight=0,
status=EditTemplateStatus.ACTIVE,
created_at=now,
updated_at=now,
)
assert tpl.editing_mode == "voice_over"
def test_all_enum_values_accepted(self):
"""所有 EditingMode 枚举值均可通过 create() 验证"""
for mode in EditingMode:
tpl = EditTemplate.create(
name=f"模板-{mode.value}",
editing_mode=mode.value,
)
assert tpl.editing_mode == mode.value