diff --git a/apps/api/app/api/router.py b/apps/api/app/api/router.py index 9bbabcd16..cc0118b68 100755 --- a/apps/api/app/api/router.py +++ b/apps/api/app/api/router.py @@ -16,6 +16,7 @@ from app.api.routes.subscription import router as subscription_router from app.api.routes.tags import router as tags_router from app.api.routes.task_center import router as task_center_router from app.api.routes.templates import router as templates_router +from app.api.routes.templates_editor import router as templates_editor_router from app.api.routes.titles import router as titles_router from app.api.routes.tts import router as tts_router from app.api.routes.upload import router as upload_router @@ -119,6 +120,11 @@ api_router.include_router( prefix="/templates", tags=["Template"], ) +api_router.include_router( + templates_editor_router, + prefix="/templates/{template_id}/editor", + tags=["TemplateEditor"], +) api_router.include_router( edit_plans_router, prefix="/edit-plans", diff --git a/apps/api/app/api/routes/templates_editor.py b/apps/api/app/api/routes/templates_editor.py new file mode 100755 index 000000000..895a9808e --- /dev/null +++ b/apps/api/app/api/routes/templates_editor.py @@ -0,0 +1,353 @@ +"""模板编辑器 API — 剪辑计划收敛为模板编辑器内部概念. + +挂载路径: /api/v1/templates/{template_id}/editor/ + +核心设计: +- 每个模板有且仅有一个"草稿"剪辑计划 (config.is_template_draft=True) +- 首次访问自动创建草稿 +- 内部复用 EditPlanService 的业务逻辑,不重复实现 +- 原 /edit-plans/ 路由继续保留作为兼容层 + +当前已实现端点(Phase 2 第一步): +- GET / 获取编辑器草稿详情 +- PUT / 更新草稿基本信息 +- POST /publish 发布草稿到模板(草稿→正式模板) +- GET /clips 获取草稿片段列表 +- POST /clips 创建片段 +- PUT /clips/{id} 更新片段 +- DELETE /clips/{id} 删除片段 + +后续子路由(BGM/转场/滤镜/AI/导出等)将逐步迁移。 +""" + +from __future__ import annotations + +import logging +from typing import Any, List, Optional + +from app.auth import AuthenticatedUser, get_current_user +from app.dependencies import get_db_session +from app.services.edit_plan_service import EditPlanService +from app.services.edit_template_service import EditTemplateService +from fastapi import APIRouter, Depends, HTTPException, Query, status +from pydantic import BaseModel, Field +from sqlalchemy.orm import Session + +logger = logging.getLogger(__name__) + +router = APIRouter(tags=["Template Editor"]) + + +# ── Request / Response Schemas ────────────────────────────────────────────── + + +class EditorDraftResponse(BaseModel): + """模板编辑器草稿详情响应""" + + plan_id: str + template_id: str + name: str + status: str + config: dict[str, Any] + total_duration: float + clip_count: int + is_draft: bool = True + + +class EditorUpdateRequest(BaseModel): + """更新草稿请求""" + + name: Optional[str] = Field(default=None, min_length=1, max_length=200) + config: Optional[dict[str, Any]] = Field(default=None) + total_duration: Optional[float] = Field(default=None, ge=0.0) + + +class EditorClipResponse(BaseModel): + """片段响应""" + + id: str + plan_id: str + clip_type: str + order: int + duration: float + text_content: str = "" + transition_effect: str = "cut" + playback_speed: float = 1.0 + config: dict[str, Any] = Field(default_factory=dict) + + +class EditorClipListResponse(BaseModel): + """片段列表响应""" + + items: List[EditorClipResponse] + total: int + + +class EditorClipCreateRequest(BaseModel): + """创建片段请求""" + + clip_type: str = Field(..., min_length=1, max_length=32) + order: int = Field(..., ge=0) + duration: float = Field(..., gt=0.0) + text_content: str = Field(default="", max_length=2000) + transition_effect: str = Field(default="cut", max_length=32) + config: dict[str, Any] = Field(default_factory=dict) + + +class EditorClipUpdateRequest(BaseModel): + """更新片段请求""" + + order: Optional[int] = Field(default=None, ge=0) + duration: Optional[float] = Field(default=None, gt=0.0) + text_content: Optional[str] = Field(default=None, max_length=2000) + transition_effect: Optional[str] = Field(default=None, max_length=32) + playback_speed: Optional[float] = Field(default=None, gt=0.0) + config: Optional[dict[str, Any]] = None + + +class EditorPublishResponse(BaseModel): + """发布草稿响应""" + + template_id: str + status: str = "published" + clip_count: int + + +# ── Dependencies ──────────────────────────────────────────────────────────── + + +def get_editor_services( + db: Session = Depends(get_db_session), +) -> tuple[EditTemplateService, EditPlanService]: + """获取模板编辑器所需的两个服务""" + return EditTemplateService(db), EditPlanService(db) + + +def get_draft_plan_id( + template_id: str, + services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services), + current_user: AuthenticatedUser = Depends(get_current_user), +) -> str: + """ + 路径依赖:根据 template_id 获取或创建草稿,返回 plan_id。 + + 这是模板编辑器路由的核心依赖——所有编辑器端点都先经过这里, + 确保 template_id → plan_id 的映射始终存在。 + """ + tpl_svc, _ = services + draft = tpl_svc.get_or_create_draft( + template_id, + user_id=str(current_user.user_id), + ) + return draft.id + + +# ── 草稿核心端点 ──────────────────────────────────────────────────────────── + + +@router.get("", response_model=EditorDraftResponse) +def get_editor_draft( + template_id: str, + plan_id: str = Depends(get_draft_plan_id), + services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services), + _: AuthenticatedUser = Depends(get_current_user), +): + """获取模板编辑器草稿详情 + + 首次访问时自动创建草稿。 + """ + _, plan_svc = services + plan = plan_svc.get_plan_or_raise(plan_id) + clips = plan_svc.list_clips(plan_id) + return EditorDraftResponse( + plan_id=plan.id, + template_id=plan.template_id, + name=plan.name, + status=plan.status.value if hasattr(plan.status, "value") else str(plan.status), + config=plan.config or {}, + total_duration=plan.total_duration, + clip_count=len(clips), + ) + + +@router.put("", response_model=EditorDraftResponse) +def update_editor_draft( + template_id: str, + req: EditorUpdateRequest, + plan_id: str = Depends(get_draft_plan_id), + services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services), + _: AuthenticatedUser = Depends(get_current_user), +): + """更新模板编辑器草稿""" + _, plan_svc = services + plan = plan_svc.update_plan( + plan_id, + name=req.name, + config=req.config, + total_duration=req.total_duration, + ) + clips = plan_svc.list_clips(plan_id) + return EditorDraftResponse( + plan_id=plan.id, + template_id=plan.template_id, + name=plan.name, + status=plan.status.value if hasattr(plan.status, "value") else str(plan.status), + config=plan.config or {}, + total_duration=plan.total_duration, + clip_count=len(clips), + ) + + +@router.post("/publish", response_model=EditorPublishResponse, status_code=status.HTTP_200_OK) +def publish_draft_to_template( + template_id: str, + plan_id: str = Depends(get_draft_plan_id), + services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services), + _: AuthenticatedUser = Depends(get_current_user), +): + """将草稿发布(同步)到正式模板 + + 草稿的 config 和 clips 会同步覆盖到模板,事务保证一致性。 + """ + tpl_svc, plan_svc = services + try: + tpl = tpl_svc.publish_template_from_draft(template_id, plan_id) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + clips = plan_svc.list_clips(plan_id) + return EditorPublishResponse( + template_id=tpl.id, + status="published", + clip_count=len(clips), + ) + + +# ── 片段管理端点 ──────────────────────────────────────────────────────────── + + +@router.get("/clips", response_model=EditorClipListResponse) +def list_draft_clips( + template_id: str, + plan_id: str = Depends(get_draft_plan_id), + services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services), + skip: int = Query(default=0, ge=0), + limit: int = Query(default=100, ge=1, le=500), + _: AuthenticatedUser = Depends(get_current_user), +): + """获取草稿的片段列表""" + _, plan_svc = services + clips = plan_svc.list_clips(plan_id, skip=skip, limit=limit) + total = plan_svc.count_clips(plan_id) + return EditorClipListResponse( + items=[ + EditorClipResponse( + id=c.id, + plan_id=c.plan_id, + clip_type=c.clip_type.value if hasattr(c.clip_type, "value") else str(c.clip_type), + order=c.order, + duration=c.duration, + text_content=c.text_content or "", + transition_effect=( + c.transition_effect.value if hasattr(c.transition_effect, "value") else str(c.transition_effect) + ), + playback_speed=c.playback_speed or 1.0, + config=c.config or {}, + ) + for c in clips + ], + total=total, + ) + + +@router.post("/clips", response_model=EditorClipResponse, status_code=status.HTTP_201_CREATED) +def create_draft_clip( + template_id: str, + req: EditorClipCreateRequest, + plan_id: str = Depends(get_draft_plan_id), + services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services), + _: AuthenticatedUser = Depends(get_current_user), +): + """在草稿中创建新片段""" + _, plan_svc = services + try: + clip = plan_svc.create_clip( + plan_id, + clip_type=req.clip_type, + order=req.order, + duration=req.duration, + text_content=req.text_content, + transition_effect=req.transition_effect, + config=req.config, + ) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + return EditorClipResponse( + id=clip.id, + plan_id=clip.plan_id, + clip_type=clip.clip_type.value if hasattr(clip.clip_type, "value") else str(clip.clip_type), + order=clip.order, + duration=clip.duration, + text_content=clip.text_content or "", + transition_effect=( + clip.transition_effect.value if hasattr(clip.transition_effect, "value") else str(clip.transition_effect) + ), + playback_speed=clip.playback_speed or 1.0, + config=clip.config or {}, + ) + + +@router.put("/clips/{clip_id}", response_model=EditorClipResponse) +def update_draft_clip( + template_id: str, + clip_id: str, + req: EditorClipUpdateRequest, + plan_id: str = Depends(get_draft_plan_id), + services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services), + _: AuthenticatedUser = Depends(get_current_user), +): + """更新草稿中的片段""" + _, plan_svc = services + try: + clip = plan_svc.update_clip( + clip_id, + order=req.order, + duration=req.duration, + text_content=req.text_content, + transition_effect=req.transition_effect, + playback_speed=req.playback_speed, + config=req.config, + ) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + return EditorClipResponse( + id=clip.id, + plan_id=clip.plan_id, + clip_type=clip.clip_type.value if hasattr(clip.clip_type, "value") else str(clip.clip_type), + order=clip.order, + duration=clip.duration, + text_content=clip.text_content or "", + transition_effect=( + clip.transition_effect.value if hasattr(clip.transition_effect, "value") else str(clip.transition_effect) + ), + playback_speed=clip.playback_speed or 1.0, + config=clip.config or {}, + ) + + +@router.delete("/clips/{clip_id}", status_code=status.HTTP_204_NO_CONTENT) +def delete_draft_clip( + template_id: str, + clip_id: str, + plan_id: str = Depends(get_draft_plan_id), + services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services), + _: AuthenticatedUser = Depends(get_current_user), +): + """删除草稿中的片段""" + _, plan_svc = services + success = plan_svc.delete_clip(clip_id) + if not success: + raise HTTPException(status_code=404, detail="片段不存在") + return None diff --git a/apps/api/app/services/edit_template_service.py b/apps/api/app/services/edit_template_service.py index 7ed7ebea0..40a3b8c8a 100755 --- a/apps/api/app/services/edit_template_service.py +++ b/apps/api/app/services/edit_template_service.py @@ -533,3 +533,249 @@ class EditTemplateService: "template": created_template, "clip_configs": created_configs, } + + # ── 模板草稿(编辑器)相关 ────────────────────────────────────────────────── + + def get_template_draft(self, template_id: str) -> Optional[Any]: + """获取模板的草稿剪辑计划 + + 通过 template_id + config.is_template_draft=True 标记查找。 + 每个模板有且仅有一个草稿计划。 + + Args: + template_id: 模板 ID + + Returns: + EditPlan | None: 草稿剪辑计划,不存在则返回 None + """ + from packages.domain.edit_plan import EditPlan # noqa: F401 + + plans = self._plan_repo.list_by_template(template_id, limit=50) + for plan in plans: + config = plan.config or {} + if config.get("is_template_draft") is True: + return plan + return None + + def create_template_draft( + self, + template_id: str, + user_id: str, + *, + project_id: str = "", + ) -> Any: + """基于模板创建草稿剪辑计划 + + 草稿与普通剪辑计划的区别: + - config.is_template_draft = True + - 不绑定具体素材(空素材列表) + - 用于模板编辑器的编辑上下文 + + Args: + template_id: 模板 ID + user_id: 创建者用户 ID + project_id: 所属项目 ID(可选) + + Returns: + EditPlan: 创建的草稿剪辑计划 + + Raises: + ValueError: 模板不存在,或草稿已存在 + """ + from .plan_generator_service import PlanGeneratorService + + # 检查模板是否存在 + template = self.get_template_or_raise(template_id) + + # 检查草稿是否已存在 + existing = self.get_template_draft(template_id) + if existing is not None: + raise ValueError(f"模板草稿已存在: {template_id}") + + # 读取模板片段配置 + clip_configs = self.list_clip_configs(template_id) + + # 基于模板生成计划(空素材) + generator = PlanGeneratorService(self._db) + result = generator.generate_from_template( + template=template, + clip_configs=clip_configs, + asset_ids=[], + project_id=project_id, + created_by_user_id=user_id, + name=f"{template.name} - 草稿", + ) + plan = result["plan"] + + # 标记为模板草稿 + plan_config = plan.config or {} + plan_config["is_template_draft"] = True + plan.config = plan_config + plan = self._plan_repo.update(plan) + + logger.info( + "创建模板草稿: template_id=%s draft_plan_id=%s user_id=%s", + template_id, + plan.id, + user_id, + ) + return plan + + def get_or_create_draft( + self, + template_id: str, + user_id: str, + *, + project_id: str = "", + ) -> Any: + """获取或创建模板草稿 + + 首次访问模板编辑器时自动创建草稿。 + + Args: + template_id: 模板 ID + user_id: 操作用户 ID + project_id: 所属项目 ID(可选) + + Returns: + EditPlan: 草稿剪辑计划 + """ + draft = self.get_template_draft(template_id) + if draft is not None: + return draft + return self.create_template_draft(template_id, user_id, project_id=project_id) + + def publish_template_from_draft( + self, + template_id: str, + draft_plan_id: str, + ) -> Any: + """将草稿剪辑计划的内容发布(同步)到模板 + + 将草稿的配置和片段结构同步到模板,相当于"保存"编辑结果。 + 使用事务保证一致性,失败则回滚。 + + 同步规则: + - 草稿 plan.config → template.config(过滤掉草稿特有字段) + - 草稿 clips → template_clip_configs(先删后插) + - 草稿 editing_mode → template.editing_mode + - 不更新模板名称、描述等元信息(由专门的接口处理) + + Args: + template_id: 模板 ID + draft_plan_id: 草稿剪辑计划 ID + + Returns: + EditTemplate: 更新后的模板 + + Raises: + ValueError: 模板/草稿不存在,或草稿不属于该模板 + """ + from packages.domain.template_clip_config import TemplateClipConfig + + # 1. 校验模板和草稿 + template = self.get_template_or_raise(template_id) + draft = self._plan_repo.get(draft_plan_id) + if draft is None: + raise ValueError(f"草稿计划不存在: {draft_plan_id}") + if draft.template_id != template_id: + raise ValueError(f"草稿不属于该模板: plan_template_id={draft.template_id}") + config = draft.config or {} + if config.get("is_template_draft") is not True: + raise ValueError("指定的计划不是模板草稿") + + # 2. 读取草稿片段 + draft_clips = self._plan_clip_repo.list_by_plan(draft_plan_id) + draft_clips.sort(key=lambda c: c.order) + + # 3. 提取 editing_mode + editing_mode = config.get("editing_mode", "one_take") + + # 4. 提取模板配置(去掉草稿/运行时字段) + draft_config = draft.config or {} + template_config: dict[str, Any] = {} + skip_keys = { + "is_template_draft", + "asset_ids", + "source_edit_plan_id", + "generation_task_id", + } + for key, value in draft_config.items(): + if key not in skip_keys: + template_config[key] = value + + # 5. 事务更新 + try: + # 更新模板元信息 + template.config = template_config + template.editing_mode = editing_mode + updated_template = self._template_repo.update(template) + + # 删除旧的片段配置 + old_configs = self._clip_config_repo.list_by_template(template_id) + for cfg in old_configs: + self._clip_config_repo.delete(cfg.id) + + # 创建新的片段配置 + created_configs: list[TemplateClipConfig] = [] + for clip in draft_clips: + clip_config: dict[str, Any] = {} + # 播放速度存入 config + if clip.playback_speed and clip.playback_speed != 1.0: + clip_config["playback_speed"] = clip.playback_speed + # 片段自有 config 合并 + if clip.config: + clip_config.update(clip.config) + # 去掉素材相关字段 + clip_config.pop("asset_info", None) + clip_config.pop("source_asset_id", None) + + # 转场效果兼容校验 + try: + from packages.domain.template_clip_config import ( + TransitionEffect, + ) + + transition = TransitionEffect(clip.transition_effect) + except (ValueError, ImportError): + transition = TransitionEffect.CUT # type: ignore + + # 片段类型兼容校验 + try: + from packages.domain.template_clip_config import ClipType + + clip_type = ClipType(clip.clip_type) + except (ValueError, ImportError): + clip_type = ClipType.MAIN # type: ignore + + config_obj = TemplateClipConfig.create( + template_id=template_id, + clip_type=clip_type, + order=clip.order, + min_duration=clip.duration, + max_duration=clip.duration, + text_template=clip.text_content or "", + transition_effect=transition, + config=clip_config, + ) + created = self._clip_config_repo.create(config_obj) + created_configs.append(created) + + self._db.commit() + logger.info( + "发布模板草稿: template_id=%s draft_plan_id=%s clip_count=%d", + template_id, + draft_plan_id, + len(created_configs), + ) + return updated_template + + except Exception as exc: + self._db.rollback() + logger.error( + "发布模板草稿失败: template_id=%s draft_plan_id=%s error=%s", + template_id, + draft_plan_id, + exc, + ) + raise diff --git a/tests/unit/test_edit_template_service.py b/tests/unit/test_edit_template_service.py index 41eb14f90..5a1d30f2d 100755 --- a/tests/unit/test_edit_template_service.py +++ b/tests/unit/test_edit_template_service.py @@ -490,6 +490,24 @@ class StubEditPlanRepository: self._plans[plan.id] = plan return plan + def list_by_template( + self, + template_id: str, + *, + status: Optional[str] = None, + skip: int = 0, + limit: int = 50, + ) -> List[EditPlan]: + items = [p for p in self._plans.values() if p.template_id == template_id] + if status: + items = [p for p in items if p.status.value == status] + items.sort(key=lambda p: p.created_at, reverse=True) + return items[skip : skip + limit] + + def update(self, plan: EditPlan) -> EditPlan: + self._plans[plan.id] = plan + return plan + class StubEditPlanClipRepository: """内存中的 EditPlanClip 仓储 stub""" @@ -751,3 +769,254 @@ class TestSavePlanAsTemplate: result = svc.save_plan_as_template(plan.id, name="类型兼容模板") assert result["clip_configs"][0].clip_type == ClipType.MAIN + + +class TestTemplateDraft: + """模板草稿相关功能测试""" + + def test_get_template_draft_not_found(self): + """没有草稿时返回None""" + svc = _make_service_with_plan_stubs() + # 先创建一个模板 + tpl = svc.create_template(name="测试模板", editing_mode="one_take") + result = svc.get_template_draft(tpl.id) + assert result is None + + def test_get_template_draft_found(self): + """能正确找到标记了is_template_draft的草稿""" + svc = _make_service_with_plan_stubs() + from packages.domain.edit_plan import EditPlan + + tpl = svc.create_template(name="测试模板", editing_mode="one_take") + + # 普通计划(不是草稿) + normal_plan = EditPlan.create(template_id=tpl.id, name="普通计划", config={"key": "value"}) + normal_plan.id = "plan-normal" + svc._plan_repo.create(normal_plan) + + # 草稿计划 + draft_plan = EditPlan.create( + template_id=tpl.id, + name="草稿计划", + config={"is_template_draft": True, "other": "data"}, + ) + draft_plan.id = "plan-draft" + svc._plan_repo.create(draft_plan) + + result = svc.get_template_draft(tpl.id) + assert result is not None + assert result.id == "plan-draft" + assert result.config["is_template_draft"] is True + + def test_create_template_draft_success(self, monkeypatch): + """创建草稿成功,标记is_template_draft=True""" + svc = _make_service_with_plan_stubs() + from packages.domain.edit_plan import EditPlan + + tpl = svc.create_template(name="草稿测试模板", editing_mode="one_take") + + # mock PlanGeneratorService.generate_from_template + called_with = {} + + def fake_generate(self, template, clip_configs, asset_ids, **kwargs): + called_with["template"] = template + called_with["clip_configs"] = clip_configs + called_with["asset_ids"] = asset_ids + called_with["kwargs"] = kwargs + plan = EditPlan.create( + template_id=template.id, + name=kwargs.get("name", "测试草稿"), + config={"editing_mode": "one_take"}, + ) + plan.id = "plan-new-draft" + svc._plan_repo.create(plan) + return {"plan": plan, "clips": []} + + from app.services import plan_generator_service + + monkeypatch.setattr( + plan_generator_service.PlanGeneratorService, + "generate_from_template", + fake_generate, + ) + + draft = svc.create_template_draft(tpl.id, "user-001") + + assert draft is not None + assert draft.id == "plan-new-draft" + assert draft.config.get("is_template_draft") is True + assert called_with["asset_ids"] == [] + assert called_with["kwargs"]["created_by_user_id"] == "user-001" + + def test_create_template_draft_duplicate_raises(self): + """草稿已存在时抛ValueError""" + svc = _make_service_with_plan_stubs() + from packages.domain.edit_plan import EditPlan + + tpl = svc.create_template(name="重复草稿模板", editing_mode="one_take") + + # 预先创建一个草稿 + draft = EditPlan.create( + template_id=tpl.id, + name="已存在草稿", + config={"is_template_draft": True}, + ) + draft.id = "plan-existing" + svc._plan_repo.create(draft) + + import pytest + + with pytest.raises(ValueError, match="草稿已存在"): + svc.create_template_draft(tpl.id, "user-001") + + def test_get_or_create_draft_creates_when_missing(self, monkeypatch): + """草稿不存在时自动创建""" + svc = _make_service_with_plan_stubs() + from packages.domain.edit_plan import EditPlan + + tpl = svc.create_template(name="自动创建模板", editing_mode="one_take") + + def fake_generate(self, template, clip_configs, asset_ids, **kwargs): + plan = EditPlan.create( + template_id=template.id, + name="自动创建草稿", + config={"editing_mode": "one_take"}, + ) + plan.id = "plan-auto" + svc._plan_repo.create(plan) + return {"plan": plan, "clips": []} + + from app.services import plan_generator_service + + monkeypatch.setattr( + plan_generator_service.PlanGeneratorService, + "generate_from_template", + fake_generate, + ) + + # 第一次调用:创建 + draft1 = svc.get_or_create_draft(tpl.id, "user-001") + assert draft1 is not None + assert draft1.config.get("is_template_draft") is True + + # 第二次调用:返回已存在的 + draft2 = svc.get_or_create_draft(tpl.id, "user-001") + assert draft2.id == draft1.id + + def test_publish_template_from_draft_success(self): + """草稿发布成功,同步config和clips到模板""" + svc = _make_service_with_plan_stubs() + from packages.domain.edit_plan import EditPlan + from packages.domain.edit_plan_clip import EditPlanClip + + # 创建模板和初始片段配置 + tpl = svc.create_template( + name="发布测试模板", + editing_mode="one_take", + config={"original": "value"}, + ) + svc.create_clip_config( + tpl.id, + clip_type="main", + order=0, + min_duration=5.0, + max_duration=5.0, + ) + + # 创建草稿 + draft = EditPlan.create( + template_id=tpl.id, + name="发布草稿", + config={ + "is_template_draft": True, + "editing_mode": "pip", + "bgm": "song.mp3", + "title": "新标题", + }, + ) + draft.id = "plan-publish" + svc._plan_repo.create(draft) + + # 草稿的片段 + clip1 = EditPlanClip.create( + plan_id=draft.id, + clip_type="main", + order=0, + duration=8.0, + text_content="第一段", + transition_effect="fade", + config={"playback_speed": 1.5}, + ) + clip1.id = "clip-pub-1" + svc._plan_clip_repo.create(clip1) + + clip2 = EditPlanClip.create( + plan_id=draft.id, + clip_type="main", + order=1, + duration=12.0, + text_content="第二段", + ) + clip2.id = "clip-pub-2" + svc._plan_clip_repo.create(clip2) + + # 发布 + result = svc.publish_template_from_draft(tpl.id, draft.id) + + assert result is not None + assert result.editing_mode == "pip" + assert result.config.get("original") is None # 旧配置被覆盖 + assert result.config.get("bgm") == "song.mp3" + assert result.config.get("is_template_draft") is None # 草稿标记不带过去 + + # 检查片段配置已更新 + new_configs = svc.list_clip_configs(tpl.id) + assert len(new_configs) == 2 + assert new_configs[0].order == 0 + assert new_configs[0].min_duration == 8.0 + assert new_configs[0].text_template == "第一段" + assert new_configs[0].config.get("playback_speed") == 1.5 + assert new_configs[1].order == 1 + assert new_configs[1].max_duration == 12.0 + + def test_publish_template_wrong_template_raises(self): + """草稿不属于指定模板时抛错""" + svc = _make_service_with_plan_stubs() + from packages.domain.edit_plan import EditPlan + + tpl1 = svc.create_template(name="模板1", editing_mode="one_take") + tpl2 = svc.create_template(name="模板2", editing_mode="one_take") + + # 草稿属于tpl1 + draft = EditPlan.create( + template_id=tpl1.id, + name="草稿", + config={"is_template_draft": True}, + ) + draft.id = "plan-wrong-tpl" + svc._plan_repo.create(draft) + + import pytest + + with pytest.raises(ValueError, match="不属于该模板"): + svc.publish_template_from_draft(tpl2.id, draft.id) + + def test_publish_template_not_draft_raises(self): + """不是草稿的计划不能发布""" + svc = _make_service_with_plan_stubs() + from packages.domain.edit_plan import EditPlan + + tpl = svc.create_template(name="非草稿模板", editing_mode="one_take") + + normal_plan = EditPlan.create( + template_id=tpl.id, + name="普通计划", + config={"is_template_draft": False}, + ) + normal_plan.id = "plan-not-draft" + svc._plan_repo.create(normal_plan) + + import pytest + + with pytest.raises(ValueError, match="不是模板草稿"): + svc.publish_template_from_draft(tpl.id, normal_plan.id)