diff --git a/apps/api/app/api/routes/templates_editor.py b/apps/api/app/api/routes/templates_editor.py index 60f49c0be..d7c9848f0 100755 --- a/apps/api/app/api/routes/templates_editor.py +++ b/apps/api/app/api/routes/templates_editor.py @@ -26,11 +26,39 @@ import logging from typing import Any, List, Optional from app.api.routes.edit_plans import ( + AIRecommendRequest, + AIRecommendResponse, BGMConfigUpdateRequest, ClipStatusItem, EditPlanGenerateResponse, EditPlanGenerationsResponse, EditPlanGenerationStatusResponse, + GenerateCoverRequest, + GenerateCoverResponse, +) +from app.api.routes.edit_plans_adjustments import ( + BatchSpeedRequest, + BatchSpeedResponse, + ClipAdjustmentsRequest, + ClipAdjustResponse, + SpeedAdjustRequest, + TrimAdjustRequest, + VolumeAdjustRequest, +) +from app.api.routes.edit_plans_clips_batch import ( + ClipBatchDeleteRequest, + ClipBatchDeleteResponse, + ClipReorderRequest, + ClipReorderResponse, + ClipsFromAssetsRequest, + ClipsFromAssetsResponse, +) +from app.api.routes.edit_plans_cover import ( + CoverConfigResponse, + CoverExtractRequest, + CoverGenerateResponse, + CoverSmartRequest, + CoverUpdateRequest, ) from app.api.routes.edit_plans_export import ( ExportConfigResponse, @@ -400,6 +428,119 @@ def delete_draft_clip( return None +@router.get("/clips/{clip_id}", response_model=EditorClipResponse) +def get_draft_clip_detail( + 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 + clip = plan_svc.get_clip(clip_id) + if clip is None: + raise HTTPException(status_code=404, detail="片段不存在") + if clip.plan_id != plan_id: + raise HTTPException(status_code=404, detail="片段不存在") + 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 {}, + ) + + +# ── 片段分割与合并 ────────────────────────────────────────────────────────── + + +class SplitClipRequest(BaseModel): + """分割片段请求体""" + + split_time: float = Field(..., gt=0, description="分割点(秒,相对于片段起始)") + + +class MergeClipsRequest(BaseModel): + """合并片段请求体""" + + clip_ids: list[str] = Field(..., min_length=2, description="要合并的片段 ID 列表") + + +@router.post("/clips/{clip_id}/split", response_model=dict[str, Any], status_code=status.HTTP_200_OK) +def split_draft_clip( + template_id: str, + clip_id: str, + body: SplitClipRequest, + plan_id: str = Depends(get_draft_plan_id), + services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services), + _: AuthenticatedUser = Depends(get_current_user), +): + """将一个片段从指定时间点分割为两个片段""" + _, plan_svc = services + clip = plan_svc.get_clip(clip_id) + if clip is None or clip.plan_id != plan_id: + raise HTTPException(status_code=404, detail="片段不存在") + try: + result = plan_svc.split_clip(clip_id, body.split_time) + except ValueError as exc: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc + left = result["left_clip"] + right = result["right_clip"] + return { + "left_clip": { + "id": left.id, + "plan_id": left.plan_id, + "clip_type": left.clip_type, + "order": left.order, + "duration": left.duration, + "start_time": left.start_time, + }, + "right_clip": { + "id": right.id, + "plan_id": right.plan_id, + "clip_type": right.clip_type, + "order": right.order, + "duration": right.duration, + "start_time": right.start_time, + }, + } + + +@router.post("/clips/merge", response_model=dict[str, Any], status_code=status.HTTP_200_OK) +def merge_draft_clips( + template_id: str, + body: MergeClipsRequest, + plan_id: str = Depends(get_draft_plan_id), + services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services), + _: AuthenticatedUser = Depends(get_current_user), +): + """将多个连续的同类型片段合并为一个片段""" + _, plan_svc = services + for cid in body.clip_ids: + clip = plan_svc.get_clip(cid) + if clip is None or clip.plan_id != plan_id: + raise HTTPException(status_code=404, detail=f"片段不存在: {cid}") + try: + merged = plan_svc.merge_clips(body.clip_ids) + except ValueError as exc: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc + return { + "id": merged.id, + "plan_id": merged.plan_id, + "clip_type": merged.clip_type, + "order": merged.order, + "duration": merged.duration, + "text_content": merged.text_content, + } + + # ── BGM 模块 ──────────────────────────────────────────────────────────────────── @@ -1186,3 +1327,679 @@ def update_editor_export( format=current_export.get("format", "mp4"), preset_id=current_export.get("preset_id", ""), ) + + +# ── AI 推荐 & 封面生成 ──────────────────────────────────────────────────────── + + +@router.post("/ai-recommend", response_model=AIRecommendResponse) +def editor_ai_recommend( + template_id: str, + body: AIRecommendRequest, + plan_id: str = Depends(get_draft_plan_id), + services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services), + db: Session = Depends(get_db_session), + current_user: AuthenticatedUser = Depends(get_current_user), +) -> AIRecommendResponse: + """AI 推荐片段方案""" + _, plan_svc = services + plan = plan_svc.get_plan_or_raise(plan_id) + + plan_status = plan.status.value if hasattr(plan.status, "value") else plan.status + if plan_status not in ("draft", "editing"): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="当前草稿状态不支持AI推荐,请先编辑后再试", + ) + + from apps.worker.worker_app.tasks.ai_tasks import run_ai_recommend + + result = run_ai_recommend( + plan_id=plan_id, + template_id=plan.template_id, + asset_ids=body.asset_ids, + editing_mode=body.editing_mode, + target_duration=body.target_duration, + ) + + try: + plan_svc.delete_all_clips(plan_id) + + for clip_data in result["clips"]: + plan_svc.create_clip( + plan_id=plan_id, + clip_type=clip_data["clip_type"], + order=clip_data["order"], + text_content=clip_data.get("text_content", ""), + duration=clip_data["duration"], + transition_effect=clip_data.get("transition_effect", "cut"), + asset_id=clip_data.get("asset_id", ""), + start_time=clip_data.get("start_time", 0.0), + config=clip_data.get("config", {}), + ) + + normalized_config = normalize_plan_config(result.get("config", {})) + plan_svc.update_plan( + plan_id, + config=normalized_config, + total_duration=result["total_duration"], + ) + except Exception as _e: + logger.exception("模板编辑器AI推荐写入失败: template_id=%s plan_id=%s", template_id, plan_id) + try: + db.rollback() + except Exception: + pass + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="AI推荐结果保存失败,请稍后重试", + ) from _e + + logger.info( + "模板编辑器AI推荐: template_id=%s plan_id=%s clips=%d duration=%.1f by user=%s", + template_id, + plan_id, + len(result["clips"]), + result["total_duration"], + current_user.user_id, + ) + + return AIRecommendResponse( + plan_id=plan_id, + clips=[ + { + "clip_type": c["clip_type"], + "order": c["order"], + "text_content": c.get("text_content", ""), + "duration": c["duration"], + "transition_effect": c.get("transition_effect", "cut"), + "asset_id": c.get("asset_id", ""), + "start_time": c.get("start_time", 0.0), + "config": c.get("config", {}), + } + for c in result["clips"] + ], + config=normalized_config, + total_duration=result["total_duration"], + confidence=result["confidence"], + ) + + +@router.post("/generate-cover", response_model=GenerateCoverResponse) +def editor_generate_cover( + template_id: str, + body: GenerateCoverRequest, + plan_id: str = Depends(get_draft_plan_id), + services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services), + current_user: AuthenticatedUser = Depends(get_current_user), +) -> GenerateCoverResponse: + """AI 生成封面""" + _, plan_svc = services + plan = plan_svc.get_plan_or_raise(plan_id) + + from apps.worker.worker_app.tasks.ai_tasks import run_generate_cover + + cover_data = run_generate_cover( + plan_id=plan_id, + asset_ids=body.asset_ids, + cover_type=body.cover_type, + frame_time=body.frame_time, + ) + + current_config = dict(plan.config) if plan.config else {} + current_config["cover"] = cover_data + normalized = normalize_plan_config(current_config) + plan_svc.update_plan_config(plan_id, {"cover": normalized["cover"]}) + + logger.info( + "模板编辑器封面生成: template_id=%s plan_id=%s type=%s by user=%s", + template_id, + plan_id, + body.cover_type, + current_user.user_id, + ) + + return GenerateCoverResponse(plan_id=plan_id, cover=cover_data) + + +# ── 字幕模块 ──────────────────────────────────────────────────────────────── + + +@router.get("/clips/{clip_id}/subtitles", response_model=list[dict[str, Any]]) +def get_editor_clip_subtitles( + 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), +) -> list[dict[str, Any]]: + """获取片段的字幕列表""" + _, plan_svc = services + clip = plan_svc.get_clip(clip_id) + if not clip: + raise HTTPException(status_code=404, detail="片段不存在") + config = clip.config or {} + subtitles = config.get("subtitles", []) + if not isinstance(subtitles, list): + subtitles = [] + return subtitles + + +@router.post("/clips/{clip_id}/subtitles", response_model=dict[str, Any]) +def create_editor_clip_subtitle( + template_id: str, + clip_id: str, + body: dict[str, Any], + plan_id: str = Depends(get_draft_plan_id), + services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services), + _: AuthenticatedUser = Depends(get_current_user), +) -> dict[str, Any]: + """新增片段字幕""" + _, plan_svc = services + clip = plan_svc.get_clip(clip_id) + if not clip: + raise HTTPException(status_code=404, detail="片段不存在") + + config = dict(clip.config) if clip.config else {} + subtitles = config.get("subtitles", []) + if not isinstance(subtitles, list): + subtitles = [] + + new_id = f"sub_{len(subtitles) + 1}" + new_subtitle = { + "id": body.get("id", new_id), + "start_time": body.get("start_time", 0.0), + "end_time": body.get("end_time", 0.0), + "text": body.get("text", ""), + "style": body.get("style", {}), + } + subtitles.append(new_subtitle) + config["subtitles"] = subtitles + + plan_svc.update_clip(clip_id, config=config) + return new_subtitle + + +@router.put("/clips/{clip_id}/subtitles/{subtitle_id}", response_model=dict[str, Any]) +def update_editor_clip_subtitle( + template_id: str, + clip_id: str, + subtitle_id: str, + body: dict[str, Any], + plan_id: str = Depends(get_draft_plan_id), + services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services), + _: AuthenticatedUser = Depends(get_current_user), +) -> dict[str, Any]: + """更新片段字幕""" + _, plan_svc = services + clip = plan_svc.get_clip(clip_id) + if not clip: + raise HTTPException(status_code=404, detail="片段不存在") + + config = dict(clip.config) if clip.config else {} + subtitles = config.get("subtitles", []) + if not isinstance(subtitles, list): + subtitles = [] + + found = False + for i, sub in enumerate(subtitles): + if sub.get("id") == subtitle_id: + subtitles[i].update(body) + found = True + break + + if not found: + raise HTTPException(status_code=404, detail="字幕不存在") + + config["subtitles"] = subtitles + plan_svc.update_clip(clip_id, config=config) + return subtitles[i] + + +@router.delete("/clips/{clip_id}/subtitles/{subtitle_id}", status_code=status.HTTP_204_NO_CONTENT) +def delete_editor_clip_subtitle( + template_id: str, + clip_id: str, + subtitle_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 + clip = plan_svc.get_clip(clip_id) + if not clip: + raise HTTPException(status_code=404, detail="片段不存在") + + config = dict(clip.config) if clip.config else {} + subtitles = config.get("subtitles", []) + if not isinstance(subtitles, list): + subtitles = [] + + new_subtitles = [s for s in subtitles if s.get("id") != subtitle_id] + if len(new_subtitles) == len(subtitles): + raise HTTPException(status_code=404, detail="字幕不存在") + + config["subtitles"] = new_subtitles + plan_svc.update_clip(clip_id, config=config) + return None + + +@router.put("/clips/{clip_id}/subtitles", response_model=list[dict[str, Any]]) +def batch_update_editor_clip_subtitles( + template_id: str, + clip_id: str, + body: list[dict[str, Any]], + plan_id: str = Depends(get_draft_plan_id), + services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services), + _: AuthenticatedUser = Depends(get_current_user), +) -> list[dict[str, Any]]: + """批量更新片段字幕(全量替换)""" + _, plan_svc = services + clip = plan_svc.get_clip(clip_id) + if not clip: + raise HTTPException(status_code=404, detail="片段不存在") + + config = dict(clip.config) if clip.config else {} + config["subtitles"] = body + plan_svc.update_clip(clip_id, config=config) + return body + + +# ── 片段调整模块 ────────────────────────────────────────────────────────────── + + +def _get_clip_config(clip) -> dict: + config = getattr(clip, "config", {}) or {} + if not isinstance(config, dict): + config = {} + return config + + +def _get_adjust_volume(clip) -> float: + config = _get_clip_config(clip) + return float(config.get("volume", 1.0)) + + +def _get_adjust_trim(clip) -> tuple[float, float]: + config = _get_clip_config(clip) + trim_start = float(config.get("trim_start", 0.0)) + trim_end = float(config.get("trim_end", 0.0)) + return trim_start, trim_end + + +def _build_adjust_response(clip) -> ClipAdjustResponse: + trim_start, trim_end = _get_adjust_trim(clip) + return ClipAdjustResponse( + clip_id=clip.id, + speed=clip.playback_speed, + volume=_get_adjust_volume(clip), + trim_start=trim_start, + trim_end=trim_end, + duration=clip.duration, + ) + + +def _validate_trim(trim_start: float, trim_end: float, total_duration: float) -> None: + if trim_start + trim_end >= total_duration: + raise ValueError(f"裁剪总时长({trim_start + trim_end:.2f}s)不能大于等于片段总时长({total_duration:.2f}s)") + + +@router.put("/clips/{clip_id}/speed", response_model=ClipAdjustResponse) +def adjust_editor_clip_speed( + template_id: str, + clip_id: str, + body: SpeedAdjustRequest, + plan_id: str = Depends(get_draft_plan_id), + services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services), + _: AuthenticatedUser = Depends(get_current_user), +) -> ClipAdjustResponse: + """调整片段播放速度""" + _, plan_svc = services + clip = plan_svc.get_clip(clip_id) + if not clip: + raise HTTPException(status_code=404, detail="片段不存在") + + updated = plan_svc.update_clip(clip_id, playback_speed=body.speed) + return _build_adjust_response(updated) + + +@router.put("/clips/{clip_id}/volume", response_model=ClipAdjustResponse) +def adjust_editor_clip_volume( + template_id: str, + clip_id: str, + body: VolumeAdjustRequest, + plan_id: str = Depends(get_draft_plan_id), + services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services), + _: AuthenticatedUser = Depends(get_current_user), +) -> ClipAdjustResponse: + """调整片段音量""" + _, plan_svc = services + clip = plan_svc.get_clip(clip_id) + if not clip: + raise HTTPException(status_code=404, detail="片段不存在") + + config = dict(_get_clip_config(clip)) + config["volume"] = body.volume + updated = plan_svc.update_clip(clip_id, config=config) + return _build_adjust_response(updated) + + +@router.put("/clips/{clip_id}/trim", response_model=ClipAdjustResponse) +def adjust_editor_clip_trim( + template_id: str, + clip_id: str, + body: TrimAdjustRequest, + plan_id: str = Depends(get_draft_plan_id), + services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services), + _: AuthenticatedUser = Depends(get_current_user), +) -> ClipAdjustResponse: + """裁剪片段(trim in/out)""" + _, plan_svc = services + clip = plan_svc.get_clip(clip_id) + if not clip: + raise HTTPException(status_code=404, detail="片段不存在") + + try: + _validate_trim(body.trim_start, body.trim_end, clip.duration) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) from e + + config = dict(_get_clip_config(clip)) + config["trim_start"] = body.trim_start + config["trim_end"] = body.trim_end + updated = plan_svc.update_clip(clip_id, config=config) + return _build_adjust_response(updated) + + +@router.put("/clips/{clip_id}/adjustments", response_model=ClipAdjustResponse) +def adjust_editor_clip_all( + template_id: str, + clip_id: str, + body: ClipAdjustmentsRequest, + plan_id: str = Depends(get_draft_plan_id), + services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services), + _: AuthenticatedUser = Depends(get_current_user), +) -> ClipAdjustResponse: + """统一调整片段的 speed / volume / trim""" + _, plan_svc = services + clip = plan_svc.get_clip(clip_id) + if not clip: + raise HTTPException(status_code=404, detail="片段不存在") + + update_kwargs: dict[str, Any] = {} + config_updates: dict[str, Any] = {} + + if body.speed is not None: + update_kwargs["playback_speed"] = body.speed + if body.volume is not None: + config_updates["volume"] = body.volume + if body.trim_start is not None: + config_updates["trim_start"] = body.trim_start + if body.trim_end is not None: + config_updates["trim_end"] = body.trim_end + + current_trim_start, current_trim_end = _get_adjust_trim(clip) + new_trim_start = body.trim_start if body.trim_start is not None else current_trim_start + new_trim_end = body.trim_end if body.trim_end is not None else current_trim_end + + if body.trim_start is not None or body.trim_end is not None: + try: + _validate_trim(new_trim_start, new_trim_end, clip.duration) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) from e + + if config_updates: + config = dict(_get_clip_config(clip)) + config.update(config_updates) + update_kwargs["config"] = config + + if not update_kwargs: + return _build_adjust_response(clip) + + updated = plan_svc.update_clip(clip_id, **update_kwargs) + return _build_adjust_response(updated) + + +@router.post("/clips/batch-speed", response_model=BatchSpeedResponse) +def batch_adjust_editor_speed( + template_id: str, + body: BatchSpeedRequest, + plan_id: str = Depends(get_draft_plan_id), + services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services), + _: AuthenticatedUser = Depends(get_current_user), +) -> BatchSpeedResponse: + """批量调整草稿内所有片段的播放速度""" + _, plan_svc = services + clips = plan_svc.list_clips(plan_id, limit=500, skip=0) + count = 0 + for clip in clips: + plan_svc.update_clip(clip.id, playback_speed=body.speed) + count += 1 + + return BatchSpeedResponse(updated_count=count, plan_id=plan_id) + + +# ── 封面管理模块 ────────────────────────────────────────────────────────────── + + +@router.get("/cover", response_model=CoverConfigResponse) +def get_editor_cover( + template_id: str, + plan_id: str = Depends(get_draft_plan_id), + services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services), + _: AuthenticatedUser = Depends(get_current_user), +) -> CoverConfigResponse: + """获取草稿封面配置""" + _, plan_svc = services + plan = plan_svc.get_plan_or_raise(plan_id) + config = plan.config or {} + cover_config = config.get("cover", {}) + + return CoverConfigResponse( + plan_id=plan.id, + cover_type=cover_config.get("cover_type", "auto"), + cover_image_url=cover_config.get("cover_image_url", ""), + clip_id=cover_config.get("clip_id", ""), + frame_time=cover_config.get("frame_time", 0.0), + ) + + +@router.put("/cover", response_model=CoverConfigResponse) +def update_editor_cover( + template_id: str, + body: CoverUpdateRequest, + plan_id: str = Depends(get_draft_plan_id), + services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services), + _: AuthenticatedUser = Depends(get_current_user), +) -> CoverConfigResponse: + """更新草稿封面配置""" + _, plan_svc = services + plan = plan_svc.get_plan_or_raise(plan_id) + + config = dict(plan.config) if plan.config else {} + current_cover = dict(config.get("cover", {})) + update_data = body.model_dump(exclude_none=True) + current_cover.update(update_data) + + config["cover"] = current_cover + normalized = normalize_plan_config(config) + plan_svc.update_plan_config(plan_id, {"cover": normalized["cover"]}) + + return CoverConfigResponse( + plan_id=plan.id, + cover_type=current_cover.get("cover_type", "auto"), + cover_image_url=current_cover.get("cover_image_url", ""), + clip_id=current_cover.get("clip_id", ""), + frame_time=current_cover.get("frame_time", 0.0), + ) + + +@router.post("/cover/extract", response_model=CoverGenerateResponse) +def extract_editor_cover( + template_id: str, + body: CoverExtractRequest, + plan_id: str = Depends(get_draft_plan_id), + services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services), + current_user: AuthenticatedUser = Depends(get_current_user), +) -> CoverGenerateResponse: + """从指定片段抽帧生成封面""" + _, plan_svc = services + plan = plan_svc.get_plan_or_raise(plan_id) + + clip = plan_svc.get_clip(body.clip_id) + if not clip or clip.plan_id != plan_id: + raise HTTPException(status_code=400, detail="片段不存在或不属于当前草稿") + + cover_url = f"cover/extract/{plan_id}_{body.clip_id}_{body.frame_time}.jpg" + + config = dict(plan.config) if plan.config else {} + cover_config = dict(config.get("cover", {})) + cover_config.update( + { + "cover_type": "extract", + "cover_image_url": cover_url, + "clip_id": body.clip_id, + "frame_time": body.frame_time, + } + ) + config["cover"] = cover_config + normalized = normalize_plan_config(config) + plan_svc.update_plan_config(plan_id, {"cover": normalized["cover"]}) + + logger.info( + "模板编辑器封面抽帧: template_id=%s plan_id=%s clip_id=%s by user=%s", + template_id, + plan_id, + body.clip_id, + current_user.user_id, + ) + + return CoverGenerateResponse( + plan_id=plan_id, + cover_url=cover_url, + cover_type="extract", + ) + + +@router.post("/cover/smart", response_model=CoverGenerateResponse) +def smart_editor_cover( + template_id: str, + body: CoverSmartRequest, + plan_id: str = Depends(get_draft_plan_id), + services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services), + current_user: AuthenticatedUser = Depends(get_current_user), +) -> CoverGenerateResponse: + """智能选帧生成封面""" + _, plan_svc = services + plan = plan_svc.get_plan_or_raise(plan_id) + + cover_url = f"cover/smart/{plan_id}_smart.jpg" + + config = dict(plan.config) if plan.config else {} + cover_config = dict(config.get("cover", {})) + cover_config.update( + { + "cover_type": "smart", + "cover_image_url": cover_url, + "strategy": body.strategy, + } + ) + config["cover"] = cover_config + normalized = normalize_plan_config(config) + plan_svc.update_plan_config(plan_id, {"cover": normalized["cover"]}) + + logger.info( + "模板编辑器智能封面: template_id=%s plan_id=%s strategy=%s by user=%s", + template_id, + plan_id, + body.strategy, + current_user.user_id, + ) + + return CoverGenerateResponse( + plan_id=plan_id, + cover_url=cover_url, + cover_type="smart", + ) + + +# ── 片段批量操作模块 ──────────────────────────────────────────────────────── + + +@router.post("/clips/reorder", response_model=ClipReorderResponse) +def reorder_editor_clips( + template_id: str, + body: ClipReorderRequest, + plan_id: str = Depends(get_draft_plan_id), + services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services), + _: AuthenticatedUser = Depends(get_current_user), +) -> ClipReorderResponse: + """批量重排片段顺序""" + _, plan_svc = services + count = 0 + for item in body.items: + try: + plan_svc.update_clip(item.clip_id, order=item.order) + count += 1 + except ValueError: + pass + + return ClipReorderResponse(updated_count=count, plan_id=plan_id) + + +@router.post("/clips/batch-delete", response_model=ClipBatchDeleteResponse) +def batch_delete_editor_clips( + template_id: str, + body: ClipBatchDeleteRequest, + plan_id: str = Depends(get_draft_plan_id), + services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services), + _: AuthenticatedUser = Depends(get_current_user), +) -> ClipBatchDeleteResponse: + """批量删除片段""" + _, plan_svc = services + deleted = 0 + for clip_id in body.clip_ids: + if plan_svc.delete_clip(clip_id): + deleted += 1 + + return ClipBatchDeleteResponse(deleted_count=deleted, plan_id=plan_id) + + +@router.post("/clips/from-assets", response_model=ClipsFromAssetsResponse) +def create_clips_from_assets_editor( + template_id: str, + body: ClipsFromAssetsRequest, + plan_id: str = Depends(get_draft_plan_id), + services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services), + current_user: AuthenticatedUser = Depends(get_current_user), +) -> ClipsFromAssetsResponse: + """从素材批量创建片段""" + _, plan_svc = services + clips = [] + for i, asset_id in enumerate(body.asset_ids): + try: + clip = plan_svc.create_clip( + plan_id, + clip_type="main", + order=body.start_order + i if hasattr(body, "start_order") else i, + duration=5.0, + asset_id=asset_id, + ) + clips.append(clip) + except ValueError: + pass + + logger.info( + "模板编辑器从素材创建片段: template_id=%s plan_id=%s count=%d by user=%s", + template_id, + plan_id, + len(clips), + current_user.user_id, + ) + + return ClipsFromAssetsResponse( + created_count=len(clips), + plan_id=plan_id, + clip_ids=[c.id for c in clips], + )