fix(generation): #2028 awaiting_cover preview + from-assets 0-duration tolerance
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
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 / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 1s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 2s
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
Preview Deploy / Deploy Preview Environment (pull_request) Failing after 1m9s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m9s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 3m14s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 3m32s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Successful in 3m38s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 5m2s
CI/CD Pipeline / PR Build Web Image (pull_request) Failing after 5m5s
AI Code Review / AI Code Review (pull_request) Successful in 6m10s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 6m19s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 7m28s
CI/CD Pipeline / Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Security (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been cancelled

- fix /generation/tasks/{id}/results: awaiting_cover 状态下从 extra_meta.rendered_output 合成轻量视频响应,供前端预览与智能封面使用
- fix generation_cover._get_task_video_url: awaiting_cover 兜底读 extra_meta.rendered_output.file_url
- feat POST /generation/tasks/{id}/finalize: 支持 custom_title;响应补全 name/size/duration/thumb/dims
- fix frontend polling: awaiting_cover 视为可预览终态,Step5 完成按钮改用 finalizeGeneration
- fix templates/{id}/editor/clips/from-assets: duration≤0/None 素材跳过,全部无效返 400 '素材尚未完成分析,请稍后重试'
This commit is contained in:
xiaoxia
2026-09-24 14:52:53 +08:00
parent 445375e1cb
commit f2ea497555
17 changed files with 210 additions and 32 deletions
+20 -5
View File
@@ -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,11 @@ 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。
#2024/#2028:任务在 awaiting_cover 状态时 GeneratedVideo 尚未入库,
# 从 extra_meta["rendered_output"]["file_url"] 读取渲染产物路径兜底。
"""
try:
video_repo = get_generated_video_repository(db)
use_case = ListGeneratedVideosByTaskUseCase(video_repo)
@@ -241,6 +242,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
+49 -2
View File
@@ -1050,6 +1050,7 @@ 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
@@ -1057,10 +1058,18 @@ def finalize_generation_task(
download_url = storage_service.get_download_url(video.file_url, expires_seconds=86400)
return FinalizeGenerationResponse(
video_id=video.id,
cover_url=video.thumbnail_url or "",
project_id=getattr(video, "project_id", "") or "",
name=getattr(video, "name", "") or "",
file_url=download_url,
file_size=int(getattr(video, "file_size", 0) or 0),
duration=float(getattr(video, "duration", 0.0) or 0.0),
thumbnail_url=getattr(video, "thumbnail_url", "") or "",
cover_url=video.thumbnail_url or "",
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 +1120,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 not asset:
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),
@@ -762,7 +795,7 @@ def create_clips_from_assets_editor(
clip_duration = 0.0
start_time: float | None = None
# 动态按使用次数排序:优先选使用最少的素材,同次数随机打散
asset_use_counts = {aid: len(used_segments.get(aid, [])) for aid in asset_ids}
asset_use_counts = {aid: len(used_segments.get(aid, [])) for aid in unique_asset_ids}
# 排序键:smart_match 评分(注入随机噪声)→ 使用次数 → 纯随机。
# 噪声让得分接近的素材排名每次浮动,避免同一批素材反复选出相同组合,
# 从素材组合层面降低成片查重率;分差 > SCORE_RANDOM_NOISE_MAX 时排名稳定,
+10 -1
View File
@@ -19,14 +19,23 @@ class FinalizeGenerationRequest(BaseModel):
cover_url: str = Field(
default="", description="用户选定的封面图片 URL;为空则使用任务默认 cover_url(自动截帧/智能封面)"
)
custom_title: str = Field(default="", description="用户自定义的成片标题;为空则沿用生成时的 title_config.text")
class FinalizeGenerationResponse(BaseModel):
"""finalize 响应:返回新创建的成品库视频信息。"""
video_id: str = Field(description="新创建的成品视频 ID")
cover_url: str = Field(default="", description="最终绑定的封面 URL")
project_id: str = Field(default="", description="项目 ID")
name: str = Field(default="", description="成片标题/名称")
file_url: str = Field(default="", description="成品视频 OSS URL")
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")
width: int = Field(default=0, description="宽")
height: int = Field(default=0, description="高")
fps: float = Field(default=0.0, description="帧率")
status: str = Field(default="success", description="success=新建成功;already_finalized=幂等返回已有记录")
is_duplicate: bool = Field(default=False, description="是否被判定为与历史成片重复")
@@ -32,7 +32,9 @@ 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 +60,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 +99,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
+17
View File
@@ -0,0 +1,17 @@
import apiClient from "../client"
import type { FinalizeGenerationRequest, FinalizeGenerationResponse } from "./types"
/** 确认封面后入库(Step5 完成按钮):
* - 幂等;任务处于 awaiting_cover 时根据 extra_meta.rendered_output 创建 GeneratedVideo;
* - 已 completed 则只更新封面/标题,直接返回已有记录。
*/
export const finalizeGeneration = async (
taskId: string,
params: FinalizeGenerationRequest,
): Promise<FinalizeGenerationResponse> => {
const { data } = await apiClient.post<FinalizeGenerationResponse>(
`/generation/tasks/${taskId}/finalize`,
params,
)
return data
}
+5
View File
@@ -3,9 +3,14 @@ export type {
CreatePreviewRequest,
CreatePreviewResponse,
PreviewTaskResponse,
FinalizeGenerationResponse,
} from "./types"
export { createPreview, getPreviewStatus } from "./preview"
export { finalizeGeneration } from "./finalize"
export { generateCover } from "./cover"
export type { GenerateCoverRequest, GenerateCoverResponse } from "./cover"
export { confirmGeneration } from "./confirm"
export type { ConfirmGenerationRequest, ConfirmGenerationResponse, ConfirmGenerationTaskItem } from "./confirm"
+22
View File
@@ -130,3 +130,25 @@ export interface ConfirmGenerationTaskItem {
result_count: number
error_message: string
}
/** #2024 finalize 响应 */
export interface FinalizeGenerationResponse {
status: "success" | "already_finalized"
video_id: string
project_id: string
name: string
file_url: string
file_size: number
duration: number
thumbnail_url: string
cover_url: string
width: number
height: number
fps: number
}
/** #2024 finalize 请求体 */
export interface FinalizeGenerationRequest {
cover_url?: string
custom_title?: string
}
+4 -4
View File
@@ -21,7 +21,7 @@ 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"
import { useTitleStyleUpdaters } from "./hooks/useStep4Title/useTitleStyleUpdaters"
@@ -436,12 +436,12 @@ const GeneratePage: React.FC = () => {
? [finalVideo.generation_task_id]
: []
// 单视频/批量:为每个任务调用 confirm(传入封面)
// 单视频/批量:为每个 awaiting_cover 任务调用 finalize(入库 + 绑定封面)
if (isBatch && previewCovers.length > 0) {
await Promise.all(
taskIds.map(async (taskId, idx) => {
const coverUrl = previewCovers[idx] || ""
return confirmGeneration(taskId, {
return finalizeGeneration(taskId, {
cover_url: coverUrl || undefined,
custom_title: previewTitles[idx] || titleSettings.title || "",
})
@@ -449,7 +449,7 @@ const GeneratePage: React.FC = () => {
)
} else if (finalVideo?.generation_task_id) {
const coverUrl = coverSettings.thumbnail_url || coverSettings.upload_url || ""
await confirmGeneration(finalVideo.generation_task_id, {
await finalizeGeneration(finalVideo.generation_task_id, {
cover_url: coverUrl || undefined,
custom_title: titleSettings.title || "",
})
@@ -37,7 +37,7 @@ const BatchGenerationGrid: React.FC<BatchGenerationGridProps> = ({
<div className="xx-preview-header">
<h3>🎬 正在生成 {tasks.length} 个视频</h3>
<span style={{ fontSize: 13, color: "var(--text-secondary, #666)" }}>
完成 {tasks.filter((t) => t.status === "completed").length} / {tasks.length}
完成 {tasks.filter((t) => t.status === "completed" || t.status === "awaiting_cover").length} / {tasks.length}
</span>
</div>
{/* #1800: grid 列宽 / gap / justify 全部交由 .xx-batch-gen-grid CSS 控制 */}
@@ -49,7 +49,7 @@ const BatchGenerationGrid: React.FC<BatchGenerationGridProps> = ({
<div key={task.taskId} className={`xx-batch-gen-card status-${task.status}`}>
<div className="xx-batch-gen-card-head">
<span className="xx-batch-gen-card-title" title={title}>
{task.status === "completed" ? (
{(task.status === "completed" || task.status === "awaiting_cover") ? (
<CheckCircleFilled
className="xx-batch-gen-card-icon"
style={{ color: "#52c41a" }}
@@ -83,7 +83,7 @@ const BatchGenerationGrid: React.FC<BatchGenerationGridProps> = ({
<div className="xx-batch-gen-card-pct">{Math.round(task.progress)}%</div>
</>
)}
{task.status === "completed" && video && (
{(task.status === "completed" || task.status === "awaiting_cover") && video && (
// 竖屏自适应容器(#1750):成片固定 1080×1920(9:16),
// 视频按真实宽高比 contain 显示,黑底居中,杜绝横屏播放器左右大黑边
<div
@@ -111,7 +111,7 @@ const BatchGenerationGrid: React.FC<BatchGenerationGridProps> = ({
/>
</div>
)}
{task.status === "completed" && !video && (
{(task.status === "completed" || task.status === "awaiting_cover") && !video && (
<div className="xx-batch-gen-card-done">✅ 已完成(成片可在下一步选择封面)</div>
)}
{task.status === "failed" && (
@@ -74,7 +74,7 @@ const Step6CoverSettings: React.FC<Step6CoverSettingsProps> = (props) => {
const uploadInputRef = useRef<HTMLInputElement>(null)
const uploadTargetRef = useRef<number>(0)
const completedVideos = props.generatedVideos.filter((v) => v.status === "completed")
const completedVideos = props.generatedVideos.filter((v) => v.status === "completed" || v.status === "awaiting_cover")
const batchTitles = cardIndexes.map((vi) => previewTitles[vi] || "")
const batchCoversList = cardIndexes.map((vi) => previewCovers[vi] || "")
const batchCovers = useBatchCovers({
@@ -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
/** 完成后的成片视频 */
@@ -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
@@ -93,7 +93,7 @@ export function useBatchCovers({
/** 为第 index 个视频自动生成封面;返回是否成功(供 generateAll 统计) */
const generateOne = useCallback(
async (index: number): Promise<boolean> => {
const finalVideos = generatedVideos.filter((v) => v.status === "completed")
const finalVideos = generatedVideos.filter((v) => v.status === "completed" || v.status === "awaiting_cover")
const target = finalVideos[index] || generatedVideos[index]
if (!target) {
message.warning("该视频尚未生成完成")
@@ -203,7 +203,7 @@ 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")
const total = finalVideos.length
// 待处理:基于调用时刻的 covers 快照判断(已有封面跳过);
// 回写走函数式 updater,循环内不再依赖可能过期的 covers 闭包
@@ -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,7 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
if (batchTasks.length === 0) return
const byVariant = new Map<number, GeneratedVideo>()
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)
}
})
@@ -48,7 +48,7 @@ export function useStep6Cover({
const [templatesError, setTemplatesError] = useState<string | null>(null)
/** 最终成片:取第一个已完成视频 */
const finalVideo = generatedVideos.find((v) => v.status === "completed") || generatedVideos[0]
const finalVideo = generatedVideos.find((v) => v.status === "completed" || v.status === "awaiting_cover") || generatedVideos[0]
/** 从后端加载封面模板列表 */
const loadTemplates = useCallback(async () => {
@@ -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,9 @@ 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"
video_name = (
(custom_name or "").strip() if custom_name else (rendered.name.strip() or f"generated-{task.id[:8]}.mp4")
)
generated_video = GeneratedVideo(
id=video_id,
+9 -1
View File
@@ -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):
@@ -595,6 +601,7 @@ class TestEditorClipsErrorHandling:
)
assert exc_info.value.status_code == 400
# 时长为0的素材被跳过,全部无效时返回「素材尚未完成分析,请稍后重试」
assert "素材可切区间不足" in exc_info.value.detail
# 复用被拒导致无起点时,不应创建任何片段
assert not mock_plan_svc.replace_all_clips_transactional.called
@@ -763,6 +770,7 @@ class TestReuseRatioGate:
)
assert exc_info.value.status_code == 400
# 时长为0的素材被跳过,全部无效时返回「素材尚未完成分析,请稍后重试」
assert "素材可切区间不足" in exc_info.value.detail
# 闸门在复用占比达上限时拒绝:40s 空闲 + 至多 ~10% 复用
assert reused.get("a1", 0.0) <= 8.0 # 不会无限复用