diff --git a/apps/api/app/api/routes/generation_cover.py b/apps/api/app/api/routes/generation_cover.py index 73130bb73..4b904cfc8 100644 --- a/apps/api/app/api/routes/generation_cover.py +++ b/apps/api/app/api/routes/generation_cover.py @@ -65,6 +65,45 @@ class GenerateCoverResponse(BaseModel): # ── Route ──────────────────────────────────────────────────────────────── + +def _persist_cover_frame(frame_url: str, plan_id: str) -> str: + """下载 MediaKit 返回的临时帧图并转存到 OSS covers/ 路径。""" + import tempfile + import uuid + from pathlib import Path + + tmp_path: str | None = None + try: + import httpx + + resp = httpx.get(frame_url, timeout=30, follow_redirects=True) + resp.raise_for_status() + if not resp.content: + return frame_url + + with tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) as tmp: + tmp.write(resp.content) + tmp_path = tmp.name + + from packages.shared.storage import get_shared_storage_service + + storage = get_shared_storage_service() + cover_key = f"covers/{plan_id}/cover_{uuid.uuid4().hex[:8]}.jpg" + storage.upload_file( + file_or_path=tmp_path, + storage_key=cover_key, + content_type="image/jpeg", + ) + public_url = storage.get_url(cover_key) + return public_url or frame_url + except Exception: + logger.warning("封面帧转存失败,返回原始 URL: plan_id=%s", plan_id, exc_info=True) + return frame_url + finally: + if tmp_path: + Path(tmp_path).unlink(missing_ok=True) + + @router.post("/generate-cover", response_model=GenerateCoverResponse) def generate_cover( body: GenerateCoverRequest, @@ -198,44 +237,30 @@ def generate_cover( exc_info=True, ) - # 仍然找不到才报 400 - if not rendered_storage_key: - logger.error("[封面生成] ❌ 找不到预览视频: plan_id=%s", plan_id) - raise HTTPException( - status_code=400, - detail="请先生成预览视频,再生成封面", - ) - - # 回写到 plan.config - plan_svc.update_plan_config(plan_id, {"rendered_storage_key": rendered_storage_key}) - - # 使用裸 URL(rendered/* 已配置公开读) + # 使用裸 URL(rendered/* 已配置公开读);找不到渲染视频时不立即报错, + # 因为步骤 E 可以直接从源素材抽帧(前端纯 Canvas/WebCodecs 预览无后端渲染产物) 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 + if rendered_storage_key: + plan_svc.update_plan_config(plan_id, {"rendered_storage_key": rendered_storage_key}) + 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) - # 防御性规范化:合并路径中的双斜杠(// -> /),但保留协议头的 :// - # 历史数据中 project_id 为空时会产生 projects//tasks/ 路径, - # MediaKit 的 HTTP 客户端会规范化 URL 导致 404 - if primary_video_url: - import re as _re - - primary_video_url = _re.sub(r"(?