From d3b3a0d31edb53913e828b83661f85250a45e3ab Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Fri, 7 Aug 2026 21:40:30 +0800 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20#1281=20=E6=94=AF=E6=8C=81=E7=94=9F?= =?UTF-8?q?=E6=88=90=E5=A4=9A=E4=B8=AA=E9=A2=84=E8=A7=88=E8=A7=86=E9=A2=91?= =?UTF-8?q?=20+=20=E9=85=8D=E9=9F=B3=E5=89=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 步骤顺序调整: - 模板(1)→素材(2)→预览(3)→标题(4)→配音(5)→封面(6)→确认(7) - 改为:模板(1)→素材(2)→配音(3)→预览(4)→标题(5)→封面(6)→确认(7) 多预览功能: - 新增 useStep4Preview hook(替代 useStep3Preview),支持 voiceIds + previewCount - 新增 Step4GeneratePreview 组件,支持预览数量选择(1/2/3个)和网格展示 - 每个预览独立轮询,可选择查看不同版本 - 右侧面板展示当前选中的预览视频 配音传入预览: - 生成预览时传递 voice_ids 参数(后端 API 已支持) - 根据 voiceMode 自动选择 selectedVoice 或 selectedClonedVoice 文件变更: - constants.ts: STEPS 顺序调整 - useStep4Preview.ts: 新 hook,多预览 + voice_ids - Step4GeneratePreview.tsx: 新组件,多预览网格 UI - GeneratePage.tsx: 步骤阈值调整(>=4 显示预览,>=5 显示结果) - GenerateStepContent.tsx: case 映射更新 - useStepNavigation.ts: 校验步骤号更新 - PreviewVideoPanel.tsx: import 路径更新 - 删除旧文件 useStep3Preview.ts / Step3GeneratePreview.tsx - 测试文件同步更新 --- apps/web/src/pages/generate/GeneratePage.tsx | 70 ++- .../components/GenerateStepContent.tsx | 77 ++-- .../generate/components/PreviewVideoPanel.tsx | 10 +- .../components/Step3GeneratePreview.tsx | 128 ------ .../components/Step4GeneratePreview.tsx | 278 ++++++++++++ apps/web/src/pages/generate/constants.ts | 6 +- .../pages/generate/hooks/useStep3Preview.ts | 308 ------------- .../pages/generate/hooks/useStep4Preview.ts | 429 ++++++++++++++++++ .../pages/generate/hooks/useStepNavigation.ts | 8 +- .../src/test/pages/generate/smoke.test.tsx | 4 +- .../generate/step3-preview.smoke.test.tsx | 17 - .../generate/step4-preview.smoke.test.tsx | 17 + 12 files changed, 831 insertions(+), 521 deletions(-) delete mode 100644 apps/web/src/pages/generate/components/Step3GeneratePreview.tsx create mode 100644 apps/web/src/pages/generate/components/Step4GeneratePreview.tsx delete mode 100644 apps/web/src/pages/generate/hooks/useStep3Preview.ts create mode 100644 apps/web/src/pages/generate/hooks/useStep4Preview.ts delete mode 100644 apps/web/src/test/pages/generate/step3-preview.smoke.test.tsx create mode 100644 apps/web/src/test/pages/generate/step4-preview.smoke.test.tsx diff --git a/apps/web/src/pages/generate/GeneratePage.tsx b/apps/web/src/pages/generate/GeneratePage.tsx index 768536f99..5dda8a745 100644 --- a/apps/web/src/pages/generate/GeneratePage.tsx +++ b/apps/web/src/pages/generate/GeneratePage.tsx @@ -1,6 +1,6 @@ /** - * 智能剪辑页面 — V21 原型 1:1 还原 - * 7 步向导:选择模板 → 选择素材 → 生成预览 → 选择标题 → 选择配音 → 选择封面 → 确认生成 + * 智能剪辑页面 — V22 多预览 + 配音前置 + * 7 步向导:选择模板 → 选择素材 → 选择配音 → 生成预览 → 选择标题 → 选择封面 → 确认生成 * 左右布局:左侧 generate-form + 右侧 generate-preview * 主组件仅保留整体布局与事件编排 * 状态管理 → hooks/useGenerateFormState @@ -9,7 +9,7 @@ * 底部按钮 → components/GenerateStepActions * 生成核心逻辑 → hooks/useGenerateVideo */ -import React from "react" +import React, { useState, useMemo } from "react" import { Modal, message } from "antd" import { useNavigate } from "react-router-dom" import type { VoiceClone } from "@/api/voice-clone" @@ -24,7 +24,7 @@ import GenerateStepActions from "./components/GenerateStepActions" import { useGenerateFormState } from "./hooks/useGenerateFormState" import { useStepNavigation } from "./hooks/useStepNavigation" import { useGenerateVideo } from "./hooks/useGenerateVideo" -import { useStep3Preview } from "./hooks/useStep3Preview" +import { useStep4Preview } from "./hooks/useStep4Preview" import "./generate.css" const GeneratePage: React.FC = () => { @@ -80,8 +80,20 @@ const GeneratePage: React.FC = () => { message.success("音色克隆成功!") } - /* ── Step3 预览生成 ── */ - const step3Preview = useStep3Preview({ + /* ── 预览数量(多预览) ── */ + const [previewCount, setPreviewCount] = useState(1) + + /* ── 根据 voiceMode 构建 voiceIds 传给预览接口 ── */ + const previewVoiceIds = useMemo(() => { + if (voiceMode === "clone") { + return selectedClonedVoice ? [selectedClonedVoice] : [] + } + // preset / custom 模式 + return selectedVoice ? [selectedVoice] : [] + }, [voiceMode, selectedVoice, selectedClonedVoice]) + + /* ── Step4 预览生成(多预览 + voice_ids) ── */ + const step4Preview = useStep4Preview({ templates: userTemplates, selectedTemplate, materialMode, @@ -89,6 +101,8 @@ const GeneratePage: React.FC = () => { smartSelectedIds, duration, videoRatio, + voiceIds: previewVoiceIds, + previewCount, }) /* ── 步骤导航 ── */ @@ -100,7 +114,7 @@ const GeneratePage: React.FC = () => { selectedMaterials, smartSelectedIds, titleSettings, - previewReady: step3Preview.canProceed, + previewReady: step4Preview.canProceed, }) /* ── 视频生成核心逻辑 ── */ @@ -187,14 +201,20 @@ const GeneratePage: React.FC = () => { onDismissError={handleDismissError} presetVoices={presetVoices} videoRatio={videoRatio} - previewStatus={step3Preview.previewStatus} - previewResult={step3Preview.previewResult} - previewError={step3Preview.previewError} - previewTemplateName={step3Preview.templateName} - previewMaterialCount={step3Preview.materialCount} - previewProgress={step3Preview.progress} - onGeneratePreview={step3Preview.generatePreview} - onRegeneratePreview={step3Preview.regeneratePreview} + /* Step4 多预览 */ + previewCount={previewCount} + onPreviewCountChange={setPreviewCount} + previewItems={step4Preview.items} + previewSelectedIndex={step4Preview.selectedIndex} + onSelectPreview={step4Preview.setSelectedIndex} + previewOverallStatus={step4Preview.previewStatus} + previewOverallError={step4Preview.previewError} + previewOverallProgress={step4Preview.progress} + previewAnyGenerating={step4Preview.anyGenerating} + previewTemplateName={step4Preview.templateName} + previewMaterialCount={step4Preview.materialCount} + onGeneratePreview={step4Preview.generatePreview} + onRegeneratePreview={step4Preview.regeneratePreview} /> { {/* ════ 右侧:预览 + 生成结果 ════ */}
- {/* 预览视频面板(Step3+ 常驻) */} - {currentStep >= 3 && ( + {/* 预览视频面板(Step4+ 常驻,展示选中的预览) */} + {currentStep >= 4 && ( = 5 ? titleSettings : undefined} /> )} - {/* 正式生成结果(Step4+ 才显示) */} - {currentStep >= 4 && ( + {/* 正式生成结果(Step5+ 才显示) */} + {currentStep >= 5 && ( void + previewItems: PreviewItem[] + previewSelectedIndex: number + onSelectPreview: (index: number) => void + previewOverallStatus: PreviewStatus + previewOverallError: string + previewOverallProgress: number + previewAnyGenerating: boolean previewTemplateName: string previewMaterialCount: string - previewProgress: number onGeneratePreview: () => void onRegeneratePreview: () => void } @@ -94,6 +100,7 @@ export const GenerateStepContent: React.FC = (props) = selectedVoice, onSelectedVoiceChange, voiceMode, + onVoiceModeChange, selectedClonedVoice, clonedVoices, generateCount, @@ -107,12 +114,17 @@ export const GenerateStepContent: React.FC = (props) = onDismissError, presetVoices, videoRatio, - previewStatus, - previewResult, - previewError, + previewCount, + onPreviewCountChange, + previewItems, + previewSelectedIndex, + onSelectPreview, + previewOverallStatus, + previewOverallError, + previewOverallProgress, + previewAnyGenerating, previewTemplateName, previewMaterialCount, - previewProgress, onGeneratePreview, onRegeneratePreview, } = props @@ -139,31 +151,36 @@ export const GenerateStepContent: React.FC = (props) = ) case 3: return ( - ) case 4: return ( - ) case 5: return ( - ) case 6: diff --git a/apps/web/src/pages/generate/components/PreviewVideoPanel.tsx b/apps/web/src/pages/generate/components/PreviewVideoPanel.tsx index 8409adecb..99df4811c 100644 --- a/apps/web/src/pages/generate/components/PreviewVideoPanel.tsx +++ b/apps/web/src/pages/generate/components/PreviewVideoPanel.tsx @@ -1,7 +1,7 @@ /** * 右侧预览视频面板 - * Step3 生成预览后常驻显示预览视频 - * Step4+ 用 Canvas 绘制标题预览(替代 CSS overlay,与 ASS 渲染行为一致) + * Step4 生成预览后常驻显示预览视频 + * Step5+ 用 Canvas 绘制标题预览(替代 CSS overlay,与 ASS 渲染行为一致) * * 设计说明:标题预览仅在有视频时显示(叠加在视频画面上方)。 * 无视频状态(idle/loading/error)下不再单独显示标题预览,这是有意为之的设计简化。 @@ -13,7 +13,7 @@ */ import React, { useRef, useEffect, useCallback } from "react" import { PlayCircleOutlined, LoadingOutlined } from "@ant-design/icons" -import type { PreviewResult, PreviewStatus } from "../hooks/useStep3Preview" +import type { PreviewResult, PreviewStatus } from "../hooks/useStep4Preview" import type { TitleSettings } from "../types" interface PreviewVideoPanelProps { @@ -23,9 +23,9 @@ interface PreviewVideoPanelProps { progress: number videoRatio: string onRegenerate: () => void - /** 标题文字(Step4 起传入) */ + /** 标题文字(Step5 起传入) */ titleText?: string - /** 标题样式设置(Step4 起传入) */ + /** 标题样式设置(Step5 起传入) */ titleSettings?: TitleSettings } diff --git a/apps/web/src/pages/generate/components/Step3GeneratePreview.tsx b/apps/web/src/pages/generate/components/Step3GeneratePreview.tsx deleted file mode 100644 index e13441ad2..000000000 --- a/apps/web/src/pages/generate/components/Step3GeneratePreview.tsx +++ /dev/null @@ -1,128 +0,0 @@ -/** - * Step 3 生成预览组件 - * 调用后端预览生成接口,展示真实视频预览 - */ -import React from "react" -import { - CheckCircleFilled, - LoadingOutlined, - ReloadOutlined, - PlayCircleOutlined, - ExclamationCircleFilled, - ClockCircleOutlined, -} from "@ant-design/icons" -import type { PreviewResult, PreviewStatus } from "../hooks/useStep3Preview" - -interface Step3GeneratePreviewProps { - templateName: string - materialCount: string - duration: number - videoRatio: string - previewStatus: PreviewStatus - previewResult: PreviewResult | null - previewError: string - progress: number - onGeneratePreview: () => void - onRegeneratePreview: () => void -} - -const Step3GeneratePreview: React.FC = ({ - templateName: _templateName, - materialCount: _materialCount, - videoRatio: _videoRatio, - previewStatus, - previewResult, - previewError, - progress, - onGeneratePreview, - onRegeneratePreview, -}) => { - return ( -
-

🎬 生成预览

- - {/* 预览生成按钮 */} - {previewStatus === "idle" && ( -
-
- -

一键生成剪辑预览

-

- AI 将根据您选择的模板和素材,智能生成完整视频预览(480p 低清版) -

-
- -
- )} - - {/* 排队中 */} - {previewStatus === "pending" && ( -
- -

预览排队中...

-

正在等待渲染资源,请稍候

-
- )} - - {/* 生成中 */} - {previewStatus === "generating" && ( -
- -

正在生成预览视频... {progress}%

-

AI 正在剪辑素材并合成预览视频

-
-
-
-
- )} - - {/* 生成失败 */} - {previewStatus === "error" && ( -
- -

预览生成失败

-

- {typeof previewError === "string" && previewError ? previewError : "请稍后重试"} -

- -
- )} - - {/* 生成成功 - 视频预览 */} - {previewStatus === "ready" && previewResult && ( - <> -
- - 预览生成成功,确认效果后进入下一步 - -
- - {/* 视频播放器 */} -
-
- - )} -
- ) -} - -export default Step3GeneratePreview diff --git a/apps/web/src/pages/generate/components/Step4GeneratePreview.tsx b/apps/web/src/pages/generate/components/Step4GeneratePreview.tsx new file mode 100644 index 000000000..7a9ec190f --- /dev/null +++ b/apps/web/src/pages/generate/components/Step4GeneratePreview.tsx @@ -0,0 +1,278 @@ +/** + * Step 4 生成预览组件(支持多预览) + * 调用后端预览生成接口,展示多个真实视频预览(网格布局) + */ +import React from "react" +import { + CheckCircleFilled, + LoadingOutlined, + ReloadOutlined, + PlayCircleOutlined, + ExclamationCircleFilled, + ClockCircleOutlined, +} from "@ant-design/icons" +import type { PreviewItem, PreviewStatus } from "../hooks/useStep4Preview" + +interface Step4GeneratePreviewProps { + templateName: string + materialCount: string + duration: number + videoRatio: string + previewCount: number + onPreviewCountChange: (count: number) => void + items: PreviewItem[] + selectedIndex: number + onSelectPreview: (index: number) => void + overallStatus: PreviewStatus + overallError: string + overallProgress: number + anyGenerating: boolean + onGeneratePreview: () => void + onRegeneratePreview: () => void +} + +/** 预览数量选项 */ +const PREVIEW_COUNT_OPTIONS = [ + { value: 1, label: "1个" }, + { value: 2, label: "2个" }, + { value: 3, label: "3个" }, +] + +const Step4GeneratePreview: React.FC = ({ + templateName: _templateName, + materialCount: _materialCount, + videoRatio: _videoRatio, + previewCount, + onPreviewCountChange, + items, + selectedIndex, + onSelectPreview, + overallStatus, + overallError, + overallProgress, + anyGenerating, + onGeneratePreview, + onRegeneratePreview, +}) => { + const isIdle = overallStatus === "idle" + const isError = overallStatus === "error" && !items.some((it) => it.status === "ready") + + return ( +
+

🎬 生成预览

+ + {/* 预览数量选择器(仅在 idle 状态显示) */} + {isIdle && ( +
+ 预览数量: +
+ {PREVIEW_COUNT_OPTIONS.map((opt) => ( + + ))} +
+ {previewCount > 1 && ( + 生成多个预览可对比不同剪辑效果 + )} +
+ )} + + {/* 预览生成按钮(idle 状态) */} + {isIdle && ( +
+
+ +

一键生成剪辑预览

+

+ AI 将根据您选择的模板、素材和配音,智能生成 + {previewCount > 1 ? `${previewCount}个不同版本的` : ""}视频预览(480p 低清版) +

+
+ +
+ )} + + {/* 整体排队中(所有都在 pending) */} + {anyGenerating && items.every((it) => it.status === "pending") && ( +
+ +

预览排队中...

+

正在等待渲染资源,请稍候

+
+ )} + + {/* 多预览网格(生成中/完成/部分完成) */} + {(anyGenerating || overallStatus === "ready") && items.length > 0 && ( +
+ {items.map((item) => { + const isSelected = item.index === selectedIndex + return ( +
{ + if (item.status === "ready") onSelectPreview(item.index) + }} + style={{ + borderRadius: 8, + border: isSelected ? "2px solid #1677ff" : "1px solid #e8e8e8", + overflow: "hidden", + cursor: item.status === "ready" ? "pointer" : "default", + opacity: item.status === "error" ? 0.6 : 1, + transition: "all 0.2s", + }} + > + {/* 缩略图/状态区域 */} +
+ {item.status === "ready" && item.result && ( +
+ {/* 底部信息 */} + {item.status === "ready" && item.result && ( +
+ {item.result.duration.toFixed(1)}秒 + {item.result.clipCount}段 +
+ )} +
+ ) + })} +
+ )} + + {/* 整体进度条(多预览生成中) */} + {anyGenerating && ( +
+
+
+ )} + + {/* 全部完成提示 */} + {overallStatus === "ready" && ( +
+ + + {items.filter((it) => it.status === "ready").length} 个预览生成成功 + {items.length > 1 ? ",点击选择要查看的版本" : ",确认效果后进入下一步"} + + +
+ )} + + {/* 全部失败 */} + {isError && ( +
+ +

预览生成失败

+

+ {typeof overallError === "string" && overallError ? overallError : "请稍后重试"} +

+ +
+ )} +
+ ) +} + +export default Step4GeneratePreview diff --git a/apps/web/src/pages/generate/constants.ts b/apps/web/src/pages/generate/constants.ts index cd27a0bf3..478523f38 100644 --- a/apps/web/src/pages/generate/constants.ts +++ b/apps/web/src/pages/generate/constants.ts @@ -31,9 +31,9 @@ export const VOICE_GENDER_ICON: Record = { export const STEPS = [ { key: 1, label: "选择模板" }, { key: 2, label: "选择素材" }, - { key: 3, label: "生成预览" }, - { key: 4, label: "选择标题" }, - { key: 5, label: "选择配音" }, + { key: 3, label: "选择配音" }, + { key: 4, label: "生成预览" }, + { key: 5, label: "选择标题" }, { key: 6, label: "选择封面" }, { key: 7, label: "确认生成" }, ] diff --git a/apps/web/src/pages/generate/hooks/useStep3Preview.ts b/apps/web/src/pages/generate/hooks/useStep3Preview.ts deleted file mode 100644 index 8cfbcc1f8..000000000 --- a/apps/web/src/pages/generate/hooks/useStep3Preview.ts +++ /dev/null @@ -1,308 +0,0 @@ -/** - * Step 3 生成预览 Hook - * 调用 /generation/preview 接口创建预览任务,轮询状态直到完成 - */ -import { useState, useCallback, useMemo, useEffect, useRef } from "react" -import { createPreview, getPreviewStatus } from "@/api/generation" -import type { PreviewTaskResponse, PreviewStatus as ApiPreviewStatus } from "@/api/generation" -import type { EditingTemplate } from "@/api/editing-planner" -import { safeExtractError } from "./generate-video/errorUtils" - -/** 安全地将值转为字符串,防止对象被直接渲染导致 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 UseStep3PreviewProps { - templates: EditingTemplate[] - selectedTemplate: string - materialMode: "manual" | "auto" - selectedMaterials: string[] - smartSelectedIds: string[] - duration: number - videoRatio: string - titleText?: string - voiceId?: string -} - -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 -} - -// 轮询超时时间(10 分钟) -const POLL_TIMEOUT_MS = 10 * 60 * 1000 -export function useStep3Preview({ - templates, - selectedTemplate, - materialMode, - selectedMaterials, - smartSelectedIds, - duration, - videoRatio, -}: UseStep3PreviewProps) { - 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 [previewStatus, setPreviewStatus] = useState("idle") - const [previewResult, setPreviewResult] = useState(null) - const [previewError, setPreviewError] = useState("") - const [progress, setProgress] = useState(0) - - // 任务 ID + 轮询定时器,用于防止竞态条件 - const currentTaskIdRef = useRef(null) - const pollTimerRef = useRef>() - const startTimeRef = useRef(0) - - const clearPollTimer = useCallback(() => { - if (pollTimerRef.current) { - clearTimeout(pollTimerRef.current) - pollTimerRef.current = undefined - } - }, []) - - /* ── 参数变化时重置预览状态 ── */ - const prevDepsRef = useRef({ - selectedTemplate, - materialMode, - selectedMaterials: [...selectedMaterials].sort().join(","), - smartSelectedIds: [...smartSelectedIds].sort().join(","), - duration, - videoRatio, - }) - - useEffect(() => { - const currentKey = [ - selectedTemplate, - materialMode, - [...selectedMaterials].sort().join(","), - [...smartSelectedIds].sort().join(","), - duration, - videoRatio, - ].join("|") - - const prevKey = [ - prevDepsRef.current.selectedTemplate, - prevDepsRef.current.materialMode, - prevDepsRef.current.selectedMaterials, - prevDepsRef.current.smartSelectedIds, - prevDepsRef.current.duration, - prevDepsRef.current.videoRatio, - ].join("|") - - if (prevKey !== currentKey && previewStatus !== "idle") { - // 参数变化,作废当前任务 - currentTaskIdRef.current = null - clearPollTimer() - setPreviewStatus("idle") - setPreviewResult(null) - setPreviewError("") - setProgress(0) - } - - prevDepsRef.current = { - selectedTemplate, - materialMode, - selectedMaterials: [...selectedMaterials].sort().join(","), - smartSelectedIds: [...smartSelectedIds].sort().join(","), - duration, - videoRatio, - } - }, [ - selectedTemplate, - materialMode, - selectedMaterials, - smartSelectedIds, - duration, - videoRatio, - previewStatus, - clearPollTimer, - ]) - - // 组件卸载时清理轮询 - useEffect(() => { - return () => { - clearPollTimer() - } - }, [clearPollTimer]) - - /** 轮询预览任务状态 */ - const pollPreviewStatus = useCallback((taskId: string) => { - const poll = async () => { - // 检查是否已被取消(参数变化或重新生成) - if (currentTaskIdRef.current !== taskId) return - - // 超时检查 - if (Date.now() - startTimeRef.current > POLL_TIMEOUT_MS) { - setPreviewError("预览生成超时,请重试") - setPreviewStatus("error") - return - } - - try { - const data: PreviewTaskResponse = await getPreviewStatus(taskId) - - if (currentTaskIdRef.current !== taskId) return - - const status = data.status as ApiPreviewStatus - - if (status === "completed") { - setProgress(100) - setPreviewResult({ - 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, - }) - setPreviewStatus("ready") - return - } - - if (status === "failed") { - setPreviewError(safeString(data.error_message, "预览生成失败,请重试")) - setPreviewStatus("error") - return - } - - if (status === "cancelled") { - setPreviewError("预览任务已取消") - setPreviewStatus("error") - return - } - - // pending / generating 状态继续轮询 - setProgress(safeNumber(data.progress)) - if (status === "pending") { - setPreviewStatus("pending") - pollTimerRef.current = setTimeout(poll, 5000) - } else { - setPreviewStatus("generating") - pollTimerRef.current = setTimeout(poll, 2000) - } - } catch (pollErr) { - if (currentTaskIdRef.current !== taskId) return - // 轮询出错,延迟后重试 - pollTimerRef.current = setTimeout(poll, 3000) - } - } - - // 首次延迟 1 秒开始轮询 - pollTimerRef.current = setTimeout(poll, 1000) - }, []) - - /** 生成预览 */ - const generatePreview = useCallback(async () => { - if (!selectedTemplate) { - setPreviewError("请先选择模板") - setPreviewStatus("error") - return - } - if (materialTotal === 0) { - setPreviewError("请先选择素材") - setPreviewStatus("error") - return - } - - // 取消之前的轮询 - clearPollTimer() - - setPreviewStatus("pending") - setPreviewError("") - setProgress(0) - setPreviewResult(null) - startTimeRef.current = Date.now() - - try { - const assetIds = materialMode === "auto" ? smartSelectedIds : selectedMaterials - const response = await createPreview({ - template_id: selectedTemplate, - asset_ids: assetIds, - duration: duration || undefined, - video_ratio: videoRatio, - }) - - // 竞态检查 - if (startTimeRef.current === 0) return // 已被重置 - - currentTaskIdRef.current = response.task_id - pollPreviewStatus(response.task_id) - } catch (e) { - setPreviewError(safeString(e instanceof Error ? e.message : e, "预览生成失败")) - setPreviewStatus("error") - } - }, [ - selectedTemplate, - materialTotal, - materialMode, - smartSelectedIds, - selectedMaterials, - duration, - videoRatio, - clearPollTimer, - pollPreviewStatus, - ]) - - /** 重新生成预览 */ - const regeneratePreview = useCallback(() => { - generatePreview() - }, [generatePreview]) - - /** 是否可以下一步(预览已生成) */ - const canProceed = previewStatus === "ready" - - return { - templateName, - materialCount, - duration, - videoRatio, - // 预览生成状态 - previewStatus, - previewResult, - previewError, - progress, - canProceed, - generatePreview, - regeneratePreview, - } -} - -export default useStep3Preview diff --git a/apps/web/src/pages/generate/hooks/useStep4Preview.ts b/apps/web/src/pages/generate/hooks/useStep4Preview.ts new file mode 100644 index 000000000..116e7b2e0 --- /dev/null +++ b/apps/web/src/pages/generate/hooks/useStep4Preview.ts @@ -0,0 +1,429 @@ +/** + * Step 4 生成预览 Hook(支持多预览 + voice_ids) + * 调用 /generation/preview 接口创建多个预览任务,轮询状态直到全部完成 + */ +import { useState, useCallback, useMemo, useEffect, useRef } from "react" +import { createPreview, getPreviewStatus } from "@/api/generation" +import type { PreviewTaskResponse, PreviewStatus as ApiPreviewStatus } from "@/api/generation" +import type { EditingTemplate } from "@/api/editing-planner" +import { safeExtractError } from "./generate-video/errorUtils" + +/** 安全地将值转为字符串,防止对象被直接渲染导致 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 UseStep4PreviewProps { + templates: EditingTemplate[] + selectedTemplate: string + materialMode: "manual" | "auto" + selectedMaterials: string[] + smartSelectedIds: string[] + duration: number + videoRatio: string + /** 配音 voice_ids(传给后端,让预览包含配音音频) */ + voiceIds?: string[] + /** 要生成的预览数量 */ + previewCount?: number +} + +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 useStep4Preview({ + templates, + selectedTemplate, + materialMode, + selectedMaterials, + smartSelectedIds, + duration, + videoRatio, + voiceIds, + previewCount = 1, +}: UseStep4PreviewProps) { + 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(","), + }) + + useEffect(() => { + const currentKey = [ + selectedTemplate, + materialMode, + [...selectedMaterials].sort().join(","), + [...smartSelectedIds].sort().join(","), + duration, + videoRatio, + [...(voiceIds || [])].sort().join(","), + ].join("|") + + const prevKey = [ + prevDepsRef.current.selectedTemplate, + prevDepsRef.current.materialMode, + prevDepsRef.current.selectedMaterials, + prevDepsRef.current.smartSelectedIds, + prevDepsRef.current.duration, + prevDepsRef.current.videoRatio, + prevDepsRef.current.voiceIds, + ].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(","), + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ + selectedTemplate, + materialMode, + selectedMaterials, + smartSelectedIds, + duration, + videoRatio, + voiceIds, + previewCount, + ]) + + // 组件卸载时清理所有轮询 + 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, + ), + ) + 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)) + }, []) + + /** 生成所有预览 */ + 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 + + // 并发创建所有预览任务 + for (let i = 0; i < previewCount; 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, + }) + + 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)), + ) + } + } + }, [ + selectedTemplate, + materialTotal, + materialMode, + smartSelectedIds, + selectedMaterials, + duration, + videoRatio, + voiceIds, + previewCount, + 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 + + return { + templateName, + materialCount, + duration, + videoRatio, + // 多预览状态 + items, + selectedIndex, + setSelectedIndex, + previewCount, + // 综合状态 + previewStatus, + previewResult: selectedResult, + previewError, + progress, + canProceed, + allReady, + anyReady, + anyGenerating, + generatePreview, + regeneratePreview, + } +} + +export default useStep4Preview diff --git a/apps/web/src/pages/generate/hooks/useStepNavigation.ts b/apps/web/src/pages/generate/hooks/useStepNavigation.ts index 76fa2f982..29b4ed6f1 100644 --- a/apps/web/src/pages/generate/hooks/useStepNavigation.ts +++ b/apps/web/src/pages/generate/hooks/useStepNavigation.ts @@ -1,6 +1,7 @@ /** * GeneratePage 步骤导航 * 管理步骤切换与各步骤的前置校验 + * 步骤顺序:模板(1) → 素材(2) → 配音(3) → 预览(4) → 标题(5) → 封面(6) → 确认(7) */ import { message } from "antd" import type { TitleSettings } from "../types" @@ -13,7 +14,7 @@ export interface UseStepNavigationOptions { selectedMaterials: string[] smartSelectedIds: string[] titleSettings: TitleSettings - /** Step3 是否已生成预览(Step3校验用) */ + /** Step4 是否已生成预览 */ previewReady: boolean } @@ -47,11 +48,12 @@ export const useStepNavigation = (options: UseStepNavigationOptions): UseStepNav message.warning("请先进行智能匹配并选择素材") return } - if (currentStep === 3 && !previewReady) { + // Step3 配音:配音为可选项,不强制校验,用户可跳过 + if (currentStep === 4 && !previewReady) { message.warning("请先生成剪辑预览") return } - if (currentStep === 4 && !titleSettings.title.trim()) { + if (currentStep === 5 && !titleSettings.title.trim()) { message.warning("请选择或输入标题") return } diff --git a/apps/web/src/test/pages/generate/smoke.test.tsx b/apps/web/src/test/pages/generate/smoke.test.tsx index 0171f0629..c4553f49d 100755 --- a/apps/web/src/test/pages/generate/smoke.test.tsx +++ b/apps/web/src/test/pages/generate/smoke.test.tsx @@ -17,7 +17,7 @@ import "@/api/generation/types" // 直接引入所有 Step 组件,建立完整依赖链 import "@/pages/generate/GeneratePage" import "@/pages/generate/components/Step2MaterialSelect" -import "@/pages/generate/components/Step3GeneratePreview" +import "@/pages/generate/components/Step4GeneratePreview" import "@/pages/generate/components/Step4TitleSettings" import "@/pages/generate/components/Step5VoiceSelect" import "@/pages/generate/components/PreviewVideoPanel" @@ -47,7 +47,7 @@ describe("GeneratePage module smoke test", () => { }) }) import "@/pages/generate/hooks/useGenerateVideo" -import "@/pages/generate/hooks/useStep3Preview" +import "@/pages/generate/hooks/useStep4Preview" import "@/pages/generate/hooks/generate-video/useGenerationPolling" import "@/pages/generate/hooks/useGenerateFormState" import "@/pages/generate/hooks/useGenerateFormState/useTemplateSelection" diff --git a/apps/web/src/test/pages/generate/step3-preview.smoke.test.tsx b/apps/web/src/test/pages/generate/step3-preview.smoke.test.tsx deleted file mode 100644 index 1ce57e7b7..000000000 --- a/apps/web/src/test/pages/generate/step3-preview.smoke.test.tsx +++ /dev/null @@ -1,17 +0,0 @@ -/** - * Step3GeneratePreview smoke test - * 确保 vitest related 模式能匹配到第3步预览生成相关文件的改动 - */ -import { describe, it, expect } from "vitest" - -import "@/pages/generate/components/Step3GeneratePreview" -import "@/pages/generate/hooks/useStep3Preview" -import "@/pages/generate/hooks/useStepNavigation" -import "@/pages/generate/components/GenerateStepContent" -import "@/pages/generate/GeneratePage" - -describe("Step3GeneratePreview module smoke test", () => { - it("should load all step3 preview modules", () => { - expect(true).toBe(true) - }) -}) diff --git a/apps/web/src/test/pages/generate/step4-preview.smoke.test.tsx b/apps/web/src/test/pages/generate/step4-preview.smoke.test.tsx new file mode 100644 index 000000000..57da83454 --- /dev/null +++ b/apps/web/src/test/pages/generate/step4-preview.smoke.test.tsx @@ -0,0 +1,17 @@ +/** + * Step4GeneratePreview smoke test + * 确保 vitest related 模式能匹配到第4步预览生成相关文件的改动 + */ +import { describe, it, expect } from "vitest" + +import "@/pages/generate/components/Step4GeneratePreview" +import "@/pages/generate/hooks/useStep4Preview" +import "@/pages/generate/hooks/useStepNavigation" +import "@/pages/generate/components/GenerateStepContent" +import "@/pages/generate/GeneratePage" + +describe("Step4GeneratePreview module smoke test", () => { + it("should load all step4 preview modules", () => { + expect(true).toBe(true) + }) +}) -- 2.54.0 From 1e7232e1f654b3f151f63bc0a10074477bf84575 Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Fri, 7 Aug 2026 22:38:23 +0800 Subject: [PATCH 2/4] fix: remove unused onVoiceModeChange from GenerateStepContent destructuring Resolves ESLint no-unused-vars and TypeScript TS6133 errors that caused Frontend Lint, PR Build Web Image, and Deploy Preview to all fail. --- apps/web/src/pages/generate/components/GenerateStepContent.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/web/src/pages/generate/components/GenerateStepContent.tsx b/apps/web/src/pages/generate/components/GenerateStepContent.tsx index a1b128ef7..3d44ffe6a 100644 --- a/apps/web/src/pages/generate/components/GenerateStepContent.tsx +++ b/apps/web/src/pages/generate/components/GenerateStepContent.tsx @@ -100,7 +100,6 @@ export const GenerateStepContent: React.FC = (props) = selectedVoice, onSelectedVoiceChange, voiceMode, - onVoiceModeChange, selectedClonedVoice, clonedVoices, generateCount, -- 2.54.0 From 7b1485ce55e0be85cf05276b0e61e18ff38fe415 Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Fri, 7 Aug 2026 22:49:16 +0800 Subject: [PATCH 3/4] fix: add explicit String() conversion for voice IDs in previewVoiceIds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses AI Code Review concern about type safety — selectedVoice and selectedClonedVoice are already strings, but explicit String() makes the intent clearer and prevents future regressions if types change. --- apps/web/src/pages/generate/GeneratePage.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/src/pages/generate/GeneratePage.tsx b/apps/web/src/pages/generate/GeneratePage.tsx index 5dda8a745..a3adf2bbe 100644 --- a/apps/web/src/pages/generate/GeneratePage.tsx +++ b/apps/web/src/pages/generate/GeneratePage.tsx @@ -86,10 +86,10 @@ const GeneratePage: React.FC = () => { /* ── 根据 voiceMode 构建 voiceIds 传给预览接口 ── */ const previewVoiceIds = useMemo(() => { if (voiceMode === "clone") { - return selectedClonedVoice ? [selectedClonedVoice] : [] + return selectedClonedVoice ? [String(selectedClonedVoice)] : [] } // preset / custom 模式 - return selectedVoice ? [selectedVoice] : [] + return selectedVoice ? [String(selectedVoice)] : [] }, [voiceMode, selectedVoice, selectedClonedVoice]) /* ── Step4 预览生成(多预览 + voice_ids) ── */ -- 2.54.0 From 0150e6ecf2f91bab4cb867d1ba1512f1e868bd25 Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Fri, 7 Aug 2026 22:55:26 +0800 Subject: [PATCH 4/4] fix: add explicit string type annotations for previewVoiceIds AI Code Review incorrectly flagged selectedVoice/selectedClonedVoice as object types. They are string (voice IDs) per useGenerateFormState types. Added explicit type annotations and comments to make this unambiguous. --- apps/web/src/pages/generate/GeneratePage.tsx | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/apps/web/src/pages/generate/GeneratePage.tsx b/apps/web/src/pages/generate/GeneratePage.tsx index a3adf2bbe..ec513f211 100644 --- a/apps/web/src/pages/generate/GeneratePage.tsx +++ b/apps/web/src/pages/generate/GeneratePage.tsx @@ -84,12 +84,16 @@ const GeneratePage: React.FC = () => { const [previewCount, setPreviewCount] = useState(1) /* ── 根据 voiceMode 构建 voiceIds 传给预览接口 ── */ - const previewVoiceIds = useMemo(() => { + /* selectedVoice / selectedClonedVoice 均为 string 类型(voice ID), + 见 useGenerateFormState 返回值类型定义 */ + const previewVoiceIds = useMemo((): string[] => { if (voiceMode === "clone") { - return selectedClonedVoice ? [String(selectedClonedVoice)] : [] + const id: string = selectedClonedVoice + return id ? [id] : [] } // preset / custom 模式 - return selectedVoice ? [String(selectedVoice)] : [] + const id: string = selectedVoice + return id ? [id] : [] }, [voiceMode, selectedVoice, selectedClonedVoice]) /* ── Step4 预览生成(多预览 + voice_ids) ── */ -- 2.54.0