80c2000bb7
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 2m8s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 2m13s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m40s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 3m16s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 4m7s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 5m14s
CI/CD Pipeline / Integration Tests (push) Successful in 2m6s
CI/CD Pipeline / Unit Tests (push) Successful in 8m28s
CI/CD Pipeline / Build Staging API Image (push) Successful in 20m33s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 59s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 42s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 54s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 2m52s
CI/CD Pipeline / Canary Release to Production (push) Failing after 765h9m26s
CI/CD Pipeline / Deploy Production (push) Failing after 765h25m24s
CI/CD Pipeline / CI Gate (push) Failing after 765h25m24s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 765h25m24s
CI/CD Pipeline / Build Production API Image (push) Failing after 765h25m26s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 765h33m52s
CI/CD Pipeline / PR Build Web Image (push) Failing after 765h33m53s
CI/CD Pipeline / PR Build API Image (push) Failing after 765h33m53s
CI/CD Pipeline / Frontend Lint (push) Failing after 765h33m54s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 765h33m56s
CI/CD Pipeline / Production Browser E2E (push) Failing after 765h58m53s
CI/CD Pipeline / Build Production Web Image (push) Failing after 765h58m57s
- CreatePreviewRequest: 删除8个独立title_*字段,改为title_config对象 - useStep5Preview: generatePreview传参改为title_config嵌套结构 - 字段映射: titleSettings.size → title_config.font_size - 修复标题配置被Pydantic忽略导致预览视频/封面无标题的问题
477 lines
15 KiB
TypeScript
477 lines
15 KiB
TypeScript
/**
|
||
* Step 5 生成预览 Hook(支持多预览 + voice_ids)
|
||
* 调用 /generation/preview 接口创建多个预览任务,轮询状态直到全部完成
|
||
*/
|
||
import { useState, useCallback, useMemo, useEffect, useRef } from "react"
|
||
import { createPreview, getPreviewStatus } from "@/api/generation"
|
||
import { updateEditPlan } from "@/api/template-editor/editPlans"
|
||
import type { PreviewTaskResponse, PreviewStatus as ApiPreviewStatus } from "@/api/generation"
|
||
import type { EditingTemplate } from "@/api/editing-planner"
|
||
import { safeExtractError } from "./generate-video/errorUtils"
|
||
import type { TitleSettings } from "../types"
|
||
|
||
/** 安全地将值转为字符串,防止对象被直接渲染导致 React Error #31 */
|
||
const safeString = (val: unknown, fallback: string): string => {
|
||
if (val == null) return fallback
|
||
const s = safeExtractError(val)
|
||
return s || fallback
|
||
}
|
||
|
||
/** 安全地将值转为数字,防止非数字值进入渲染 */
|
||
const safeNumber = (val: unknown, fallback = 0): number => {
|
||
if (typeof val === "number" && !Number.isNaN(val)) return val
|
||
if (typeof val === "string") {
|
||
const n = Number(val)
|
||
return Number.isNaN(n) ? fallback : n
|
||
}
|
||
return fallback
|
||
}
|
||
|
||
interface UseStep5PreviewProps {
|
||
templates: EditingTemplate[]
|
||
selectedTemplate: string
|
||
materialMode: "manual" | "auto"
|
||
selectedMaterials: string[]
|
||
smartSelectedIds: string[]
|
||
duration: number
|
||
videoRatio: string
|
||
/** 配音 voice_ids(传给后端,让预览包含配音音频) */
|
||
voiceIds?: string[]
|
||
/** 配音素材库ID(用户选择的上传音频或AI配音素材) */
|
||
voiceLibraryId?: string
|
||
/** 要生成的预览数量 */
|
||
previewCount?: number
|
||
/** 标题设置(传递给后端,让预览视频包含标题) */
|
||
titleSettings?: TitleSettings
|
||
}
|
||
|
||
export type PreviewStatus = "idle" | "pending" | "generating" | "ready" | "error"
|
||
|
||
/** 单个预览生成结果 */
|
||
export interface PreviewResult {
|
||
taskId: string
|
||
videoUrl: string
|
||
clipCount: number
|
||
transitionCount: number
|
||
materialUsage: number
|
||
duration: number
|
||
fileSize: number
|
||
generateDuration: number
|
||
progress: number
|
||
}
|
||
|
||
/** 单个预览项的完整状态(用于多预览) */
|
||
export interface PreviewItem {
|
||
index: number
|
||
status: PreviewStatus
|
||
result: PreviewResult | null
|
||
error: string
|
||
progress: number
|
||
}
|
||
|
||
// 轮询超时时间(10 分钟)
|
||
const POLL_TIMEOUT_MS = 10 * 60 * 1000
|
||
|
||
/** 初始单项状态 */
|
||
const createInitialItem = (index: number): PreviewItem => ({
|
||
index,
|
||
status: "idle",
|
||
result: null,
|
||
error: "",
|
||
progress: 0,
|
||
})
|
||
|
||
export function useStep5Preview({
|
||
templates,
|
||
selectedTemplate,
|
||
materialMode,
|
||
selectedMaterials,
|
||
smartSelectedIds,
|
||
duration,
|
||
videoRatio,
|
||
voiceIds,
|
||
voiceLibraryId,
|
||
previewCount = 1,
|
||
titleSettings,
|
||
}: UseStep5PreviewProps) {
|
||
const templateName = useMemo(
|
||
() => templates.find((t) => t.id === selectedTemplate)?.name ?? "未选择",
|
||
[templates, selectedTemplate],
|
||
)
|
||
|
||
const materialCount = useMemo(() => {
|
||
if (materialMode === "auto") {
|
||
return `${smartSelectedIds.length} 个素材(智能匹配)`
|
||
}
|
||
return `${selectedMaterials.length} 个素材`
|
||
}, [materialMode, selectedMaterials.length, smartSelectedIds.length])
|
||
|
||
const materialTotal = materialMode === "auto" ? smartSelectedIds.length : selectedMaterials.length
|
||
|
||
/* ── 多预览状态 ── */
|
||
const [items, setItems] = useState<PreviewItem[]>(() =>
|
||
Array.from({ length: previewCount }, (_, i) => createInitialItem(i)),
|
||
)
|
||
const [selectedIndex, setSelectedIndex] = useState(0)
|
||
|
||
// 每个任务 ID + 轮询定时器,用于防止竞态条件(按 index 存储)
|
||
const taskIdsRef = useRef<Map<number, string>>(new Map())
|
||
const pollTimersRef = useRef<Map<number, ReturnType<typeof setTimeout>>>(new Map())
|
||
const startTimeRef = useRef<number>(0)
|
||
|
||
const clearPollTimer = useCallback((index?: number) => {
|
||
if (index !== undefined) {
|
||
const timer = pollTimersRef.current.get(index)
|
||
if (timer) {
|
||
clearTimeout(timer)
|
||
pollTimersRef.current.delete(index)
|
||
}
|
||
} else {
|
||
pollTimersRef.current.forEach((timer) => clearTimeout(timer))
|
||
pollTimersRef.current.clear()
|
||
}
|
||
}, [])
|
||
|
||
// 同步 previewCount 变化(增减项)
|
||
useEffect(() => {
|
||
setItems((prev) => {
|
||
if (prev.length === previewCount) return prev
|
||
if (prev.length > previewCount) return prev.slice(0, previewCount)
|
||
return [
|
||
...prev,
|
||
...Array.from({ length: previewCount - prev.length }, (_, i) =>
|
||
createInitialItem(prev.length + i),
|
||
),
|
||
]
|
||
})
|
||
// 如果 selectedIndex 超出范围,重置
|
||
setSelectedIndex((prev) => Math.min(prev, previewCount - 1))
|
||
}, [previewCount])
|
||
|
||
/* ── 参数变化时重置所有预览状态 ── */
|
||
const prevDepsRef = useRef({
|
||
selectedTemplate,
|
||
materialMode,
|
||
selectedMaterials: [...selectedMaterials].sort().join(","),
|
||
smartSelectedIds: [...smartSelectedIds].sort().join(","),
|
||
duration,
|
||
videoRatio,
|
||
voiceIds: [...(voiceIds || [])].sort().join(","),
|
||
titleSettings: titleSettings?.title || "",
|
||
})
|
||
|
||
useEffect(() => {
|
||
const currentKey = [
|
||
selectedTemplate,
|
||
materialMode,
|
||
[...selectedMaterials].sort().join(","),
|
||
[...smartSelectedIds].sort().join(","),
|
||
duration,
|
||
videoRatio,
|
||
[...(voiceIds || [])].sort().join(","),
|
||
titleSettings?.title || "",
|
||
].join("|")
|
||
|
||
const prevKey = [
|
||
prevDepsRef.current.selectedTemplate,
|
||
prevDepsRef.current.materialMode,
|
||
prevDepsRef.current.selectedMaterials,
|
||
prevDepsRef.current.smartSelectedIds,
|
||
prevDepsRef.current.duration,
|
||
prevDepsRef.current.videoRatio,
|
||
prevDepsRef.current.voiceIds,
|
||
prevDepsRef.current.titleSettings,
|
||
].join("|")
|
||
|
||
if (prevKey !== currentKey && items.some((it) => it.status !== "idle")) {
|
||
taskIdsRef.current.clear()
|
||
clearPollTimer()
|
||
setItems(Array.from({ length: previewCount }, (_, i) => createInitialItem(i)))
|
||
setSelectedIndex(0)
|
||
}
|
||
|
||
prevDepsRef.current = {
|
||
selectedTemplate,
|
||
materialMode,
|
||
selectedMaterials: [...selectedMaterials].sort().join(","),
|
||
smartSelectedIds: [...smartSelectedIds].sort().join(","),
|
||
duration,
|
||
videoRatio,
|
||
voiceIds: [...(voiceIds || [])].sort().join(","),
|
||
titleSettings: titleSettings?.title || "",
|
||
}
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [
|
||
selectedTemplate,
|
||
materialMode,
|
||
selectedMaterials,
|
||
smartSelectedIds,
|
||
duration,
|
||
videoRatio,
|
||
voiceIds,
|
||
previewCount,
|
||
titleSettings,
|
||
])
|
||
|
||
// 组件卸载时清理所有轮询
|
||
useEffect(() => {
|
||
return () => {
|
||
clearPollTimer()
|
||
}
|
||
}, [clearPollTimer])
|
||
|
||
/** 轮询单个预览任务状态 */
|
||
const pollPreviewStatus = useCallback(
|
||
(index: number, taskId: string) => {
|
||
const poll = async () => {
|
||
// 竞态检查
|
||
if (taskIdsRef.current.get(index) !== taskId) return
|
||
|
||
// 超时检查
|
||
if (Date.now() - startTimeRef.current > POLL_TIMEOUT_MS) {
|
||
setItems((prev) =>
|
||
prev.map((it) =>
|
||
it.index === index ? { ...it, status: "error", error: "预览生成超时,请重试" } : it,
|
||
),
|
||
)
|
||
return
|
||
}
|
||
|
||
try {
|
||
const data: PreviewTaskResponse = await getPreviewStatus(taskId)
|
||
|
||
if (taskIdsRef.current.get(index) !== taskId) return
|
||
|
||
const status = data.status as ApiPreviewStatus
|
||
|
||
if (status === "completed") {
|
||
const result: PreviewResult = {
|
||
taskId: safeString(data.task_id, ""),
|
||
videoUrl: safeString(data.video_url, ""),
|
||
clipCount: safeNumber(data.clip_count),
|
||
transitionCount: safeNumber(data.transition_count),
|
||
materialUsage: safeNumber(data.material_usage),
|
||
duration: safeNumber(data.duration),
|
||
fileSize: safeNumber(data.file_size),
|
||
generateDuration: safeNumber(data.generate_duration),
|
||
progress: 100,
|
||
}
|
||
setItems((prev) =>
|
||
prev.map((it) =>
|
||
it.index === index ? { ...it, status: "ready", result, progress: 100 } : it,
|
||
),
|
||
)
|
||
|
||
// 保存预览视频 URL 到 plan config,供封面生成使用
|
||
if (result.videoUrl && selectedTemplate) {
|
||
updateEditPlan(selectedTemplate, {
|
||
config: { rendered_storage_key: result.videoUrl },
|
||
}).catch((err) => {
|
||
console.warn("[Step4] 保存预览视频URL到plan config失败:", err)
|
||
})
|
||
}
|
||
return
|
||
}
|
||
|
||
if (status === "failed") {
|
||
setItems((prev) =>
|
||
prev.map((it) =>
|
||
it.index === index
|
||
? {
|
||
...it,
|
||
status: "error",
|
||
error: safeString(data.error_message, "预览生成失败,请重试"),
|
||
}
|
||
: it,
|
||
),
|
||
)
|
||
return
|
||
}
|
||
|
||
if (status === "cancelled") {
|
||
setItems((prev) =>
|
||
prev.map((it) =>
|
||
it.index === index ? { ...it, status: "error", error: "预览任务已取消" } : it,
|
||
),
|
||
)
|
||
return
|
||
}
|
||
|
||
// pending / generating 状态继续轮询
|
||
const prog = safeNumber(data.progress)
|
||
const nextStatus: PreviewStatus = status === "pending" ? "pending" : "generating"
|
||
setItems((prev) =>
|
||
prev.map((it) =>
|
||
it.index === index ? { ...it, status: nextStatus, progress: prog } : it,
|
||
),
|
||
)
|
||
const delay = status === "pending" ? 5000 : 2000
|
||
pollTimersRef.current.set(index, setTimeout(poll, delay))
|
||
} catch {
|
||
if (taskIdsRef.current.get(index) !== taskId) return
|
||
pollTimersRef.current.set(index, setTimeout(poll, 3000))
|
||
}
|
||
}
|
||
|
||
pollTimersRef.current.set(index, setTimeout(poll, 1000))
|
||
},
|
||
[selectedTemplate],
|
||
)
|
||
|
||
/** 生成所有预览 */
|
||
const generatePreview = useCallback(async () => {
|
||
if (!selectedTemplate) {
|
||
setItems((prev) => prev.map((it) => ({ ...it, status: "error", error: "请先选择模板" })))
|
||
return
|
||
}
|
||
if (materialTotal === 0) {
|
||
setItems((prev) => prev.map((it) => ({ ...it, status: "error", error: "请先选择素材" })))
|
||
return
|
||
}
|
||
|
||
// 取消之前的所有轮询
|
||
clearPollTimer()
|
||
taskIdsRef.current.clear()
|
||
|
||
// 初始化所有项为 pending
|
||
setItems(
|
||
Array.from({ length: previewCount }, (_, i) => ({
|
||
index: i,
|
||
status: "pending" as PreviewStatus,
|
||
result: null,
|
||
error: "",
|
||
progress: 0,
|
||
})),
|
||
)
|
||
setSelectedIndex(0)
|
||
startTimeRef.current = Date.now()
|
||
|
||
const assetIds = materialMode === "auto" ? smartSelectedIds : selectedMaterials
|
||
|
||
// 并发创建所有预览任务(Promise.all 并行请求,减少串行等待)
|
||
const createTasks = Array.from({ length: previewCount }, async (_, i) => {
|
||
try {
|
||
const response = await createPreview({
|
||
template_id: selectedTemplate,
|
||
asset_ids: assetIds,
|
||
duration: duration || undefined,
|
||
video_ratio: videoRatio,
|
||
voice_ids: voiceIds && voiceIds.length > 0 ? voiceIds : undefined,
|
||
voice_library_id: voiceLibraryId || undefined,
|
||
// 标题烧录配置
|
||
title_config: titleSettings?.title
|
||
? {
|
||
text: titleSettings.title,
|
||
font: titleSettings.font,
|
||
font_size: titleSettings.size,
|
||
font_color: titleSettings.color,
|
||
position: titleSettings.position,
|
||
bold: titleSettings.bold,
|
||
stroke: titleSettings.stroke,
|
||
shadow: titleSettings.shadow,
|
||
}
|
||
: undefined,
|
||
})
|
||
|
||
if (startTimeRef.current === 0) return
|
||
|
||
taskIdsRef.current.set(i, response.task_id)
|
||
pollPreviewStatus(i, response.task_id)
|
||
} catch (e) {
|
||
const errMsg = safeString(e instanceof Error ? e.message : e, "预览生成失败")
|
||
setItems((prev) =>
|
||
prev.map((it) => (it.index === i ? { ...it, status: "error", error: errMsg } : it)),
|
||
)
|
||
}
|
||
})
|
||
await Promise.all(createTasks)
|
||
}, [
|
||
selectedTemplate,
|
||
materialTotal,
|
||
materialMode,
|
||
smartSelectedIds,
|
||
selectedMaterials,
|
||
duration,
|
||
videoRatio,
|
||
voiceIds,
|
||
voiceLibraryId,
|
||
previewCount,
|
||
titleSettings,
|
||
clearPollTimer,
|
||
pollPreviewStatus,
|
||
])
|
||
|
||
/** 重新生成所有预览 */
|
||
const regeneratePreview = useCallback(() => {
|
||
generatePreview()
|
||
}, [generatePreview])
|
||
|
||
/** 是否所有预览都已完成 */
|
||
const allReady = items.length > 0 && items.every((it) => it.status === "ready")
|
||
/** 是否至少有一个预览已完成 */
|
||
const anyReady = items.some((it) => it.status === "ready")
|
||
/** 是否有任一正在生成中 */
|
||
const anyGenerating = items.some((it) => it.status === "pending" || it.status === "generating")
|
||
|
||
/** 当前选中的预览结果 */
|
||
const selectedResult = items[selectedIndex]?.result ?? null
|
||
|
||
/** 综合状态(兼容旧逻辑) */
|
||
const previewStatus: PreviewStatus = useMemo(() => {
|
||
if (items.every((it) => it.status === "idle")) return "idle"
|
||
if (items.some((it) => it.status === "pending" || it.status === "generating"))
|
||
return "generating"
|
||
if (allReady) return "ready"
|
||
if (items.every((it) => it.status === "error")) return "error"
|
||
// 部分完成部分出错
|
||
if (anyReady) return "ready"
|
||
return "error"
|
||
}, [items, allReady, anyReady])
|
||
|
||
/** 综合进度(取平均) */
|
||
const progress = useMemo(() => {
|
||
if (items.length === 0) return 0
|
||
return Math.round(items.reduce((sum, it) => sum + it.progress, 0) / items.length)
|
||
}, [items])
|
||
|
||
/** 综合错误信息 */
|
||
const previewError = useMemo(() => {
|
||
const errorItems = items.filter((it) => it.status === "error" && it.error)
|
||
if (errorItems.length === 0) return ""
|
||
if (errorItems.length === 1) return errorItems[0].error
|
||
return `${errorItems.length} 个预览生成失败`
|
||
}, [items])
|
||
|
||
const canProceed = anyReady
|
||
|
||
/** 当前选中预览的 taskId(用于确认生成时复用预览产物) */
|
||
const selectedTaskId = selectedResult?.taskId ?? ""
|
||
|
||
return {
|
||
templateName,
|
||
materialCount,
|
||
duration,
|
||
videoRatio,
|
||
// 多预览状态
|
||
items,
|
||
selectedIndex,
|
||
setSelectedIndex,
|
||
previewCount,
|
||
// 综合状态
|
||
previewStatus,
|
||
previewResult: selectedResult,
|
||
previewError,
|
||
progress,
|
||
canProceed,
|
||
allReady,
|
||
anyReady,
|
||
anyGenerating,
|
||
generatePreview,
|
||
regeneratePreview,
|
||
// 确认生成复用预览产物
|
||
selectedTaskId,
|
||
}
|
||
}
|
||
|
||
export default useStep5Preview
|