diff --git a/alembic/versions/035_add_editing_mode_to_templates.py b/alembic/versions/035_add_editing_mode_to_templates.py new file mode 100644 index 000000000..7970cee78 --- /dev/null +++ b/alembic/versions/035_add_editing_mode_to_templates.py @@ -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") diff --git a/apps/api/app/api/routes/edit_plans.py b/apps/api/app/api/routes/edit_plans.py index c69877fa9..5c7ea8f7a 100644 --- a/apps/api/app/api/routes/edit_plans.py +++ b/apps/api/app/api/routes/edit_plans.py @@ -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 + ], + ) diff --git a/apps/api/app/api/routes/edit_templates.py b/apps/api/app/api/routes/edit_templates.py index 0f9eb94c4..2d96de80a 100644 --- a/apps/api/app/api/routes/edit_templates.py +++ b/apps/api/app/api/routes/edit_templates.py @@ -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, diff --git a/apps/api/app/services/__init__.py b/apps/api/app/services/__init__.py index b245a6023..dd0beb93c 100755 --- a/apps/api/app/services/__init__.py +++ b/apps/api/app/services/__init__.py @@ -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", ] diff --git a/apps/api/app/services/edit_template_service.py b/apps/api/app/services/edit_template_service.py index a99ed3e48..e5bbc23ce 100644 --- a/apps/api/app/services/edit_template_service.py +++ b/apps/api/app/services/edit_template_service.py @@ -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, diff --git a/apps/api/app/services/plan_generator_service.py b/apps/api/app/services/plan_generator_service.py new file mode 100644 index 000000000..174f2286f --- /dev/null +++ b/apps/api/app/services/plan_generator_service.py @@ -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]) diff --git a/docs/schema-metadata-snapshot.json b/docs/schema-metadata-snapshot.json index 1b947366f..fb9be9640 100644 --- a/docs/schema-metadata-snapshot.json +++ b/docs/schema-metadata-snapshot.json @@ -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", diff --git a/packages/adapters/sqlalchemy_impl/edit_template_repository.py b/packages/adapters/sqlalchemy_impl/edit_template_repository.py index f0c0e4691..122805f9e 100644 --- a/packages/adapters/sqlalchemy_impl/edit_template_repository.py +++ b/packages/adapters/sqlalchemy_impl/edit_template_repository.py @@ -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, diff --git a/packages/adapters/sqlalchemy_impl/models.py b/packages/adapters/sqlalchemy_impl/models.py index 90a714bdf..46c672af1 100755 --- a/packages/adapters/sqlalchemy_impl/models.py +++ b/packages/adapters/sqlalchemy_impl/models.py @@ -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) diff --git a/packages/domain/config_schemas.py b/packages/domain/config_schemas.py index e616fc5c2..8dcbcc764 100644 --- a/packages/domain/config_schemas.py +++ b/packages/domain/config_schemas.py @@ -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 diff --git a/packages/domain/edit_template.py b/packages/domain/edit_template.py index 259515003..7412f3dd0 100644 --- a/packages/domain/edit_template.py +++ b/packages/domain/edit_template.py @@ -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, diff --git a/tests/unit/test_config_schemas_and_ai_endpoints.py b/tests/unit/test_config_schemas_and_ai_endpoints.py index 22671825a..bbb5825ad 100644 --- a/tests/unit/test_config_schemas_and_ai_endpoints.py +++ b/tests/unit/test_config_schemas_and_ai_endpoints.py @@ -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 diff --git a/tests/unit/test_edit_templates_api.py b/tests/unit/test_edit_templates_api.py index 1537ab73b..a50e60afa 100644 --- a/tests/unit/test_edit_templates_api.py +++ b/tests/unit/test_edit_templates_api.py @@ -407,6 +407,7 @@ class TestResponseSchema: "name", "description", "template_type", + "editing_mode", "config", "preview_url", "sort_weight", diff --git a/tests/unit/test_plan_generator.py b/tests/unit/test_plan_generator.py new file mode 100644 index 000000000..d4838ba5f --- /dev/null +++ b/tests/unit/test_plan_generator.py @@ -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 diff --git a/tests/unit/test_template_editing_mode.py b/tests/unit/test_template_editing_mode.py new file mode 100644 index 000000000..29a8fcad8 --- /dev/null +++ b/tests/unit/test_template_editing_mode.py @@ -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