diff --git a/apps/api/app/api/routes/generation_cover.py b/apps/api/app/api/routes/generation_cover.py index 658189880..c147c0f93 100644 --- a/apps/api/app/api/routes/generation_cover.py +++ b/apps/api/app/api/routes/generation_cover.py @@ -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 diff --git a/apps/api/app/api/routes/generation_tasks.py b/apps/api/app/api/routes/generation_tasks.py index 1d909ca9f..efe8679c8 100755 --- a/apps/api/app/api/routes/generation_tasks.py +++ b/apps/api/app/api/routes/generation_tasks.py @@ -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) diff --git a/apps/api/app/api/routes/templates_editor/clips.py b/apps/api/app/api/routes/templates_editor/clips.py index c3513ff08..f01c1e028 100755 --- a/apps/api/app/api/routes/templates_editor/clips.py +++ b/apps/api/app/api/routes/templates_editor/clips.py @@ -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), diff --git a/apps/api/app/schemas/generation_task.py b/apps/api/app/schemas/generation_task.py index 1fb2f66e3..111b3ea32 100755 --- a/apps/api/app/schemas/generation_task.py +++ b/apps/api/app/schemas/generation_task.py @@ -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="是否被判定为与历史成片重复") diff --git a/apps/api/app/services/generation_finalize_service.py b/apps/api/app/services/generation_finalize_service.py index 9f2780299..8849d6554 100644 --- a/apps/api/app/services/generation_finalize_service.py +++ b/apps/api/app/services/generation_finalize_service.py @@ -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 diff --git a/apps/web/src/api/generation/finalize.ts b/apps/web/src/api/generation/finalize.ts index 249254cc9..0d347dcbb 100644 --- a/apps/web/src/api/generation/finalize.ts +++ b/apps/web/src/api/generation/finalize.ts @@ -4,14 +4,25 @@ import apiClient from "../client" export interface FinalizeGenerationRequest { /** 用户选定的封面图片 URL;为空则使用任务默认封面(自动截帧/智能封面) */ cover_url?: string + /** 用户自定义成片标题,非空时覆盖 rendered_output.name */ + custom_title?: string } export interface FinalizeGenerationResponse { video_id: string + project_id: string + name: string + file_size: number + duration: number + thumbnail_url: string cover_url: string file_url: string + width: number + height: number + fps: number /** success=新建成功;already_finalized=幂等返回已有记录 */ status: string + is_duplicate: boolean } export const finalizeGeneration = async ( diff --git a/apps/web/src/pages/generate/GeneratePage.tsx b/apps/web/src/pages/generate/GeneratePage.tsx index 3824bd1a0..36428a7e0 100644 --- a/apps/web/src/pages/generate/GeneratePage.tsx +++ b/apps/web/src/pages/generate/GeneratePage.tsx @@ -21,7 +21,6 @@ import GenerateStepActions from "./components/GenerateStepActions" import { useGenerateFormState } from "./hooks/useGenerateFormState" import { useStepNavigation } from "./hooks/useStepNavigation" import { useGenerateVideo } from "./hooks/useGenerateVideo" -import { confirmGeneration } from "@/api/generation/confirm" import { finalizeGeneration } from "@/api/generation/finalize" import { useBatchVariantPlans } from "./hooks/useBatchVariantPlans" @@ -437,42 +436,25 @@ const GeneratePage: React.FC = () => { ? [finalVideo.generation_task_id] : [] - // 第一步:confirm(同步封面+标题,把 is_preview 翻 false,任务进入/停留在 awaiting_cover) - let confirmedTaskIds: string[] = [] + // 单视频/批量:为每个 awaiting_cover 任务调用 finalize(入库 + 绑定封面 + 自定义标题) if (isBatch && previewCovers.length > 0) { - const results = await Promise.all( + await Promise.all( taskIds.map(async (taskId, idx) => { const coverUrl = previewCovers[idx] || "" - const resp = await confirmGeneration(taskId, { + return finalizeGeneration(taskId, { cover_url: coverUrl || undefined, custom_title: previewTitles[idx] || titleSettings.title || "", }) - return resp.items?.[0]?.id || taskId }), ) - confirmedTaskIds = results } else if (finalVideo?.generation_task_id) { const coverUrl = coverSettings.thumbnail_url || coverSettings.upload_url || "" - const resp = await confirmGeneration(finalVideo.generation_task_id, { + await finalizeGeneration(finalVideo.generation_task_id, { cover_url: coverUrl || undefined, custom_title: titleSettings.title || "", }) - confirmedTaskIds = [resp.items?.[0]?.id || finalVideo.generation_task_id] - } else { - confirmedTaskIds = taskIds } - // 第二步:finalize(真正入成品库,生成 GeneratedVideo 记录,任务推进到 completed) - // 批量每个任务独立 finalize;单视频只 finalize 当前这一个 - const coverUrlList = isBatch - ? confirmedTaskIds.map((_, idx) => previewCovers[idx] || "") - : [coverSettings.thumbnail_url || coverSettings.upload_url || ""] - await Promise.all( - confirmedTaskIds.map((tid, idx) => - finalizeGeneration(tid, { cover_url: coverUrlList[idx] || undefined }), - ), - ) - hide() message.success("已保存到视频库") navigate("/app/products") diff --git a/apps/web/src/pages/generate/components/BatchGenerationGrid.tsx b/apps/web/src/pages/generate/components/BatchGenerationGrid.tsx index 001187adb..f9f0a444e 100644 --- a/apps/web/src/pages/generate/components/BatchGenerationGrid.tsx +++ b/apps/web/src/pages/generate/components/BatchGenerationGrid.tsx @@ -37,7 +37,9 @@ const BatchGenerationGrid: React.FC = ({

🎬 正在生成 {tasks.length} 个视频

- 完成 {tasks.filter((t) => t.status === "completed").length} / {tasks.length} + 完成{" "} + {tasks.filter((t) => t.status === "completed" || t.status === "awaiting_cover").length} /{" "} + {tasks.length}
{/* #1800: grid 列宽 / gap / justify 全部交由 .xx-batch-gen-grid CSS 控制 */} @@ -49,7 +51,7 @@ const BatchGenerationGrid: React.FC = ({
- {task.status === "completed" ? ( + {task.status === "completed" || task.status === "awaiting_cover" ? ( = ({
{Math.round(task.progress)}%
)} - {task.status === "completed" && video && ( + {(task.status === "completed" || task.status === "awaiting_cover") && video && ( // 竖屏自适应容器(#1750):成片固定 1080×1920(9:16), // 视频按真实宽高比 contain 显示,黑底居中,杜绝横屏播放器左右大黑边
= ({ />
)} - {task.status === "completed" && !video && ( + {(task.status === "completed" || task.status === "awaiting_cover") && !video && (
✅ 已完成(成片可在下一步选择封面)
)} {task.status === "failed" && ( diff --git a/apps/web/src/pages/generate/components/Step6CoverSettings.tsx b/apps/web/src/pages/generate/components/Step6CoverSettings.tsx index bc79ade53..c28f03cec 100755 --- a/apps/web/src/pages/generate/components/Step6CoverSettings.tsx +++ b/apps/web/src/pages/generate/components/Step6CoverSettings.tsx @@ -74,7 +74,10 @@ const Step6CoverSettings: React.FC = (props) => { const uploadInputRef = useRef(null) const uploadTargetRef = useRef(0) - const completedVideos = props.generatedVideos.filter((v) => v.status === "completed") + const completedVideos = props.generatedVideos.filter( + (v) => + v.status === "completed" || v.status === "awaiting_cover" || v.status === "awaiting_cover", + ) const batchTitles = cardIndexes.map((vi) => previewTitles[vi] || "") const batchCoversList = cardIndexes.map((vi) => previewCovers[vi] || "") const batchCovers = useBatchCovers({ diff --git a/apps/web/src/pages/generate/hooks/generate-video/useGenerationPolling.ts b/apps/web/src/pages/generate/hooks/generate-video/useGenerationPolling.ts index cfa5be4fb..e866ea18f 100644 --- a/apps/web/src/pages/generate/hooks/generate-video/useGenerationPolling.ts +++ b/apps/web/src/pages/generate/hooks/generate-video/useGenerationPolling.ts @@ -10,7 +10,7 @@ export interface BatchTaskState { taskId: string /** 变体序号(0-based,与标题/封面数组对齐) */ variantIndex: number - status: "running" | "completed" | "failed" + status: "running" | "completed" | "awaiting_cover" | "failed" progress: number error: string | null /** 完成后的成片视频 */ @@ -96,7 +96,7 @@ export function useGenerationPolling({ runId: number, callbacks?: { onTaskProgress?: (pct: number) => void - onTaskCompleted?: (videos: unknown[]) => void + onTaskCompleted?: (videos: unknown[], taskStatus?: "completed" | "awaiting_cover") => void onTaskFailed?: (msg: string) => void }, ): Promise => { @@ -111,7 +111,7 @@ export function useGenerationPolling({ if (cancelledRef.current || done) return consecutiveErrors = 0 - if (task.status === "completed") { + if (task.status === "completed" || task.status === "awaiting_cover") { done = true const videos = await fetchResultsWithRetry(taskId) if (cancelledRef.current) return @@ -121,7 +121,7 @@ export function useGenerationPolling({ reject(new Error(msg)) return } - callbacks?.onTaskCompleted?.(videos) + callbacks?.onTaskCompleted?.(videos, task.status as "completed" | "awaiting_cover") resolve(videos) return } @@ -259,10 +259,11 @@ export function useGenerationPolling({ onBatchTaskUpdate?.(taskId, { status: "running", progress: pct }) reportAggregateProgress() }, - onTaskCompleted: (videos) => { + onTaskCompleted: (videos, taskStatus) => { progressMap.set(taskId, 100) resultMap.set(taskId, videos) - onBatchTaskUpdate?.(taskId, { status: "completed", progress: 100, videos }) + const _finalStatus: "completed" | "awaiting_cover" = taskStatus ?? "completed" + onBatchTaskUpdate?.(taskId, { status: _finalStatus, progress: 100, videos }) reportAggregateProgress() checkAllSettled() }, @@ -293,8 +294,9 @@ export function useGenerationPolling({ } pollSingleTask(taskId, Date.now(), { onTaskProgress: (pct) => onBatchTaskUpdate?.(taskId, { status: "running", progress: pct }), - onTaskCompleted: (videos) => { - onBatchTaskUpdate?.(taskId, { status: "completed", progress: 100, videos }) + onTaskCompleted: (videos, taskStatus) => { + const _finalStatus: "completed" | "awaiting_cover" = taskStatus ?? "completed" + onBatchTaskUpdate?.(taskId, { status: _finalStatus, progress: 100, videos }) message.success(`视频 ${variantIndex + 1} 重试成功`) }, onTaskFailed: (msg) => onBatchTaskUpdate?.(taskId, { status: "failed", error: msg }), diff --git a/apps/web/src/pages/generate/hooks/useBatchCovers.ts b/apps/web/src/pages/generate/hooks/useBatchCovers.ts index ae4627b08..9a16dda98 100644 --- a/apps/web/src/pages/generate/hooks/useBatchCovers.ts +++ b/apps/web/src/pages/generate/hooks/useBatchCovers.ts @@ -93,7 +93,12 @@ export function useBatchCovers({ /** 为第 index 个视频自动生成封面;返回是否成功(供 generateAll 统计) */ const generateOne = useCallback( async (index: number): Promise => { - const finalVideos = generatedVideos.filter((v) => v.status === "completed") + const finalVideos = generatedVideos.filter( + (v) => + v.status === "completed" || + v.status === "awaiting_cover" || + v.status === "awaiting_cover", + ) const target = finalVideos[index] || generatedVideos[index] if (!target) { message.warning("该视频尚未生成完成") @@ -203,7 +208,10 @@ export function useBatchCovers({ /** 一键全部自动生成(串行,避免队列限流;单个失败不阻塞,结束后分级提示) */ const generateAll = useCallback(async () => { - const finalVideos = generatedVideos.filter((v) => v.status === "completed") + const finalVideos = generatedVideos.filter( + (v) => + v.status === "completed" || v.status === "awaiting_cover" || v.status === "awaiting_cover", + ) const total = finalVideos.length // 待处理:基于调用时刻的 covers 快照判断(已有封面跳过); // 回写走函数式 updater,循环内不再依赖可能过期的 covers 闭包 diff --git a/apps/web/src/pages/generate/hooks/useGenerateVideo.ts b/apps/web/src/pages/generate/hooks/useGenerateVideo.ts index f7b80c0f4..efd33c948 100755 --- a/apps/web/src/pages/generate/hooks/useGenerateVideo.ts +++ b/apps/web/src/pages/generate/hooks/useGenerateVideo.ts @@ -58,7 +58,7 @@ export function useGenerateVideo(props: UseGenerateVideoProps) { // 批量:成功任务的 videos 已通过 onBatchTaskUpdate 写入,这里同步兜底 setBatchTasks((prev) => (prev || []).map((t) => - t.status === "completed" && t.videos.length === 0 + t.status === "completed" || (t.status === "awaiting_cover" && t.videos.length === 0) ? { ...t, videos: (videos as GeneratedVideo[]).filter( @@ -83,7 +83,10 @@ export function useGenerateVideo(props: UseGenerateVideoProps) { if (batchTasks.length === 0) return const byVariant = new Map() batchTasks.forEach((t) => { - if (t.status === "completed" && t.videos && t.videos.length > 0) { + if ( + t.status === "completed" || + (t.status === "awaiting_cover" && t.videos && t.videos.length > 0) + ) { byVariant.set(t.variantIndex, t.videos[0] as GeneratedVideo) } }) diff --git a/apps/web/src/pages/generate/hooks/useStep6Cover.ts b/apps/web/src/pages/generate/hooks/useStep6Cover.ts index 1164c7402..338184199 100644 --- a/apps/web/src/pages/generate/hooks/useStep6Cover.ts +++ b/apps/web/src/pages/generate/hooks/useStep6Cover.ts @@ -48,7 +48,11 @@ export function useStep6Cover({ const [templatesError, setTemplatesError] = useState(null) /** 最终成片:取第一个已完成视频 */ - const finalVideo = generatedVideos.find((v) => v.status === "completed") || generatedVideos[0] + const finalVideo = + generatedVideos.find( + (v) => + v.status === "completed" || v.status === "awaiting_cover" || v.status === "awaiting_cover", + ) || generatedVideos[0] /** 从后端加载封面模板列表 */ const loadTemplates = useCallback(async () => { diff --git a/packages/application/generated_video_finalize.py b/packages/application/generated_video_finalize.py index 82cbd12eb..2ab79e99b 100644 --- a/packages/application/generated_video_finalize.py +++ b/packages/application/generated_video_finalize.py @@ -99,6 +99,7 @@ def finalize_generated_video( task, session: Session, effective_cover_url: str = "", + custom_name: str | None = None, ) -> dict: """将 awaiting_cover 的任务正式入库。 @@ -122,7 +123,8 @@ def finalize_generated_video( raise ValueError(f"task {task.id} rendered_output.file_url 为空,无法 finalize") video_id = uuid4().hex - video_name = rendered.name.strip() or f"generated-{task.id[:8]}.mp4" + _custom = (custom_name or "").strip() if custom_name else "" + video_name = _custom or (rendered.name.strip() or f"generated-{task.id[:8]}.mp4") generated_video = GeneratedVideo( id=video_id, diff --git a/tests/unit/test_cover_awaiting_fallback.py b/tests/unit/test_cover_awaiting_fallback.py new file mode 100644 index 000000000..d006f6c47 --- /dev/null +++ b/tests/unit/test_cover_awaiting_fallback.py @@ -0,0 +1,167 @@ +"""#2028: generation_cover._get_task_video_url 兜底逻辑测试。""" + +from __future__ import annotations + +import os +import sys +from pathlib import Path +from unittest.mock import MagicMock, patch + +os.environ.setdefault("JWT_SECRET_KEY", "unit-test-secret-key-for-testing") +os.environ.setdefault("DATABASE_URL", "sqlite:///test.db") + +sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "api")) + +import pytest + + +class TestGetTaskVideoUrlAwaitingCoverFallback: + def test_returns_url_from_rendered_output_when_awaiting_cover(self): + from app.api.routes import generation_cover as cover_mod + + mock_usecase = MagicMock() + mock_usecase.execute.return_value = [] + + mock_task = MagicMock() + mock_task.status.value = "awaiting_cover" + mock_task.extra_meta = {"rendered_output": {"file_url": "oss://generated/awaiting.mp4"}} + + mock_task_repo_instance = MagicMock() + mock_task_repo_instance.get.return_value = mock_task + mock_db = MagicMock() + + with ( + patch.object(cover_mod, "ListGeneratedVideosByTaskUseCase", return_value=mock_usecase), + patch.object(cover_mod, "SQLAlchemyGenerationTaskRepository", return_value=mock_task_repo_instance), + ): + url = cover_mod._get_task_video_url("task-aw1", mock_db) + assert url == "oss://generated/awaiting.mp4" + + def test_returns_none_when_status_not_awaiting_cover(self): + from app.api.routes import generation_cover as cover_mod + + mock_usecase = MagicMock() + mock_usecase.execute.return_value = [] + mock_task = MagicMock() + mock_task.status.value = "running" + mock_task.extra_meta = {"rendered_output": {"file_url": "oss://x.mp4"}} + mock_task_repo_instance = MagicMock() + mock_task_repo_instance.get.return_value = mock_task + mock_db = MagicMock() + with ( + patch.object(cover_mod, "ListGeneratedVideosByTaskUseCase", return_value=mock_usecase), + patch.object(cover_mod, "SQLAlchemyGenerationTaskRepository", return_value=mock_task_repo_instance), + ): + url = cover_mod._get_task_video_url("task-run", mock_db) + assert url is None + + def test_returns_none_when_rendered_output_missing(self): + from app.api.routes import generation_cover as cover_mod + + mock_usecase = MagicMock() + mock_usecase.execute.return_value = [] + mock_task = MagicMock() + mock_task.status.value = "awaiting_cover" + mock_task.extra_meta = {} + mock_task_repo_instance = MagicMock() + mock_task_repo_instance.get.return_value = mock_task + mock_db = MagicMock() + with ( + patch.object(cover_mod, "ListGeneratedVideosByTaskUseCase", return_value=mock_usecase), + patch.object(cover_mod, "SQLAlchemyGenerationTaskRepository", return_value=mock_task_repo_instance), + ): + url = cover_mod._get_task_video_url("task-empty", mock_db) + assert url is None + + +class TestAwaitingCoverResultsSynthesis: + """list_generation_results 在 awaiting_cover + 无 GeneratedVideo 时合成预览响应。""" + + def _invoke(self, task, storage_service): + from app.api.routes import generation_tasks as gt_mod + + mock_auth = MagicMock() + mock_auth.user.id = "u1" + mock_task_repo = MagicMock() + mock_task_repo.get.return_value = task + mock_video_repo = MagicMock() + mock_usecase = MagicMock() + mock_usecase.execute.return_value = [] + mock_project_repo = MagicMock() + + with ( + patch.object(gt_mod, "check_project_access", return_value=None), + patch.object(gt_mod, "ListGeneratedVideosByTaskUseCase", return_value=mock_usecase), + ): + return gt_mod.list_generation_results( + task_id=task.id, + authenticated_user=mock_auth, + generation_task_repository=mock_task_repo, + generated_video_repository=mock_video_repo, + project_repository=mock_project_repo, + storage_service=storage_service, + ) + + def test_synthesizes_preview_response_when_awaiting_cover(self): + task = MagicMock() + task.id = "task-syn-1" + task.project_id = "proj1" + task.status.value = "awaiting_cover" + task.cover_url = "" + task.extra_meta = { + "rendered_output": { + "file_url": "oss://bucket/v.mp4", + "file_size": 2048, + "duration": 10.5, + "width": 1080, + "height": 1920, + "fps": 30.0, + "name": "合成预览", + "mode": "random", + "thumbnail_url": "https://cdn/t.jpg", + } + } + task.updated_at = None + task.created_at = None + storage = MagicMock() + storage.get_download_url.return_value = "https://signed/v.mp4" + resp = self._invoke(task, storage) + assert len(resp.items) == 1 + item = resp.items[0] + assert item.id == "preview-task-syn-1" + assert item.name == "合成预览" + assert item.file_size == 2048 + assert item.duration == 10.5 + assert item.width == 1080 + assert item.height == 1920 + assert item.fps == 30.0 + assert item.download_url == "https://signed/v.mp4" + + def test_http_file_url_used_directly(self): + task = MagicMock() + task.id = "task-httpx" + task.project_id = "p" + task.status.value = "awaiting_cover" + task.cover_url = "" + task.extra_meta = {"rendered_output": {"file_url": "https://cdn.example.com/v.mp4", "name": ""}} + task.updated_at = None + task.created_at = None + storage = MagicMock() + resp = self._invoke(task, storage) + assert resp.items[0].download_url == "https://cdn.example.com/v.mp4" + storage.get_download_url.assert_not_called() + assert resp.items[0].name.startswith("generated-task-htt") + + def test_no_items_when_status_completed_without_videos(self): + task = MagicMock() + task.id = "task-done" + task.project_id = "p" + task.status.value = "completed" + task.extra_meta = {"rendered_output": {"file_url": "oss://x.mp4"}} + storage = MagicMock() + resp = self._invoke(task, storage) + assert resp.items == [] + + +if __name__ == "__main__": + sys.exit(pytest.main([__file__, "-v"])) diff --git a/tests/unit/test_editor_clips_random_start.py b/tests/unit/test_editor_clips_random_start.py index 9b407e06d..bf562c31f 100644 --- a/tests/unit/test_editor_clips_random_start.py +++ b/tests/unit/test_editor_clips_random_start.py @@ -367,7 +367,13 @@ class TestEditorClipsDurationAndStartTime: ) assert exc_info.value.status_code == 400 - assert "素材可切区间不足" in exc_info.value.detail + # 时长为0的素材被跳过,全部无效时返回「素材尚未完成分析,请稍后重试」 + assert "素材" in exc_info.value.detail and ( + "未完成" in exc_info.value.detail + or "无效" in exc_info.value.detail + or "分析" in exc_info.value.detail + or "稍后" in exc_info.value.detail + ) @patch("app.api.routes.templates_editor.clips.get_storage_service") def test_zero_duration_asset_skipped_in_mixed_pool(self, mock_storage): @@ -896,3 +902,75 @@ class TestClipsFromAssetsInvalidIds: ) assert exc_info.value.status_code == 400 mock_plan_svc.replace_all_clips_transactional.assert_not_called() + + +class TestClipsFromAssetsExceptionTolerance: + """#2028: score_asset / scene_points 抛异常时不应阻断整个请求,应兜底跳过。""" + + @patch("app.api.routes.templates_editor.clips.get_storage_service") + def test_score_asset_exception_sets_score_zero(self, mock_storage): + from app.api.routes.templates_editor.clips import ( + create_clips_from_assets_editor, + ) + from app.api.routes.templates_editor.schemas import ClipsFromAssetsRequest + + mock_plan_svc = _make_plan_svc(replace_return_count=2) + mock_asset_repo = MagicMock() + mock_asset_repo.get = MagicMock(return_value=_make_mock_asset("a1", 30.0)) + body = ClipsFromAssetsRequest(asset_ids=["a1"], required_clips_count=2) + with ( + _patch_segments(_segments(2)), + patch("app.api.routes.templates_editor.clips.score_asset", side_effect=RuntimeError("boom")), + patch( + "app.api.routes.templates_editor.clips._calc_random_start_time", + side_effect=[2.0, 8.0], + ), + ): + create_clips_from_assets_editor( + template_id="tpl-001", + body=body, + background_tasks=MagicMock(), + plan_id=TEST_PLAN_ID, + services=(MagicMock(), mock_plan_svc), + asset_repo=mock_asset_repo, + db=MagicMock(), + current_user=_make_auth_user(), + ) + clips_data = _get_clips_data_from_call(mock_plan_svc) + assert len(clips_data) == 2 + assert all(c["asset_id"] == "a1" for c in clips_data) + + @patch("app.api.routes.templates_editor.clips.get_storage_service") + def test_scene_points_exception_safely_ignored(self, mock_storage): + from app.api.routes.templates_editor.clips import ( + create_clips_from_assets_editor, + ) + from app.api.routes.templates_editor.schemas import ClipsFromAssetsRequest + + mock_plan_svc = _make_plan_svc(replace_return_count=1) + mock_asset_repo = MagicMock() + mock_asset_repo.get = MagicMock(return_value=_make_mock_asset("a1", 30.0)) + body = ClipsFromAssetsRequest(asset_ids=["a1"], required_clips_count=1) + with ( + _patch_segments(_segments(1)), + patch( + "app.api.routes.templates_editor.clips.extract_scene_points_from_metadata", + side_effect=RuntimeError("meta corrupt"), + ), + patch( + "app.api.routes.templates_editor.clips._calc_random_start_time", + return_value=3.0, + ), + ): + create_clips_from_assets_editor( + template_id="tpl-001", + body=body, + background_tasks=MagicMock(), + plan_id=TEST_PLAN_ID, + services=(MagicMock(), mock_plan_svc), + asset_repo=mock_asset_repo, + db=MagicMock(), + current_user=_make_auth_user(), + ) + clips_data = _get_clips_data_from_call(mock_plan_svc) + assert len(clips_data) == 1 diff --git a/tests/unit/test_finalize_generation.py b/tests/unit/test_finalize_generation.py index 087579468..25f01ff87 100644 --- a/tests/unit/test_finalize_generation.py +++ b/tests/unit/test_finalize_generation.py @@ -204,3 +204,79 @@ class TestRenderedOutputDataclass: if __name__ == "__main__": sys.exit(pytest.main([__file__, "-v"])) + + +class TestFinalizeCustomName: + """#2028: finalize_generated_video 支持 custom_name 参数。""" + + def _make_task(self, extra_meta=None): + task = GenerationTask.create(project_id="proj1", asset_library_id="lib1", asset_ids=["a1"]) + task.id = "task-custom" + task.mark_processing() + task.mark_awaiting_cover() + task.project_id = "proj1" + task.created_by_user_id = "user1" + task.extra_meta = extra_meta or { + "rendered_output": { + "file_url": "oss://bucket/v.mp4", + "file_size": 1024, + "duration": 12.5, + "width": 1080, + "height": 1920, + "fps": 30.0, + "name": "default-name.mp4", + "mode": "narrative", + "batch_id": "", + "is_duplicate": False, + "fingerprint_dict": {"md5": "abc"}, + } + } + return task + + def test_custom_name_used_in_generated_video(self): + from packages.application.generated_video_finalize import finalize_generated_video + + task = self._make_task() + session = MagicMock() + with patch( + "packages.adapters.sqlalchemy_impl.generated_video_repository.SQLAlchemyGeneratedVideoRepository" + ) as mock_repo_cls: + mock_repo = MagicMock() + mock_repo_cls.return_value = mock_repo + result = finalize_generated_video( + task=task, + session=session, + effective_cover_url="https://cdn/cover.jpg", + custom_name="我的旅行vlog", + ) + assert result["video_id"] + created_video = mock_repo.create.call_args[0][0] + assert created_video.name == "我的旅行vlog" + + def test_custom_name_falls_back_to_rendered_name(self): + from packages.application.generated_video_finalize import finalize_generated_video + + task = self._make_task() + session = MagicMock() + with patch( + "packages.adapters.sqlalchemy_impl.generated_video_repository.SQLAlchemyGeneratedVideoRepository" + ) as mock_repo_cls: + mock_repo = MagicMock() + mock_repo_cls.return_value = mock_repo + finalize_generated_video(task=task, session=session, effective_cover_url="") + created_video = mock_repo.create.call_args[0][0] + assert created_video.name == "default-name.mp4" + + def test_custom_name_empty_uses_generated_id(self): + from packages.application.generated_video_finalize import finalize_generated_video + + task = self._make_task(extra_meta={"rendered_output": {"file_url": "oss://bucket/v.mp4", "name": ""}}) + session = MagicMock() + with patch( + "packages.adapters.sqlalchemy_impl.generated_video_repository.SQLAlchemyGeneratedVideoRepository" + ) as mock_repo_cls: + mock_repo = MagicMock() + mock_repo_cls.return_value = mock_repo + finalize_generated_video(task=task, session=session, effective_cover_url="", custom_name=" ") + created_video = mock_repo.create.call_args[0][0] + assert created_video.name.startswith("generated-task-cus")