From 00d2e4714c2bf41ae987c04bca5584a7d3d21da3 Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Tue, 18 Aug 2026 01:05:25 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20=E5=88=A0=E9=99=A4=20useStep5Previe?= =?UTF-8?q?w=20-=20=E5=89=8D=E7=AB=AF=E9=A2=84=E8=A7=88=E4=B8=8D=E5=86=8D?= =?UTF-8?q?=E4=BE=9D=E8=B5=96=E5=90=8E=E7=AB=AFFFmpeg=E6=B8=B2=E6=9F=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pages/generate/hooks/useStep5Preview.ts | 476 ------------------ 1 file changed, 476 deletions(-) delete mode 100644 apps/web/src/pages/generate/hooks/useStep5Preview.ts diff --git a/apps/web/src/pages/generate/hooks/useStep5Preview.ts b/apps/web/src/pages/generate/hooks/useStep5Preview.ts deleted file mode 100644 index 583e69051..000000000 --- a/apps/web/src/pages/generate/hooks/useStep5Preview.ts +++ /dev/null @@ -1,476 +0,0 @@ -/** - * 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(() => - Array.from({ length: previewCount }, (_, i) => createInitialItem(i)), - ) - const [selectedIndex, setSelectedIndex] = useState(0) - - // 每个任务 ID + 轮询定时器,用于防止竞态条件(按 index 存储) - const taskIdsRef = useRef>(new Map()) - const pollTimersRef = useRef>>(new Map()) - const startTimeRef = useRef(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