Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 88269bf3e0 |
@@ -76,7 +76,10 @@ 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 不可用或评分失败时,返回第一帧。
|
||||
@@ -229,10 +232,7 @@ def _persist_cover_frame(
|
||||
|
||||
|
||||
def _get_task_video_url(db: Session, task_id: str) -> Optional[str]:
|
||||
"""从 GenerationTask 关联的 GeneratedVideo 中获取视频 storage_key / URL.
|
||||
|
||||
#2028: awaiting_cover 状态下 GeneratedVideo 尚未入库,兜底从 task.extra_meta.rendered_output.file_url 读取。
|
||||
"""
|
||||
"""从 GenerationTask 关联的 GeneratedVideo 中获取视频 storage_key / URL."""
|
||||
try:
|
||||
video_repo = get_generated_video_repository(db)
|
||||
use_case = ListGeneratedVideosByTaskUseCase(video_repo)
|
||||
@@ -241,20 +241,6 @@ 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,29 +1050,17 @@ 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
|
||||
|
||||
try:
|
||||
download_url = storage_service.get_download_url(video.file_url, expires_seconds=86400)
|
||||
except Exception:
|
||||
download_url = video.file_url
|
||||
download_url = storage_service.get_download_url(video.file_url, expires_seconds=86400)
|
||||
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(getattr(video, "is_duplicate", False)),
|
||||
is_duplicate=bool(video.is_duplicate),
|
||||
)
|
||||
|
||||
|
||||
@@ -1123,44 +1111,6 @@ 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,50 +682,17 @@ 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 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:
|
||||
if asset and hasattr(asset, "duration"):
|
||||
asset_durations[asset_id] = float(asset.duration or 0.0)
|
||||
# 计算 smart_match 综合评分,用于候选排序
|
||||
smart_score, _ = score_asset(asset)
|
||||
asset_smart_scores[asset_id] = smart_score
|
||||
except Exception:
|
||||
asset_smart_scores[asset_id] = 0.0
|
||||
# 读取场景切换点缓存(新素材未检测过时为 None,走随机起点兜底)
|
||||
try:
|
||||
# 读取场景切换点缓存(新素材未检测过时为 None,走随机起点兜底)
|
||||
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,23 +19,14 @@ 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="成品视频下载 URL")
|
||||
width: int = Field(default=0)
|
||||
height: int = Field(default=0)
|
||||
fps: float = Field(default=0.0)
|
||||
file_url: str = Field(default="", description="成品视频 OSS URL")
|
||||
status: str = Field(default="success", description="success=新建成功;already_finalized=幂等返回已有记录")
|
||||
is_duplicate: bool = Field(default=False, description="是否被判定为与历史成片重复")
|
||||
|
||||
|
||||
@@ -32,13 +32,7 @@ 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,
|
||||
custom_title: Optional[str] = None,
|
||||
):
|
||||
def finalize_task(self, task_id: str, user_id: str, cover_url: Optional[str] = None):
|
||||
"""执行 finalize:状态校验 → 幂等 → 绑定封面 → 入库 → 推进 completed。
|
||||
|
||||
Returns:
|
||||
@@ -64,15 +58,9 @@ 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:
|
||||
@@ -103,23 +91,12 @@ 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
|
||||
|
||||
@@ -4,25 +4,14 @@ 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 (
|
||||
|
||||
@@ -21,6 +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"
|
||||
@@ -436,25 +437,42 @@ const GeneratePage: React.FC = () => {
|
||||
? [finalVideo.generation_task_id]
|
||||
: []
|
||||
|
||||
// 单视频/批量:为每个 awaiting_cover 任务调用 finalize(入库 + 绑定封面 + 自定义标题)
|
||||
// 第一步:confirm(同步封面+标题,把 is_preview 翻 false,任务进入/停留在 awaiting_cover)
|
||||
let confirmedTaskIds: string[] = []
|
||||
if (isBatch && previewCovers.length > 0) {
|
||||
await Promise.all(
|
||||
const results = await Promise.all(
|
||||
taskIds.map(async (taskId, idx) => {
|
||||
const coverUrl = previewCovers[idx] || ""
|
||||
return finalizeGeneration(taskId, {
|
||||
const resp = await confirmGeneration(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 || ""
|
||||
await finalizeGeneration(finalVideo.generation_task_id, {
|
||||
const resp = await confirmGeneration(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")
|
||||
|
||||
@@ -37,9 +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" || t.status === "awaiting_cover").length} /{" "}
|
||||
{tasks.length}
|
||||
完成 {tasks.filter((t) => t.status === "completed").length} / {tasks.length}
|
||||
</span>
|
||||
</div>
|
||||
{/* #1800: grid 列宽 / gap / justify 全部交由 .xx-batch-gen-grid CSS 控制 */}
|
||||
@@ -51,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 === "awaiting_cover" ? (
|
||||
{task.status === "completed" ? (
|
||||
<CheckCircleFilled
|
||||
className="xx-batch-gen-card-icon"
|
||||
style={{ color: "#52c41a" }}
|
||||
@@ -85,7 +83,7 @@ const BatchGenerationGrid: React.FC<BatchGenerationGridProps> = ({
|
||||
<div className="xx-batch-gen-card-pct">{Math.round(task.progress)}%</div>
|
||||
</>
|
||||
)}
|
||||
{(task.status === "completed" || task.status === "awaiting_cover") && video && (
|
||||
{task.status === "completed" && video && (
|
||||
// 竖屏自适应容器(#1750):成片固定 1080×1920(9:16),
|
||||
// 视频按真实宽高比 contain 显示,黑底居中,杜绝横屏播放器左右大黑边
|
||||
<div
|
||||
@@ -113,7 +111,7 @@ const BatchGenerationGrid: React.FC<BatchGenerationGridProps> = ({
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{(task.status === "completed" || task.status === "awaiting_cover") && !video && (
|
||||
{task.status === "completed" && !video && (
|
||||
<div className="xx-batch-gen-card-done">✅ 已完成(成片可在下一步选择封面)</div>
|
||||
)}
|
||||
{task.status === "failed" && (
|
||||
|
||||
@@ -74,10 +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" || v.status === "awaiting_cover" || v.status === "awaiting_cover",
|
||||
)
|
||||
const completedVideos = props.generatedVideos.filter((v) => v.status === "completed")
|
||||
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" | "awaiting_cover" | "failed"
|
||||
status: "running" | "completed" | "failed"
|
||||
progress: number
|
||||
error: string | null
|
||||
/** 完成后的成片视频 */
|
||||
@@ -96,7 +96,7 @@ export function useGenerationPolling({
|
||||
runId: number,
|
||||
callbacks?: {
|
||||
onTaskProgress?: (pct: number) => void
|
||||
onTaskCompleted?: (videos: unknown[], taskStatus?: "completed" | "awaiting_cover") => void
|
||||
onTaskCompleted?: (videos: unknown[]) => void
|
||||
onTaskFailed?: (msg: string) => void
|
||||
},
|
||||
): Promise<unknown[]> => {
|
||||
@@ -111,7 +111,7 @@ export function useGenerationPolling({
|
||||
if (cancelledRef.current || done) return
|
||||
consecutiveErrors = 0
|
||||
|
||||
if (task.status === "completed" || task.status === "awaiting_cover") {
|
||||
if (task.status === "completed") {
|
||||
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, task.status as "completed" | "awaiting_cover")
|
||||
callbacks?.onTaskCompleted?.(videos)
|
||||
resolve(videos)
|
||||
return
|
||||
}
|
||||
@@ -259,11 +259,10 @@ export function useGenerationPolling({
|
||||
onBatchTaskUpdate?.(taskId, { status: "running", progress: pct })
|
||||
reportAggregateProgress()
|
||||
},
|
||||
onTaskCompleted: (videos, taskStatus) => {
|
||||
onTaskCompleted: (videos) => {
|
||||
progressMap.set(taskId, 100)
|
||||
resultMap.set(taskId, videos)
|
||||
const _finalStatus: "completed" | "awaiting_cover" = taskStatus ?? "completed"
|
||||
onBatchTaskUpdate?.(taskId, { status: _finalStatus, progress: 100, videos })
|
||||
onBatchTaskUpdate?.(taskId, { status: "completed", progress: 100, videos })
|
||||
reportAggregateProgress()
|
||||
checkAllSettled()
|
||||
},
|
||||
@@ -294,9 +293,8 @@ export function useGenerationPolling({
|
||||
}
|
||||
pollSingleTask(taskId, Date.now(), {
|
||||
onTaskProgress: (pct) => onBatchTaskUpdate?.(taskId, { status: "running", progress: pct }),
|
||||
onTaskCompleted: (videos, taskStatus) => {
|
||||
const _finalStatus: "completed" | "awaiting_cover" = taskStatus ?? "completed"
|
||||
onBatchTaskUpdate?.(taskId, { status: _finalStatus, progress: 100, videos })
|
||||
onTaskCompleted: (videos) => {
|
||||
onBatchTaskUpdate?.(taskId, { status: "completed", progress: 100, videos })
|
||||
message.success(`视频 ${variantIndex + 1} 重试成功`)
|
||||
},
|
||||
onTaskFailed: (msg) => onBatchTaskUpdate?.(taskId, { status: "failed", error: msg }),
|
||||
|
||||
@@ -93,12 +93,7 @@ export function useBatchCovers({
|
||||
/** 为第 index 个视频自动生成封面;返回是否成功(供 generateAll 统计) */
|
||||
const generateOne = useCallback(
|
||||
async (index: number): Promise<boolean> => {
|
||||
const finalVideos = generatedVideos.filter(
|
||||
(v) =>
|
||||
v.status === "completed" ||
|
||||
v.status === "awaiting_cover" ||
|
||||
v.status === "awaiting_cover",
|
||||
)
|
||||
const finalVideos = generatedVideos.filter((v) => v.status === "completed")
|
||||
const target = finalVideos[index] || generatedVideos[index]
|
||||
if (!target) {
|
||||
message.warning("该视频尚未生成完成")
|
||||
@@ -208,10 +203,7 @@ export function useBatchCovers({
|
||||
|
||||
/** 一键全部自动生成(串行,避免队列限流;单个失败不阻塞,结束后分级提示) */
|
||||
const generateAll = useCallback(async () => {
|
||||
const finalVideos = generatedVideos.filter(
|
||||
(v) =>
|
||||
v.status === "completed" || v.status === "awaiting_cover" || v.status === "awaiting_cover",
|
||||
)
|
||||
const finalVideos = generatedVideos.filter((v) => v.status === "completed")
|
||||
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.status === "awaiting_cover" && t.videos.length === 0)
|
||||
t.status === "completed" && t.videos.length === 0
|
||||
? {
|
||||
...t,
|
||||
videos: (videos as GeneratedVideo[]).filter(
|
||||
@@ -83,10 +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.status === "awaiting_cover" && t.videos && t.videos.length > 0)
|
||||
) {
|
||||
if (t.status === "completed" && t.videos && t.videos.length > 0) {
|
||||
byVariant.set(t.variantIndex, t.videos[0] as GeneratedVideo)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -48,11 +48,7 @@ export function useStep6Cover({
|
||||
const [templatesError, setTemplatesError] = useState<string | null>(null)
|
||||
|
||||
/** 最终成片:取第一个已完成视频 */
|
||||
const finalVideo =
|
||||
generatedVideos.find(
|
||||
(v) =>
|
||||
v.status === "completed" || v.status === "awaiting_cover" || v.status === "awaiting_cover",
|
||||
) || generatedVideos[0]
|
||||
const finalVideo = generatedVideos.find((v) => v.status === "completed") || generatedVideos[0]
|
||||
|
||||
/** 从后端加载封面模板列表 */
|
||||
const loadTemplates = useCallback(async () => {
|
||||
|
||||
@@ -12,12 +12,6 @@ server {
|
||||
client_max_body_size 800m;
|
||||
|
||||
# SPA routing - index.html 禁止缓存,确保每次获取最新版本
|
||||
location = /index.html {
|
||||
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
||||
add_header Pragma "no-cache";
|
||||
expires 0;
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri /index.html;
|
||||
# HTML 文档(含 try_files 回退的 SPA 路由,如 /login /app/dashboard)一律 no-cache,
|
||||
|
||||
@@ -12,13 +12,6 @@ server {
|
||||
|
||||
client_max_body_size 800m;
|
||||
|
||||
# SPA routing - index.html 禁止缓存,确保每次获取最新版本
|
||||
location = /index.html {
|
||||
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
||||
add_header Pragma "no-cache";
|
||||
expires 0;
|
||||
}
|
||||
|
||||
# SPA routing - all routes to index.html
|
||||
# 注意:不能加 $uri/,否则 /assets 等与构建产物目录同名的路由会被当成目录访问,返回 403
|
||||
location / {
|
||||
|
||||
@@ -99,7 +99,6 @@ def finalize_generated_video(
|
||||
task,
|
||||
session: Session,
|
||||
effective_cover_url: str = "",
|
||||
custom_name: str | None = None,
|
||||
) -> dict:
|
||||
"""将 awaiting_cover 的任务正式入库。
|
||||
|
||||
@@ -123,8 +122,7 @@ def finalize_generated_video(
|
||||
raise ValueError(f"task {task.id} rendered_output.file_url 为空,无法 finalize")
|
||||
|
||||
video_id = uuid4().hex
|
||||
_custom = (custom_name or "").strip() if custom_name else ""
|
||||
video_name = _custom or (rendered.name.strip() or f"generated-{task.id[:8]}.mp4")
|
||||
video_name = rendered.name.strip() or f"generated-{task.id[:8]}.mp4"
|
||||
|
||||
generated_video = GeneratedVideo(
|
||||
id=video_id,
|
||||
|
||||
@@ -1,167 +0,0 @@
|
||||
"""#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"]))
|
||||
@@ -367,13 +367,7 @@ class TestEditorClipsDurationAndStartTime:
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 400
|
||||
# 时长为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
|
||||
)
|
||||
assert "素材可切区间不足" 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):
|
||||
@@ -902,75 +896,3 @@ 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
|
||||
|
||||
@@ -204,79 +204,3 @@ 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")
|
||||
|
||||
Reference in New Issue
Block a user