From e09ad9f4b738220f85a04e9005c55d6cdccdbea8 Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Fri, 28 Aug 2026 22:58:45 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E5=B0=81=E9=9D=A2=E9=80=89=E5=B8=A7?= =?UTF-8?q?=E8=BE=93=E5=85=A5=E6=BA=90=E6=94=B9=E4=B8=BA=E6=9C=80=E7=BB=88?= =?UTF-8?q?=E6=88=90=E7=89=87=EF=BC=8C=E9=A2=84=E8=A7=88=E7=89=87=E6=AE=B5?= =?UTF-8?q?=E4=BD=9C=E4=B8=BA=E5=85=BC=E5=AE=B9=E5=9B=9E=E9=80=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GenerateCoverRequest 新增可选字段 generated_video_id / video_url, 与前端 PR #1533 契约对齐:前端在「选择封面」步骤传入确认生成产出的 最终视频 ID 或 URL,后端据此定位最终成片文件并抽帧 - 视频源查找优先级调整: 0a. 请求体 generated_video_id → GeneratedVideo.file_url 0b. 请求体 video_url → 直接使用 1. plan.config.rendered_storage_key 2. plan.config.generation_task_id 3. source_edit_plan 关联的 is_preview=False 已完成任务(最终成片优先) 4. source_edit_plan 关联的 is_preview=True 预览任务(回退) 5. user+template 最近预览任务(兜底) - cover_url 查找同步调整:优先最终成片任务的 cover_url,再回退预览 - MediaKit 的 strategy/max_frames/轮询/重试/降级逻辑全部不变 - 权限校验:通过 generated_video_id 关联的 generation_task 校验归属 - 新增 5 个单测覆盖:schema 字段、video_id 抽帧、video_url 抽帧、 最终成片优先于预览、权限校验 403 --- apps/api/app/api/routes/generation_cover.py | 322 ++++++++++++++------ tests/unit/test_generation_cover.py | 301 ++++++++++++++++++ 2 files changed, 533 insertions(+), 90 deletions(-) diff --git a/apps/api/app/api/routes/generation_cover.py b/apps/api/app/api/routes/generation_cover.py index ef4400615..b42283c52 100644 --- a/apps/api/app/api/routes/generation_cover.py +++ b/apps/api/app/api/routes/generation_cover.py @@ -1,7 +1,7 @@ """封面生成路由 — Generation 模块. 端点: -- POST /generate-cover AI 生成封面(从预览视频中抽帧) +- POST /generate-cover AI 生成封面(从最终成片视频中抽帧,兼容预览片段回退) 挂载路径: /api/v1/generation/generate-cover """ @@ -51,6 +51,16 @@ class GenerateCoverRequest(BaseModel): default=None, description="上传的封面图片 URL,仅 cover_type=upload 时有效", ) + generated_video_id: Optional[str] = Field( + default=None, + description="确认生成产出的最终视频 ID。传入后封面从该视频文件抽帧," + "而非预览片段。", + ) + video_url: Optional[str] = Field( + default=None, + description="最终视频 URL(兜底)。当 generated_video_id 不可用时," + "直接从此 URL 对应的视频抽帧。", + ) class GenerateCoverResponse(BaseModel): @@ -140,6 +150,41 @@ def _persist_cover_frame( Path(tmp_path).unlink(missing_ok=True) + +def _get_task_video_url(db: Session, task_id: str) -> Optional[str]: + """从 GenerationTask 关联的 GeneratedVideo 中获取视频 storage_key / URL.""" + try: + video_repo = get_generated_video_repository(db) + use_case = ListGeneratedVideosByTaskUseCase(video_repo) + videos = use_case.execute(task_id) + if videos: + return getattr(videos[0], "file_url", "") or "" + except Exception: + logger.warning("[封面生成] 获取任务视频失败: task_id=%s", task_id, exc_info=True) + return None + + +def _resolve_storage_key_to_url(storage_key: str) -> Optional[str]: + """将 storage_key 或完整 URL 转换为可访问的裸 URL。""" + if not storage_key: + return None + try: + if storage_key.startswith("http"): + url = storage_key + else: + from packages.shared.storage import get_shared_storage_service + + storage_svc = get_shared_storage_service() + url = storage_svc.get_url(storage_key) + if url: + import re as _re + url = _re.sub(r"(? GenerateCoverResponse: - """AI 生成封面 — 从预览视频中抽帧. + """AI 生成封面 — 优先从最终成片视频中抽帧,回退到预览片段. 流程(串行): - 1. 预览视频已渲染完成(通过 3 步查找获取 URL) - 2. 用裸 URL 让 MediaKit 下载视频并抽帧 - 3. 帧图下载后上传到 OSS covers/ 路径 + 1. 优先使用前端传入的 generation_task_id 定位最终成片任务, + 或自动查找 plan 关联的已完成最终成片任务(is_preview=False) + 2. 回退:从预览片段获取视频 URL(兼容旧流程) + 3. 用裸 URL 让 MediaKit 下载视频并抽帧 + 4. 帧图下载后上传到 OSS covers/ 路径 + + MediaKit 的调用方式(strategy / max_frames / 轮询 / 重试 / 降级)不变。 """ _, plan_svc = services plan = plan_svc.get_plan_or_raise(plan_id) @@ -182,122 +231,174 @@ def generate_cover( ) return GenerateCoverResponse(plan_id=plan_id, cover=cover_data) - # ── 3 步查找预览视频 URL ────────────────────────────────────────── - # 第一步:从 plan.config 读取 + # ── 查找用于抽帧的视频 URL ──────────────────────────────────────── + # 优先级: + # 0. 请求体显式传入的 generation_task_id(最终成片任务) + # 1. plan.config.rendered_storage_key + # 2. plan.config.generation_task_id 对应的任务 + # 3. source_edit_plan_id 关联的已完成「最终成片」任务(is_preview=False) + # 4. source_edit_plan_id 关联的已完成预览任务(is_preview=True,兼容回退) + # 5. user + template 最近的已完成预览任务(兜底) 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 查找预览任务的产物 + # 步骤 0:请求体传入最终视频标识(generated_video_id 或 video_url) + if not rendered_storage_key: + # 0a:通过 generated_video_id 查找最终成片视频 + if body.generated_video_id: + logger.info( + "[封面生成] 步骤0a: 使用 generated_video_id: plan_id=%s video_id=%s", + plan_id, body.generated_video_id, + ) + try: + from app.dependencies import get_generated_video_repository as _get_gv_repo + + gv_repo = _get_gv_repo(db) + gv = gv_repo.get(body.generated_video_id) + if gv: + file_url = getattr(gv, "file_url", "") or "" + if file_url: + # 权限校验:通过关联的 generation_task 校验归属 + gv_task_id = getattr(gv, "generation_task_id", "") or "" + if gv_task_id: + try: + _repo0 = SQLAlchemyGenerationTaskRepository(db) + task0 = _repo0.get(gv_task_id) + if ( + task0 + and task0.created_by_user_id + and task0.created_by_user_id != current_user.user.id + ): + raise HTTPException(status_code=403, detail="无权访问该视频") + except HTTPException: + raise + except Exception: + pass + rendered_storage_key = file_url + logger.info( + "[封面生成] ✅ 步骤0a找到最终成片: plan_id=%s video_id=%s url=%s", + plan_id, body.generated_video_id, file_url[:80], + ) + except HTTPException: + raise + except Exception: + logger.warning( + "[封面生成] 步骤0a查找视频失败: plan_id=%s video_id=%s", + plan_id, body.generated_video_id, exc_info=True, + ) + + # 0b:直接使用 video_url(兜底) + if not rendered_storage_key and body.video_url: + logger.info( + "[封面生成] 步骤0b: 使用请求体传入的 video_url: plan_id=%s url=%s", + plan_id, body.video_url[:80], + ) + rendered_storage_key = body.video_url + + # 步骤 2:通过 plan.config.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: + logger.info( + "[封面生成] 步骤2: 通过 plan.config.generation_task_id 查找: plan_id=%s task_id=%s", + plan_id, generation_task_id, + ) try: - gen_task_repo = SQLAlchemyGenerationTaskRepository(db) - task = gen_task_repo.get(generation_task_id) + _repo = SQLAlchemyGenerationTaskRepository(db) + 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 "" + rendered_storage_key = _get_task_video_url(db, task.id) or "" + if rendered_storage_key: logger.info( "[封面生成] ✅ 步骤2找到视频: plan_id=%s task_id=%s url=%s", - plan_id, - generation_task_id, - rendered_storage_key[:80], + plan_id, generation_task_id, rendered_storage_key[:80], ) except Exception: logger.warning( - "封面生成: 通过 generation_task_id 查找视频失败: plan_id=%s", - plan_id, - exc_info=True, + "[封面生成] 步骤2查找失败: plan_id=%s", plan_id, exc_info=True, ) - # 第 2.5 步:通过 plan_id 作为 source_edit_plan_id 查找关联的已完成预览任务 + # 步骤 3:通过 source_edit_plan_id 查找已完成「最终成片」任务(is_preview=False) if not rendered_storage_key: try: - gen_task_repo = SQLAlchemyGenerationTaskRepository(db) - logger.info("[封面生成] 步骤2.5: 通过 source_edit_plan_id 查找: plan_id=%s", plan_id) - preview_tasks = gen_task_repo.list_by_source_edit_plan(plan_id) - for pt in preview_tasks: - if getattr(pt, "status", "") == "completed" and getattr(pt, "is_preview", False): - video_repo = get_generated_video_repository(db) - use_case = ListGeneratedVideosByTaskUseCase(video_repo) - videos = use_case.execute(pt.id) - if videos: - rendered_storage_key = getattr(videos[0], "file_url", "") or "" + _repo = SQLAlchemyGenerationTaskRepository(db) + logger.info("[封面生成] 步骤3: 查找最终成片任务(is_preview=False): plan_id=%s", plan_id) + all_tasks = _repo.list_by_source_edit_plan(plan_id) + for pt in all_tasks: + if ( + getattr(pt, "status", "") == "completed" + and not getattr(pt, "is_preview", False) + ): + rendered_storage_key = _get_task_video_url(db, pt.id) or "" + if rendered_storage_key: logger.info( - "[封面生成] ✅ 步骤2.5找到视频: plan_id=%s task_id=%s url=%s", - plan_id, - pt.id, - rendered_storage_key[:80], + "[封面生成] ✅ 步骤3找到最终成片: plan_id=%s task_id=%s url=%s", + plan_id, pt.id, rendered_storage_key[:80], ) break except Exception: logger.warning( - "封面生成: 通过 source_edit_plan_id 查找预览任务失败: plan_id=%s", - plan_id, - exc_info=True, + "[封面生成] 步骤3查找最终成片失败: plan_id=%s", plan_id, exc_info=True, ) - # 第三步:按 user + template 查找最近的已完成预览任务(兜底) + # 步骤 4:兼容回退 — 通过 source_edit_plan_id 查找已完成预览任务 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( + _repo = SQLAlchemyGenerationTaskRepository(db) + logger.info("[封面生成] 步骤4: 回退查找预览任务(is_preview=True): plan_id=%s", plan_id) + preview_tasks = _repo.list_by_source_edit_plan(plan_id) + for pt in preview_tasks: + if ( + getattr(pt, "status", "") == "completed" + and getattr(pt, "is_preview", False) + ): + rendered_storage_key = _get_task_video_url(db, pt.id) or "" + if rendered_storage_key: + logger.info( + "[封面生成] ✅ 步骤4找到预览视频: plan_id=%s task_id=%s url=%s", + plan_id, pt.id, rendered_storage_key[:80], + ) + break + except Exception: + logger.warning( + "[封面生成] 步骤4查找预览任务失败: plan_id=%s", plan_id, exc_info=True, + ) + + # 步骤 5:按 user + template 查找最近的已完成预览任务(兜底) + if not rendered_storage_key: + try: + _repo = SQLAlchemyGenerationTaskRepository(db) + logger.info( + "[封面生成] 步骤5: 通过 user+template 查找预览任务: plan_id=%s template_id=%s", + plan_id, template_id, + ) + preview_tasks = _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 "" + rendered_storage_key = _get_task_video_url(db, preview_tasks[0].id) or "" + if rendered_storage_key: logger.info( - "封面视频: 通过 user+template 找到预览任务: plan_id=%s template_id=%s task_id=%s", - plan_id, - template_id, - completed_preview.id, + "[封面生成] ✅ 步骤5找到预览视频: plan_id=%s task_id=%s", + plan_id, preview_tasks[0].id, ) except Exception: logger.warning( - "封面警告: user+template 查找预览任务失败: plan_id=%s template_id=%s", - plan_id, - template_id, - exc_info=True, + "[封面生成] 步骤5 user+template 查找失败: plan_id=%s", plan_id, exc_info=True, ) - # 使用裸 URL(rendered/* 已配置公开读);找不到渲染视频时不立即报错, - # 因为步骤 E 可以直接从源素材抽帧(历史数据或 Worker 抽帧失败时的兜底) + # 将 storage_key 转换为可访问 URL;找不到视频时不立即报错, + # 因为步骤 E2 可以直接从源素材抽帧(历史数据或 Worker 抽帧失败时的兜底) primary_video_url = None 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) - if primary_video_url: - import re as _re - - primary_video_url = _re.sub(r"(?