fix(generation): #2028 awaiting_cover 预览可见 + clips/from-assets 零时长容错(rebase on #2030)
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 1s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 2s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 1m44s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m47s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m19s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 3m31s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 3m53s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 3m54s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Successful in 4m2s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 5m5s
AI Code Review / AI Code Review (pull_request) Successful in 5m16s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 5m39s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 8m35s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 11m51s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 13m29s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 1s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 10m54s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 33s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 1m7s
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 1s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 2s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 1m44s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m47s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m19s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 3m31s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 3m53s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 3m54s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Successful in 4m2s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 5m5s
AI Code Review / AI Code Review (pull_request) Successful in 5m16s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 5m39s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 8m35s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 11m51s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 13m29s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 1s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 10m54s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 33s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 1m7s
- 后端 _get_task_video_url:DB 查不到 GeneratedVideo 时回退读 task.extra_meta.rendered_output.file_url,封面生成不再 404
- 后端 results:awaiting_cover 且无 DB 记录时,从 extra_meta 合成 preview-{task_id} 响应
- 后端 finalize 服务:支持 custom_title 自定义标题,幂等分支也更新名称
- 后端 clips/from-assets:asset=None 或 duration≤0 跳过;score_asset/scene_points 异常兜底不阻断;全素材无效返友好 400「素材尚未完成分析,请稍后重试」
- 前端轮询:pollSingleTask 把 awaiting_cover 视为可预览终态;BatchTaskState.status 加 awaiting_cover;7 处 completed 过滤加 OR awaiting_cover
- 前端 finalize:单步调 finalizeGeneration(custom_title 透传),不再先 confirm 再 finalize;响应字段补全 project_id/name/file_size/duration/thumbnail_url/width/height/fps
- 单测 11 个新增/更新,本地全过
This commit is contained in:
@@ -76,10 +76,7 @@ class GenerateCoverResponse(BaseModel):
|
||||
# ── Route ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
||||
def _select_best_frame_from_snapshots(
|
||||
snapshots: list[dict], plan_id: str
|
||||
) -> str:
|
||||
def _select_best_frame_from_snapshots(snapshots: list[dict], plan_id: str) -> str:
|
||||
"""从 MediaKit 抽帧结果中,通过质量评分选出最佳帧。
|
||||
|
||||
降级策略:cv2 不可用或评分失败时,返回第一帧。
|
||||
@@ -232,7 +229,10 @@ def _persist_cover_frame(
|
||||
|
||||
|
||||
def _get_task_video_url(db: Session, task_id: str) -> Optional[str]:
|
||||
"""从 GenerationTask 关联的 GeneratedVideo 中获取视频 storage_key / URL."""
|
||||
"""从 GenerationTask 关联的 GeneratedVideo 中获取视频 storage_key / URL.
|
||||
|
||||
#2028: awaiting_cover 状态下 GeneratedVideo 尚未入库,兜底从 task.extra_meta.rendered_output.file_url 读取。
|
||||
"""
|
||||
try:
|
||||
video_repo = get_generated_video_repository(db)
|
||||
use_case = ListGeneratedVideosByTaskUseCase(video_repo)
|
||||
@@ -241,6 +241,20 @@ def _get_task_video_url(db: Session, task_id: str) -> Optional[str]:
|
||||
return getattr(videos[0], "file_url", "") or ""
|
||||
except Exception:
|
||||
logger.warning("[封面生成] 获取任务视频失败: task_id=%s", task_id, exc_info=True)
|
||||
# awaiting_cover 兜底:从 extra_meta.rendered_output 取
|
||||
try:
|
||||
task_repo = SQLAlchemyGenerationTaskRepository(db)
|
||||
task = task_repo.get(task_id)
|
||||
if task is not None:
|
||||
_status = task.status.value if hasattr(task.status, "value") else str(task.status)
|
||||
if _status == "awaiting_cover":
|
||||
_meta = getattr(task, "extra_meta", {}) or {}
|
||||
_ro = _meta.get("rendered_output") or {}
|
||||
_url = _ro.get("file_url") or ""
|
||||
if _url:
|
||||
return _url
|
||||
except Exception:
|
||||
logger.warning("[封面生成] awaiting_cover 兜底读取失败: task_id=%s", task_id, exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
|
||||
@@ -1050,17 +1050,29 @@ def finalize_generation_task(
|
||||
task_id=task_id,
|
||||
user_id=authenticated_user.user.id,
|
||||
cover_url=request.cover_url or None,
|
||||
custom_title=(request.custom_title or "").strip() or None,
|
||||
)
|
||||
except GenerationFinalizeError as e:
|
||||
raise HTTPException(status_code=e.status_code, detail=str(e)) from e
|
||||
|
||||
download_url = storage_service.get_download_url(video.file_url, expires_seconds=86400)
|
||||
try:
|
||||
download_url = storage_service.get_download_url(video.file_url, expires_seconds=86400)
|
||||
except Exception:
|
||||
download_url = video.file_url
|
||||
return FinalizeGenerationResponse(
|
||||
video_id=video.id,
|
||||
project_id=getattr(video, "project_id", "") or "",
|
||||
name=getattr(video, "name", "") or "",
|
||||
file_size=int(getattr(video, "file_size", 0) or 0),
|
||||
duration=float(getattr(video, "duration", 0.0) or 0.0),
|
||||
thumbnail_url=video.thumbnail_url or "",
|
||||
cover_url=video.thumbnail_url or "",
|
||||
file_url=download_url,
|
||||
width=int(getattr(video, "width", 0) or 0),
|
||||
height=int(getattr(video, "height", 0) or 0),
|
||||
fps=float(getattr(video, "fps", 0.0) or 0.0),
|
||||
status="success",
|
||||
is_duplicate=bool(video.is_duplicate),
|
||||
is_duplicate=bool(getattr(video, "is_duplicate", False)),
|
||||
)
|
||||
|
||||
|
||||
@@ -1111,6 +1123,44 @@ def list_generation_results(
|
||||
for item in items:
|
||||
download_url = storage_service.get_download_url(item.file_url, expires_seconds=86400)
|
||||
responses.append(_to_generated_video_response(item, download_url=download_url))
|
||||
|
||||
# #2024/#2028: awaiting_cover 状态下 GeneratedVideo 尚未入库,
|
||||
# 从 extra_meta["rendered_output"] 合成一条轻量视频响应,供前端预览与智能封面使用。
|
||||
status_val = task.status.value if hasattr(task.status, "value") else str(task.status)
|
||||
if not responses and status_val == "awaiting_cover":
|
||||
_meta = getattr(task, "extra_meta", {}) or {}
|
||||
_ro = _meta.get("rendered_output") or {}
|
||||
_file_url = _ro.get("file_url") or ""
|
||||
if _file_url:
|
||||
if _file_url.startswith("http"):
|
||||
_download = _file_url
|
||||
else:
|
||||
try:
|
||||
_download = storage_service.get_download_url(_file_url, expires_seconds=86400)
|
||||
except Exception:
|
||||
_download = _file_url
|
||||
_name = _ro.get("name") or ""
|
||||
if not _name:
|
||||
_name = f"generated-{task_id[:8]}"
|
||||
responses.append(
|
||||
GeneratedVideoResponse(
|
||||
id=f"preview-{task_id}",
|
||||
project_id=getattr(task, "project_id", "") or "",
|
||||
generation_task_id=task_id,
|
||||
name=_name,
|
||||
file_url=_file_url,
|
||||
file_size=int(_ro.get("file_size") or 0),
|
||||
duration=float(_ro.get("duration") or 0.0),
|
||||
thumbnail_url=_ro.get("thumbnail_url") or getattr(task, "cover_url", "") or "",
|
||||
width=int(_ro.get("width") or 0),
|
||||
height=int(_ro.get("height") or 0),
|
||||
fps=float(_ro.get("fps") or 0.0),
|
||||
mode=_ro.get("mode", ""),
|
||||
download_url=_download,
|
||||
created_at=getattr(task, "updated_at", None) or getattr(task, "created_at", None),
|
||||
)
|
||||
)
|
||||
|
||||
return ListGeneratedVideosResponse(items=responses)
|
||||
|
||||
|
||||
|
||||
@@ -682,17 +682,50 @@ def create_clips_from_assets_editor(
|
||||
# 素材 metadata 中缓存的场景切换点(由后台 MediaKit SceneChange 检测写入):
|
||||
# 有缓存时片段起点从随机镜头段中选取(不同片段来自不同镜头),无缓存回退随机起点
|
||||
asset_scene_points: dict[str, list[float]] = {}
|
||||
invalid_asset_ids: list[str] = []
|
||||
valid_asset_ids: list[str] = []
|
||||
for asset_id in unique_asset_ids:
|
||||
asset = asset_repo.get(asset_id)
|
||||
if asset and hasattr(asset, "duration"):
|
||||
asset_durations[asset_id] = float(asset.duration or 0.0)
|
||||
# 计算 smart_match 综合评分,用于候选排序
|
||||
if asset is None:
|
||||
logger.warning("from-assets 素材不存在或已删除,跳过: asset_id=%s", asset_id)
|
||||
invalid_asset_ids.append(asset_id)
|
||||
continue
|
||||
_dur = float(getattr(asset, "duration", 0.0) or 0.0)
|
||||
if _dur <= 0:
|
||||
# 素材时长缺失(刚上传/分析未完成)或为0,跳过该素材——避免按兜底时长分配无效片段。
|
||||
# 若所有素材都无效,在下面统一抛 400。
|
||||
logger.warning("from-assets 素材时长缺失或为0,跳过: asset_id=%s", asset_id)
|
||||
invalid_asset_ids.append(asset_id)
|
||||
continue
|
||||
valid_asset_ids.append(asset_id)
|
||||
asset_durations[asset_id] = _dur
|
||||
# 计算 smart_match 综合评分,用于候选排序
|
||||
try:
|
||||
smart_score, _ = score_asset(asset)
|
||||
asset_smart_scores[asset_id] = smart_score
|
||||
# 读取场景切换点缓存(新素材未检测过时为 None,走随机起点兜底)
|
||||
except Exception:
|
||||
asset_smart_scores[asset_id] = 0.0
|
||||
# 读取场景切换点缓存(新素材未检测过时为 None,走随机起点兜底)
|
||||
try:
|
||||
cached_points = extract_scene_points_from_metadata(getattr(asset, "metadata", None))
|
||||
if cached_points:
|
||||
asset_scene_points[asset_id] = cached_points
|
||||
except Exception:
|
||||
pass
|
||||
if invalid_asset_ids:
|
||||
logger.info(
|
||||
"from-assets %d 个素材无效(时长缺失/不存在,已跳过): %s",
|
||||
len(invalid_asset_ids),
|
||||
",".join(invalid_asset_ids[:5]),
|
||||
)
|
||||
# 所有素材都无效(刚上传未分析完)→ 400 让前端稍后重试,而不是用兜底时长产生错乱片段
|
||||
if not valid_asset_ids:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="素材尚未完成分析,请稍后重试",
|
||||
)
|
||||
# 后续分配素材时只在 valid_asset_ids 里挑选
|
||||
unique_asset_ids = valid_asset_ids
|
||||
logger.info(
|
||||
"from-assets 场景缓存命中: %d/%d 个素材有场景切换点",
|
||||
len(asset_scene_points),
|
||||
|
||||
@@ -19,14 +19,23 @@ class FinalizeGenerationRequest(BaseModel):
|
||||
cover_url: str = Field(
|
||||
default="", description="用户选定的封面图片 URL;为空则使用任务默认 cover_url(自动截帧/智能封面)"
|
||||
)
|
||||
custom_title: str = Field(default="", description="用户自定义成片标题,非空时覆盖 rendered_output.name")
|
||||
|
||||
|
||||
class FinalizeGenerationResponse(BaseModel):
|
||||
"""finalize 响应:返回新创建的成品库视频信息。"""
|
||||
|
||||
video_id: str = Field(description="新创建的成品视频 ID")
|
||||
project_id: str = Field(default="", description="成品所属项目 ID")
|
||||
name: str = Field(default="", description="成片名称")
|
||||
file_size: int = Field(default=0, description="文件大小(字节)")
|
||||
duration: float = Field(default=0.0, description="时长(秒)")
|
||||
thumbnail_url: str = Field(default="", description="最终绑定的缩略图/封面 URL")
|
||||
cover_url: str = Field(default="", description="最终绑定的封面 URL")
|
||||
file_url: str = Field(default="", description="成品视频 OSS URL")
|
||||
file_url: str = Field(default="", description="成品视频下载 URL")
|
||||
width: int = Field(default=0)
|
||||
height: int = Field(default=0)
|
||||
fps: float = Field(default=0.0)
|
||||
status: str = Field(default="success", description="success=新建成功;already_finalized=幂等返回已有记录")
|
||||
is_duplicate: bool = Field(default=False, description="是否被判定为与历史成片重复")
|
||||
|
||||
|
||||
@@ -32,7 +32,13 @@ class GenerationFinalizeService:
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
|
||||
def finalize_task(self, task_id: str, user_id: str, cover_url: Optional[str] = None):
|
||||
def finalize_task(
|
||||
self,
|
||||
task_id: str,
|
||||
user_id: str,
|
||||
cover_url: Optional[str] = None,
|
||||
custom_title: Optional[str] = None,
|
||||
):
|
||||
"""执行 finalize:状态校验 → 幂等 → 绑定封面 → 入库 → 推进 completed。
|
||||
|
||||
Returns:
|
||||
@@ -58,9 +64,15 @@ class GenerationFinalizeService:
|
||||
existing = self.db.query(GeneratedVideoModel).filter(GeneratedVideoModel.generation_task_id == task_id).first()
|
||||
if existing is not None:
|
||||
logger.info("[finalize] 幂等命中 task=%s video=%s", task_id, existing.id)
|
||||
_changed = False
|
||||
if cover_url and cover_url.strip() and existing.thumbnail_url != cover_url.strip():
|
||||
existing.thumbnail_url = cover_url.strip()
|
||||
task.cover_url = cover_url.strip()
|
||||
_changed = True
|
||||
if custom_title and custom_title.strip() and (getattr(existing, "name", "") or "") != custom_title.strip():
|
||||
existing.name = custom_title.strip()
|
||||
_changed = True
|
||||
if _changed:
|
||||
self.db.commit()
|
||||
if task.status.value != "completed":
|
||||
try:
|
||||
@@ -91,12 +103,23 @@ class GenerationFinalizeService:
|
||||
task=task,
|
||||
session=self.db,
|
||||
effective_cover_url=effective_cover,
|
||||
custom_name=custom_title,
|
||||
)
|
||||
except ValueError as e:
|
||||
raise GenerationFinalizeError(str(e), "RenderedOutputMissing", 400) from e
|
||||
|
||||
video_id = result["video_id"]
|
||||
|
||||
# 应用自定义标题
|
||||
if custom_title and custom_title.strip():
|
||||
try:
|
||||
_v = self.db.query(GeneratedVideoModel).filter(GeneratedVideoModel.id == video_id).first()
|
||||
if _v is not None:
|
||||
_v.name = custom_title.strip()
|
||||
self.db.flush()
|
||||
except Exception:
|
||||
logger.warning("[finalize] 更新标题失败: video_id=%s", video_id, exc_info=True)
|
||||
|
||||
# ── 推进任务 ─────────────────────────────────────────────
|
||||
task.mark_completed(result_count=1)
|
||||
task.cover_url = effective_cover
|
||||
|
||||
Reference in New Issue
Block a user