diff --git a/apps/api/app/api/routes/templates_editor/cover.py b/apps/api/app/api/routes/templates_editor/cover.py index c05467224..a112942d7 100755 --- a/apps/api/app/api/routes/templates_editor/cover.py +++ b/apps/api/app/api/routes/templates_editor/cover.py @@ -52,11 +52,13 @@ def editor_generate_cover( # ── 3 步查找预览视频 URL ────────────────────────────────────────── # 第一步:从 plan.config 读取 + logger.info("[封面生成] 步骤1: 从 plan.config 查找 rendered_storage_key: plan_id=%s", plan_id) rendered_storage_key = (plan.config or {}).get("rendered_storage_key", "") # 第二步:如果还没有,通过 generation_task_id 查找预览任务的产物 if not rendered_storage_key: generation_task_id = (plan.config or {}).get("generation_task_id", "") + logger.info("[封面生成] 步骤2: 通过 generation_task_id 查找: plan_id=%s task_id=%s", plan_id, generation_task_id) if generation_task_id: try: gen_task_repo = SQLAlchemyGenerationTaskRepository(db) @@ -68,7 +70,12 @@ def editor_generate_cover( if videos: rendered_storage_key = getattr(videos[0], "file_url", "") or "" logger.info( - "封面生成: 通过 generation_task_id 找到视频: plan_id=%s task_id=%s", + "[封面生成] ✅ 步骤2找到视频: plan_id=%s task_id=%s url=%s", + plan_id, + generation_task_id, + rendered_storage_key[:80], + ) + logger.info( plan_id, generation_task_id, ) @@ -83,6 +90,7 @@ def editor_generate_cover( if not rendered_storage_key: try: gen_task_repo = SQLAlchemyGenerationTaskRepository(db) + logger.info("[封面生成] 步骤3: 通过 user+template 查找: plan_id=%s template_id=%s", plan_id, template_id) preview_tasks = gen_task_repo.list_latest_completed_preview( user_id=str(current_user.user.id), template_id=template_id, @@ -110,6 +118,7 @@ def editor_generate_cover( # 仍然找不到才报 400 if not rendered_storage_key: + logger.error("[封面生成] ❌ 找不到预览视频: plan_id=%s", plan_id) raise HTTPException( status_code=400, detail="请先生成预览视频,再生成封面", @@ -142,6 +151,7 @@ def editor_generate_cover( from packages.shared.ai_service import run_generate_cover try: + logger.info("[封面生成] 开始调用 AI 封面生成服务: plan_id=%s", plan_id) cover_data = run_generate_cover( plan_id=plan_id, asset_ids=body.asset_ids, diff --git a/apps/api/app/api/routes/templates_editor/cover.py.bak b/apps/api/app/api/routes/templates_editor/cover.py.bak new file mode 100755 index 000000000..c05467224 --- /dev/null +++ b/apps/api/app/api/routes/templates_editor/cover.py.bak @@ -0,0 +1,168 @@ +"""封面管理路由. + +端点: +- POST /generate-cover AI 生成封面(从预览视频中抽帧) +""" + +from __future__ import annotations + +import logging + +from app.auth import AuthenticatedUser, get_current_user +from app.dependencies import get_db_session, get_generated_video_repository +from app.services.edit_plan_service import EditPlanService +from app.services.edit_template_service import EditTemplateService +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session + +from packages.adapters.sqlalchemy_impl.generation_task_repository import ( + SQLAlchemyGenerationTaskRepository, +) +from packages.application import ListGeneratedVideosByTaskUseCase +from packages.domain.config_schemas import normalize_plan_config + +from .dependencies import get_draft_plan_id, get_editor_services +from .schemas import ( + GenerateCoverRequest, + GenerateCoverResponse, +) + +logger = logging.getLogger(__name__) +router = APIRouter(tags=["Template Editor"]) + + +@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), + db: Session = Depends(get_db_session), + current_user: AuthenticatedUser = Depends(get_current_user), +) -> GenerateCoverResponse: + """AI 生成封面 — 从预览视频中抽帧. + + 流程(串行): + 1. 预览视频已渲染完成(通过 3 步查找获取 URL) + 2. 用裸 URL 让 MediaKit 下载视频并抽帧 + 3. 帧图下载后上传到 OSS covers/ 路径 + """ + _, plan_svc = services + plan = plan_svc.get_plan_or_raise(plan_id) + + # ── 3 步查找预览视频 URL ────────────────────────────────────────── + # 第一步:从 plan.config 读取 + rendered_storage_key = (plan.config or {}).get("rendered_storage_key", "") + + # 第二步:如果还没有,通过 generation_task_id 查找预览任务的产物 + if not rendered_storage_key: + generation_task_id = (plan.config or {}).get("generation_task_id", "") + if generation_task_id: + try: + gen_task_repo = SQLAlchemyGenerationTaskRepository(db) + task = gen_task_repo.get(generation_task_id) + if task: + video_repo = get_generated_video_repository(db) + use_case = ListGeneratedVideosByTaskUseCase(video_repo) + videos = use_case.execute(task.id) + if videos: + rendered_storage_key = getattr(videos[0], "file_url", "") or "" + logger.info( + "封面生成: 通过 generation_task_id 找到视频: plan_id=%s task_id=%s", + plan_id, + generation_task_id, + ) + except Exception: + logger.warning( + "封面生成: 通过 generation_task_id 查找视频失败: plan_id=%s", + plan_id, + exc_info=True, + ) + + # 第三步:按 user + template 查找最近的已完成预览任务(兜底) + if not rendered_storage_key: + try: + gen_task_repo = SQLAlchemyGenerationTaskRepository(db) + preview_tasks = gen_task_repo.list_latest_completed_preview( + user_id=str(current_user.user.id), + template_id=template_id, + ) + if preview_tasks: + completed_preview = preview_tasks[0] + video_repo = get_generated_video_repository(db) + use_case = ListGeneratedVideosByTaskUseCase(video_repo) + videos = use_case.execute(completed_preview.id) + if videos: + rendered_storage_key = getattr(videos[0], "file_url", "") or "" + logger.info( + "封面视频: 通过 user+template 找到预览任务: plan_id=%s template_id=%s task_id=%s", + plan_id, + template_id, + completed_preview.id, + ) + except Exception: + logger.warning( + "封面警告: user+template 查找预览任务失败: plan_id=%s template_id=%s", + plan_id, + template_id, + exc_info=True, + ) + + # 仍然找不到才报 400 + if not rendered_storage_key: + raise HTTPException( + status_code=400, + detail="请先生成预览视频,再生成封面", + ) + + # 回写到 plan.config + plan_svc.update_plan_config(plan_id, {"rendered_storage_key": rendered_storage_key}) + + # 使用裸 URL(rendered/* 已配置公开读) + primary_video_url = None + try: + if rendered_storage_key.startswith("http"): + primary_video_url = rendered_storage_key + else: + from packages.shared.storage import get_shared_storage_service + + storage_svc = get_shared_storage_service() + primary_video_url = storage_svc.get_url(rendered_storage_key) + logger.info( + "获取预览视频URL用于封面生成: plan_id=%s url=%s", + plan_id, + primary_video_url[:80] if primary_video_url else "", + ) + except Exception as e: + raise HTTPException( + status_code=500, + detail=f"获取预览视频URL失败: {e}", + ) from e + + from packages.shared.ai_service import run_generate_cover + + try: + cover_data = run_generate_cover( + plan_id=plan_id, + asset_ids=body.asset_ids, + cover_type=body.cover_type, + frame_time=body.frame_time, + primary_video_url=primary_video_url, + ) + except RuntimeError as e: + raise HTTPException(status_code=500, detail=str(e)) from e + + 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) diff --git a/apps/web/src/pages/generate/hooks/useStep6Cover.ts b/apps/web/src/pages/generate/hooks/useStep6Cover.ts index 8e1d1a262..d530f8409 100644 --- a/apps/web/src/pages/generate/hooks/useStep6Cover.ts +++ b/apps/web/src/pages/generate/hooks/useStep6Cover.ts @@ -106,8 +106,24 @@ export function useStep6Cover({ } } catch (err) { clearTimeout(timeoutId) - console.warn("[Step6] 智能封面生成失败:", err) - message.error("封面生成失败,请稍后重试") + console.error("[Step6] 智能封面生成失败:", err) + + // 提取详细错误信息 + let errorMsg = "封面生成失败" + if (err.response) { + // 后端返回错误 + const detail = err.response.data?.detail || err.response.data?.message || "" + errorMsg = detail || `后端错误 (${err.response.status})` + console.error("[Step6] 后端返回:", err.response.data) + } else if (err.request) { + // 请求已发送但无响应 + errorMsg = "服务器无响应,请检查网络连接" + console.error("[Step6] 请求无响应:", err.request) + } else if (err.message) { + errorMsg = err.message + } + + message.error(errorMsg) } finally { clearTimeout(timeoutId) setGenerating(false)