From 035e589dfc87129695afb14eaf76d779f44933ca Mon Sep 17 00:00:00 2001 From: CI Bot Date: Sat, 22 Aug 2026 22:47:52 +0800 Subject: [PATCH 1/3] 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"(? Date: Sat, 22 Aug 2026 14:52:01 +0000 Subject: [PATCH 2/3] style: auto-format with black + isort + prettier [skip ci-format-check] --- tests/unit/test_generation_cover.py | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/tests/unit/test_generation_cover.py b/tests/unit/test_generation_cover.py index e496eedd5..dc6a05747 100644 --- a/tests/unit/test_generation_cover.py +++ b/tests/unit/test_generation_cover.py @@ -870,9 +870,7 @@ class TestUploadCoverType: mock_mk = MagicMock() mock_mk.is_available = True - mock_mk.extract_frames.return_value = [ - {"image_url": "https://mediakit.internal/frame-abc.jpg"} - ] + mock_mk.extract_frames.return_value = [{"image_url": "https://mediakit.internal/frame-abc.jpg"}] mock_storage = MagicMock() mock_storage.get_url.return_value = "https://oss.example.com/uploads/source-clip.mp4" @@ -884,10 +882,16 @@ class TestUploadCoverType: with ( patch("app.api.routes.generation_cover.SQLAlchemyGenerationTaskRepository") as mock_repo_cls, - patch("packages.adapters.sqlalchemy_impl.asset_repository.SQLAlchemyAssetRepository", return_value=mock_asset_repo), + patch( + "packages.adapters.sqlalchemy_impl.asset_repository.SQLAlchemyAssetRepository", + return_value=mock_asset_repo, + ), patch("packages.shared.mediakit_client.get_mediakit_client", return_value=mock_mk), patch("packages.shared.storage.get_shared_storage_service", return_value=mock_storage), - patch("app.api.routes.generation_cover._persist_cover_frame", return_value="https://oss.example.com/covers/final.jpg"), + patch( + "app.api.routes.generation_cover._persist_cover_frame", + return_value="https://oss.example.com/covers/final.jpg", + ), patch("app.api.routes.generation_cover.normalize_plan_config") as mock_normalize, ): mock_repo = MagicMock() @@ -948,7 +952,10 @@ class TestUploadCoverType: with ( patch("app.api.routes.generation_cover.SQLAlchemyGenerationTaskRepository") as mock_repo_cls, - patch("packages.adapters.sqlalchemy_impl.asset_repository.SQLAlchemyAssetRepository", return_value=mock_asset_repo), + patch( + "packages.adapters.sqlalchemy_impl.asset_repository.SQLAlchemyAssetRepository", + return_value=mock_asset_repo, + ), patch("packages.shared.mediakit_client.get_mediakit_client", return_value=mock_mk), patch("packages.shared.storage.get_shared_storage_service") as mock_storage_getter, ): -- 2.54.0 From 93540f904c51c60efefe0f98cb8b0064317a8e54 Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Sat, 22 Aug 2026 23:06:08 +0800 Subject: [PATCH 3/3] fix(cover): address AI review - temp file cleanup + per-asset error handling - _persist_cover_frame: use try/finally to ensure temp file is always cleaned up - Step E2: move try/except inside loop so single asset failure doesn't block others --- apps/api/app/api/routes/generation_cover.py | 40 +++++++++++---------- 1 file changed, 22 insertions(+), 18 deletions(-) diff --git a/apps/api/app/api/routes/generation_cover.py b/apps/api/app/api/routes/generation_cover.py index d7f4cc2b9..4b904cfc8 100644 --- a/apps/api/app/api/routes/generation_cover.py +++ b/apps/api/app/api/routes/generation_cover.py @@ -72,6 +72,7 @@ def _persist_cover_frame(frame_url: str, plan_id: str) -> str: import uuid from pathlib import Path + tmp_path: str | None = None try: import httpx @@ -94,11 +95,13 @@ def _persist_cover_frame(frame_url: str, plan_id: str) -> str: 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 + finally: + if tmp_path: + Path(tmp_path).unlink(missing_ok=True) @router.post("/generate-cover", response_model=GenerateCoverResponse) @@ -385,18 +388,18 @@ def generate_cover( # 步骤 E2:前端纯 Canvas/WebCodecs 预览(无后端渲染任务)时, # 直接从用户选择的第一个视频素材中抽取封面帧。 if not cover_url_from_task and body.asset_ids: - try: - from packages.adapters.sqlalchemy_impl.asset_repository import ( - SQLAlchemyAssetRepository, - ) - from packages.shared.mediakit_client import get_mediakit_client - from packages.shared.storage import get_shared_storage_service + from packages.adapters.sqlalchemy_impl.asset_repository import ( + SQLAlchemyAssetRepository, + ) + from packages.shared.mediakit_client import get_mediakit_client + from packages.shared.storage import get_shared_storage_service - asset_repo = SQLAlchemyAssetRepository(db) - storage_svc = get_shared_storage_service() - mk_client = get_mediakit_client() - if mk_client.is_available: - for aid in body.asset_ids: + asset_repo = SQLAlchemyAssetRepository(db) + storage_svc = get_shared_storage_service() + mk_client = get_mediakit_client() + if mk_client.is_available: + for aid in body.asset_ids: + try: asset = asset_repo.get(aid) if not asset or asset.file_type != "video": continue @@ -427,12 +430,13 @@ def generate_cover( cover_url_from_task[:80], ) break - except Exception: - logger.warning( - "[封面生成] 步骤E从素材抽帧失败: plan_id=%s", - plan_id, - exc_info=True, - ) + except Exception: + logger.warning( + "[封面生成] 步骤E从素材抽帧失败: plan_id=%s asset_id=%s", + plan_id, + aid, + exc_info=True, + ) if cover_url_from_task: # 标题已在预览视频渲染时烧录(ASS字幕),封面帧自然包含标题 -- 2.54.0