From 035e589dfc87129695afb14eaf76d779f44933ca Mon Sep 17 00:00:00 2001 From: CI Bot Date: Sat, 22 Aug 2026 22:47:52 +0800 Subject: [PATCH] fix(cover): extract cover from source asset when no backend preview exists The frontend now renders preview entirely in-browser via Canvas/WebCodecs, so there is no backend GenerationTask or rendered video for the cover pipeline to read. Steps A-D all rely on backend-rendered artifacts. - Remove early 400 gate on missing rendered_storage_key for ai_frame - Add step E1: extract a frame from rendered video URL when available but cover_url was not persisted (legacy data) - Add step E2: when no backend preview exists, pick the first video asset from asset_ids and ask MediaKit to extract a frame directly - Add _persist_cover_frame helper to download MediaKit frame and re-upload to OSS covers/ path for a stable public URL - Update final 400 message to reflect the real cause - Add 2 unit tests covering source-asset extraction and non-video skip - Clean stale rendered_storage_key on test plan ec4071eb --- apps/api/app/api/routes/generation_cover.py | 182 ++++++++++++++++---- tests/unit/test_generation_cover.py | 137 ++++++++++++++- 2 files changed, 281 insertions(+), 38 deletions(-) diff --git a/apps/api/app/api/routes/generation_cover.py b/apps/api/app/api/routes/generation_cover.py index 73130bb73..d7f4cc2b9 100644 --- a/apps/api/app/api/routes/generation_cover.py +++ b/apps/api/app/api/routes/generation_cover.py @@ -65,6 +65,42 @@ 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 + + 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) + Path(tmp_path).unlink(missing_ok=True) + return public_url or frame_url + except Exception: + logger.warning("封面帧转存失败,返回原始 URL: plan_id=%s", plan_id, exc_info=True) + return frame_url + + @router.post("/generate-cover", response_model=GenerateCoverResponse) def generate_cover( body: GenerateCoverRequest, @@ -198,44 +234,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"(?