feat: 扩展剪辑模板体系 + 实现剪辑计划生成器 #201
@@ -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")
|
||||
@@ -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
|
||||
],
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 clips(N = asset_count,至少1个)
|
||||
- PIP: 1 个 main + (N-1) 个 overlay(N = 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 clips(B-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])
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -407,6 +407,7 @@ class TestResponseSchema:
|
||||
"name",
|
||||
"description",
|
||||
"template_type",
|
||||
"editing_mode",
|
||||
"config",
|
||||
"preview_url",
|
||||
"sort_weight",
|
||||
|
||||
@@ -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: 构建 PlanGeneratorService(patch 仓储)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
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-2(ONE_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 clip(n = 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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user