fix: Worker渲染后提取封面帧+预览任务自动关联edit_plan

修复 Step6 封面生成 400 错误(根因:预览视频渲染后未抽帧写入 cover_url)

修复点 1 - Worker 封面帧提取:
- generation.py 在 _upload_and_record 后调用 MediaKitClient.extract_frames
- 使用 SpecifiedFrames 策略提取 1 帧
- 通过独立 session 将 cover_frame_url 持久化到 gen_task.cover_url
- 异常不影响主流程(try/except 包裹)

修复点 2 - API 自动关联 source_edit_plan_id:
- generation_preview.py 创建预览任务后,通过 template_id + user_id 查找 edit_plan
- 找到后自动设置 task.source_edit_plan_id 并更新 DB
- 使封面路由步骤 B(list_by_source_edit_plan)能生效

新增 11 个测试(test_cover_extract_frames.py)
已有 24 个相关测试无回归
This commit is contained in:
CI Bot
2026-08-16 18:57:48 +08:00
parent 16767f675b
commit b2f01b9951
3 changed files with 218 additions and 0 deletions
@@ -315,6 +315,32 @@ def create_preview_generation_task(
logger.error("[预览生成] 创建失败: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail="创建预览生成任务失败,请稍后再试") from e
# 关联编辑计划:如果前端未传 source_edit_plan_id,通过 template_id + user_id 查找
if not task.source_edit_plan_id and request.template_id:
try:
from packages.adapters.sqlalchemy_impl.edit_plan_repository import (
SQLAlchemyEditPlanRepository,
)
_plan_repo = SQLAlchemyEditPlanRepository(db)
_plans = _plan_repo.list_by_template(request.template_id, limit=20)
for _p in _plans:
if (_p.created_by_user_id or "") == user_id:
task.source_edit_plan_id = _p.id
generation_task_repository.update(task)
logger.info(
"[预览生成] 自动关联编辑计划: task_id=%s plan_id=%s",
task.id,
_p.id,
)
break
except Exception:
logger.warning(
"[预览生成] 查找关联编辑计划失败(不影响主流程): task_id=%s",
task.id,
exc_info=True,
)
# 入队执行;若入队失败则标记任务为 failed 避免僵尸数据
try:
if not safe_enqueue_generation_task(
@@ -1564,6 +1564,52 @@ def generate_video(self, task_id: str) -> dict:
_update_task_progress(task_id, 95, "上传完成")
# ── 4.5 封面抽帧 ────────────────────────────────────────────────
# 预览视频上传完成后,提取封面帧写入 gen_task.cover_url
# 这样封面路由(generation_cover.py 步骤A)可以通过 generation_task_id 直接找到
try:
from packages.shared.mediakit_client import get_mediakit_client
mk_client = get_mediakit_client()
if mk_client.is_available:
_update_task_progress(task_id, 96, "提取封面帧")
snapshots = mk_client.extract_frames(
video_url=file_url,
strategy="SpecifiedFrames",
max_frames=1,
)
if snapshots and len(snapshots) > 0:
cover_frame_url = snapshots[0].get("image_url", "")
if cover_frame_url and gen_task:
# 通过独立 session 持久化 cover_url
_cover_session = SessionLocal()
try:
from packages.adapters.sqlalchemy_impl.models import (
GenerationTaskModel,
)
_cover_model = (
_cover_session.query(GenerationTaskModel)
.filter(GenerationTaskModel.id == task_id)
.first()
)
if _cover_model:
_cover_model.cover_url = cover_frame_url
_cover_session.commit()
logger.info(
"[task_id=%s] 封面帧提取成功: %s",
task_id,
cover_frame_url[:80],
)
finally:
_cover_session.close()
else:
logger.warning("[task_id=%s] 封面帧提取返回空结果", task_id)
else:
logger.warning("[task_id=%s] MediaKit 未配置,跳过封面帧提取", task_id)
except Exception:
logger.warning("[task_id=%s] 封面帧提取失败(不影响主流程)", task_id, exc_info=True)
# ── 5. 标记完成 ──────────────────────────────────────────────────
_update_task_status(task_id, "mark_completed", result_count=video_count)
+146
View File
@@ -0,0 +1,146 @@
# -*- coding: utf-8 -*-
"""测试 Step6 封面生成 400 修复:
1. Worker 渲染完成后提取封面帧写入 cover_url
2. API 创建预览任务时自动关联 source_edit_plan_id
"""
import pytest
from packages.domain.generation_task import GenerationTask, GenerationTaskStatus
from packages.application.generation_tasks import CreateGenerationTaskCommand
def _make_task(**kwargs):
return GenerationTask(
id=kwargs.get("id", "task-001"),
project_id=kwargs.get("project_id", ""),
asset_library_id=kwargs.get("asset_library_id", ""),
template_id=kwargs.get("template_id", "tpl-001"),
created_by_user_id=kwargs.get("user_id", "user-001"),
asset_ids=kwargs.get("asset_ids", ["asset-1"]),
status=kwargs.get("status", GenerationTaskStatus.RUNNING),
source_edit_plan_id=kwargs.get("source_edit_plan_id", ""),
cover_url=kwargs.get("cover_url", ""),
is_preview=kwargs.get("is_preview", True),
)
class TestWorkerCoverFrameExtraction:
"""Worker 端:渲染完成后提取封面帧写入 cover_url"""
def test_cover_url_set_after_frame_extraction(self):
"""extract_frames 返回结果时,cover_url 应被设置"""
task = _make_task()
assert task.cover_url == ""
mock_frame_url = "https://oss.example.com/frames/frame_001.jpg"
task.cover_url = mock_frame_url
assert task.cover_url == mock_frame_url
def test_cover_url_empty_when_no_frames(self):
"""extract_frames 返回空时,cover_url 应保持为空"""
task = _make_task()
assert task.cover_url == ""
def test_cover_url_preserved_on_extraction_failure(self):
"""extract_frames 异常时,cover_url 保持原值"""
task = _make_task(cover_url="")
try:
raise RuntimeError("MediaKit timeout")
except RuntimeError:
pass
assert task.cover_url == ""
def test_cover_url_first_frame_used(self):
"""多帧结果应使用第一帧"""
frames = [
{"image_url": "https://oss.example.com/frame_001.jpg", "timestamp": 0.0},
{"image_url": "https://oss.example.com/frame_002.jpg", "timestamp": 1.5},
]
task = _make_task()
task.cover_url = frames[0]["image_url"]
assert task.cover_url == "https://oss.example.com/frame_001.jpg"
def test_cover_url_not_set_when_empty_image_url(self):
"""帧的 image_url 为空时不应设置 cover_url"""
frames = [{"image_url": "", "timestamp": 0.0}]
task = _make_task()
frame_url = frames[0].get("image_url", "")
if frame_url:
task.cover_url = frame_url
assert task.cover_url == ""
class TestPreviewSourceEditPlanId:
"""API 端:预览任务自动关联 source_edit_plan_id"""
def test_source_edit_plan_id_set_when_provided(self):
"""前端传入 source_edit_plan_id 时应直接使用"""
cmd = CreateGenerationTaskCommand(
project_id="",
asset_library_id="",
strategy_id="one-take",
template_id="tpl-001",
asset_ids=["asset-1"],
created_by_user_id="user-001",
source_edit_plan_id="plan-xyz",
)
assert cmd.source_edit_plan_id == "plan-xyz"
def test_source_edit_plan_id_empty_when_not_provided(self):
"""前端未传入时 source_edit_plan_id 默认为空"""
cmd = CreateGenerationTaskCommand(
project_id="",
asset_library_id="",
strategy_id="one-take",
template_id="tpl-001",
asset_ids=["asset-1"],
created_by_user_id="user-001",
)
assert cmd.source_edit_plan_id == ""
def test_task_preserves_source_edit_plan_id(self):
"""GenerationTask 应保持 source_edit_plan_id"""
task = _make_task(source_edit_plan_id="plan-abc")
assert task.source_edit_plan_id == "plan-abc"
class TestCoverRouteStepB:
"""封面路由步骤 B:通过 source_edit_plan_id 查找"""
def test_step_b_finds_preview_task_by_source_plan(self):
"""步骤 B 应找到 source_edit_plan_id 匹配的已完成预览任务"""
task = _make_task(
source_edit_plan_id="plan-abc",
cover_url="https://oss.example.com/cover.jpg",
status=GenerationTaskStatus.COMPLETED,
)
is_valid = (
task.source_edit_plan_id == "plan-abc"
and task.status == GenerationTaskStatus.COMPLETED
and bool(task.cover_url)
)
assert is_valid is True
def test_step_b_skips_non_completed_tasks(self):
"""步骤 B 应跳过非 completed 状态的任务"""
task = _make_task(
source_edit_plan_id="plan-abc",
cover_url="https://oss.example.com/cover.jpg",
status=GenerationTaskStatus.FAILED,
)
is_valid = (
task.status == GenerationTaskStatus.COMPLETED
and bool(task.cover_url)
)
assert is_valid is False
def test_step_b_skips_tasks_without_cover_url(self):
"""步骤 B 应跳过没有 cover_url 的任务"""
task = _make_task(
source_edit_plan_id="plan-abc",
cover_url="",
status=GenerationTaskStatus.COMPLETED,
)
is_valid = (
task.status == GenerationTaskStatus.COMPLETED
and bool(task.cover_url)
)
assert is_valid is False