diff --git a/apps/web/src/api/tasks/types.ts b/apps/web/src/api/tasks/types.ts index 4dd381eef..685d7fea1 100644 --- a/apps/web/src/api/tasks/types.ts +++ b/apps/web/src/api/tasks/types.ts @@ -71,6 +71,16 @@ export interface CreateGenerationTaskRequest { duration?: number /** 视频宽高比,如 "9:16" */ video_ratio?: string + /** #1970:剪辑模式 random/narrative */ + assembly_mode?: "random" | "narrative" + /** #1970:叙事模式下的文案 ID */ + script_id?: string + /** #1970:TTS 音色 ID */ + tts_voice_id?: string + /** #1970:TTS 音色来源 preset/clone */ + tts_voice_source?: "preset" | "clone" + /** #1970:智能降重开关(默认 true) */ + dedup_enabled?: boolean /** 标题烧录配置 */ title_config?: { text?: string diff --git a/apps/web/src/pages/generate/GeneratePage.tsx b/apps/web/src/pages/generate/GeneratePage.tsx index e7ca18edd..2560101dd 100644 --- a/apps/web/src/pages/generate/GeneratePage.tsx +++ b/apps/web/src/pages/generate/GeneratePage.tsx @@ -11,6 +11,9 @@ import type { VoiceClone } from "@/api/voice-clone" import { useQuery } from "@tanstack/react-query" import { useCloneProgress } from "@/hooks/useCloneProgress" import CloneModal from "@/components/voice/CloneModal" +import VoiceSelectModal from "./components/VoiceSelectModal" +import ScriptSelectModal from "./components/ScriptSelectModal" +import TtsVoiceModal from "./components/TtsVoiceModal" import GenerateHeader from "./components/GenerateHeader" import FrontendPreviewPlayer from "./components/FrontendPreviewPlayer" import CanvasPreviewGrid from "./components/CanvasPreviewGrid" @@ -62,11 +65,26 @@ const GeneratePage: React.FC = () => { selectedVoice, setSelectedVoice, voiceMode, + setVoiceMode, selectedClonedVoice, + setSelectedClonedVoice, + editMode, + setEditMode, + selectedScript, + setSelectedScript, + ttsVoiceId, + setTtsVoiceId, + ttsVoiceSource, + setTtsVoiceSource, + ttsVoiceAssetId, + setTtsVoiceAssetId, + dedupEnabled, + setDedupEnabled, cloneModalOpen, setCloneModalOpen, videoRatio, + setVideoRatio, duration, style, autoSubtitles, @@ -124,6 +142,11 @@ const GeneratePage: React.FC = () => { /* ── 数量选择弹窗 ── */ const [countModalOpen, setCountModalOpen] = useState(false) + /* ── #1970 流程重构:分支弹窗 ── */ + const [voiceModalOpen, setVoiceModalOpen] = useState(false) + const [scriptModalOpen, setScriptModalOpen] = useState(false) + const [ttsModalOpen, setTtsModalOpen] = useState(false) + /* ── 标题样式回调 ── */ const styleUpdaters = useTitleStyleUpdaters({ titleSettings, @@ -301,6 +324,12 @@ const GeneratePage: React.FC = () => { selectedClonedVoice, coverSettings, videoRatio, + editMode, + selectedScript, + ttsVoiceId, + ttsVoiceSource, + ttsVoiceAssetId, + dedupEnabled, style, duration, autoSubtitles, @@ -340,7 +369,7 @@ const GeneratePage: React.FC = () => { return Array.from({ length: count }, (_, i) => list[i] ?? "") }) setSelectedVariantIds(Array.from({ length: count }, (_, i) => i)) - setCurrentStep(2) + setCurrentStep(3) }, [ setPreviewCount, @@ -354,6 +383,58 @@ const GeneratePage: React.FC = () => { ], ) + /* ── #1970:Step1 弹窗回调 ── */ + const handleVoiceModalConfirm = useCallback( + (voiceAssetId: string) => { + setSelectedVoice(voiceAssetId) + setVoiceMode("custom") + setVoiceModalOpen(false) + setCurrentStep(2) + }, + [setSelectedVoice, setVoiceMode, setCurrentStep], + ) + + const handleScriptModalConfirm = useCallback( + (script: import("@/api/scripts").ScriptItem) => { + setSelectedScript(script) + // 自动带入标题(若标题为空则预填) + if (!titleSettings.title?.trim() && script.title) { + setTitleSettings((prev) => ({ ...prev, title: script.title, aiAutoSelect: false })) + } + setScriptModalOpen(false) + // 自动打开 TTS 弹窗 + setTtsModalOpen(true) + }, + [setSelectedScript, setTitleSettings, titleSettings.title], + ) + + const handleTtsSynthesized = useCallback( + (payload: { voiceAssetId: string; ttsVoiceId: string; ttsVoiceSource: "preset" | "clone" }) => { + setTtsVoiceId(payload.ttsVoiceId) + setTtsVoiceSource(payload.ttsVoiceSource) + setTtsVoiceAssetId(payload.voiceAssetId) + if (payload.ttsVoiceSource === "clone") { + setSelectedClonedVoice(payload.ttsVoiceId) + setVoiceMode("clone") + } else { + setSelectedVoice(payload.ttsVoiceId) + setVoiceMode("preset") + } + setTtsModalOpen(false) + message.success("配音合成成功") + setCurrentStep(2) + }, + [ + setTtsVoiceId, + setTtsVoiceSource, + setTtsVoiceAssetId, + setSelectedVoice, + setSelectedClonedVoice, + setVoiceMode, + setCurrentStep, + ], + ) + /* ── 步骤3「确认生成视频」:校验通过 → 创建正式生成任务 → 跳步骤4看实时进展 ── */ const handleConfirmGenerate = useCallback(async () => { // 积分预检查 @@ -412,12 +493,20 @@ const GeneratePage: React.FC = () => { const { goNext, goPrev } = useStepNavigation({ currentStep, setCurrentStep, + editMode, materialMode, selectedMaterials, smartSelectedIds, titleSettings, generated, onOpenCountModal: () => setCountModalOpen(true), + onOpenStep1Modal: () => { + if (editMode === "random") { + setVoiceModalOpen(true) + } else { + setScriptModalOpen(true) + } + }, }) /* ── 最终成片 ── */ @@ -545,6 +634,16 @@ const GeneratePage: React.FC = () => { coverSettings={coverSettings} onCoverSettingsChange={setCoverSettings} selectedVoice={selectedVoice} + editMode={editMode} + onEditModeChange={setEditMode} + dedupEnabled={dedupEnabled} + onDedupEnabledChange={setDedupEnabled} + onPreviewCountChange={setPreviewCount} + videoRatio={videoRatio} + onVideoRatioChange={(r) => setVideoRatio(r)} + selectedScript={selectedScript} + ttsVoiceId={ttsVoiceId} + ttsVoiceSource={ttsVoiceSource} onSelectedVoiceChange={setSelectedVoice} onServerClipsChange={setServerClips} generating={generating} @@ -671,6 +770,27 @@ const GeneratePage: React.FC = () => { onClose={() => setCloneModalOpen(false)} onSuccess={handleCloneSuccess} /> + + {/* #1970 流程弹窗 */} + setVoiceModalOpen(false)} + onConfirm={handleVoiceModalConfirm} + /> + setScriptModalOpen(false)} + onConfirm={handleScriptModalConfirm} + /> + setTtsModalOpen(false)} + onSynthesized={handleTtsSynthesized} + /> ) } diff --git a/apps/web/src/pages/generate/components/GenerateStepContent.tsx b/apps/web/src/pages/generate/components/GenerateStepContent.tsx index 937f1b5c7..6a331f980 100644 --- a/apps/web/src/pages/generate/components/GenerateStepContent.tsx +++ b/apps/web/src/pages/generate/components/GenerateStepContent.tsx @@ -1,14 +1,16 @@ /** - * GeneratePage 步骤内容渲染(#1899 简化为 5 步,#1913 传递 selectedTemplate) - * 步骤顺序:素材(1) → 配音(2) → 标题(3) → 确认生成(4) → 封面(5) - * 步骤3预览(Canvas 网格)与步骤4进度(批量渲染网格)由 GeneratePage 直接渲染在左侧大区域。 + * GeneratePage 步骤内容渲染(#1970 流程重构) + * 步骤顺序:选择模式(1) → 选择素材(2) → 选择标题(3) → 确认生成(4) → 选择封面(5) + * 原步骤"选择配音"已从主流程移除,改为 Step1 下一步分支弹窗(VoiceSelectModal / ScriptSelectModal → TtsVoiceModal)。 */ import React from "react" import type { EditPlanClip } from "@/api/template-editor" import type { CoverConfig } from "../types/cover" import type { TitleSettings } from "../types" +import type { ScriptItem } from "@/api/scripts" +import Step1EditMode from "./Step1EditMode" +import type { EditMode } from "./Step1EditMode" import Step2MaterialSelect from "../components/Step2MaterialSelect" -import Step3VoiceWithMode from "./Step3VoiceWithMode" import Step4TitleSettings from "../components/Step4TitleSettings" import Step6CoverSettings from "../components/Step6CoverSettings" import BatchGenerationGrid from "./BatchGenerationGrid" @@ -17,19 +19,28 @@ import type { GeneratedVideo } from "@/api/template-editor" export interface GenerateStepContentProps { currentStep: number - /* 片段数量(#1899) */ + /* Step1:剪辑模式 + 生成设置 */ + editMode: EditMode + onEditModeChange: (m: EditMode) => void + dedupEnabled: boolean + onDedupEnabledChange: (v: boolean) => void + /* ── 片段数量(#1899) ── */ clipCount: number onClipCountChange: (n: number) => void - /* 素材 */ + /* ── 生成数量/比例(Step1 设置) ── */ + previewCount: number + onPreviewCountChange: (n: number) => void + videoRatio: "9:16" | "16:9" + onVideoRatioChange: (r: "9:16" | "16:9") => void + /* ── 素材 ── */ materialMode: "manual" | "auto" onMaterialModeChange: (mode: "manual" | "auto") => void selectedMaterials: string[] onSelectedMaterialsChange: (ids: string[]) => void smartSelectedIds: string[] onSmartSelectedIdsChange: (ids: string[]) => void - /* 当前选中的模板/草稿 ID;空串时由后端自动兜底(#1913) */ selectedTemplate?: string - /* 标题 */ + /* ── 标题 ── */ titleSettings: TitleSettings onTitleSettingsChange: (settings: TitleSettings) => void onUpdatePosition: (position: string) => void @@ -42,14 +53,14 @@ export interface GenerateStepContentProps { onApplyPreset: (presetKey: string) => void activePreset: string | null titlePresets: { key: string; label: string; previewStyle: React.CSSProperties }[] - /* 封面 */ + /* ── 封面 ── */ coverSettings: CoverConfig onCoverSettingsChange: (settings: CoverConfig) => void - /* 配音 */ + /* ── 配音 ── */ selectedVoice: string onSelectedVoiceChange: (id: string) => void onServerClipsChange: (clips: EditPlanClip[]) => void - /* 生成 */ + /* ── 生成 ── */ generating: boolean generated: boolean generateError: string | null @@ -58,14 +69,10 @@ export interface GenerateStepContentProps { onRetry: () => void onRetryBatchTask: (taskId: string) => void onDismissError: () => void - /** 批量:每个正式生成任务的独立状态(步骤4进度网格) */ batchTasks: BatchTaskState[] - /** BGM 开关 */ bgm: boolean - /** BGM 配置 */ bgmConfig?: { enabled: boolean; music_id?: string } - /* ── 批量生成(#1677)── */ - previewCount: number + /* ── 批量生成 ── */ previewTitles: string[] onPreviewTitlesChange: (titles: string[]) => void voiceModePerVideo: boolean @@ -74,15 +81,27 @@ export interface GenerateStepContentProps { onVoiceLibraryIdsChange: (ids: string[]) => void previewCovers: string[] onPreviewCoversChange: (urls: string[]) => void - /** 批量模式勾选的变体索引 */ selectedVariantIds?: number[] + /* ── 摘要信息(#1970 Step4 展示用) ── */ + selectedScript: ScriptItem | null + ttsVoiceId: string + ttsVoiceSource: "preset" | "clone" } export const GenerateStepContent: React.FC = (props) => { + // Only destructure props actually referenced in JSX below const { currentStep, + editMode, + onEditModeChange, + dedupEnabled, + onDedupEnabledChange, clipCount, onClipCountChange, + previewCount, + onPreviewCountChange, + videoRatio, + onVideoRatioChange, materialMode, onMaterialModeChange, selectedMaterials, @@ -104,31 +123,24 @@ export const GenerateStepContent: React.FC = (props) = titlePresets, coverSettings, onCoverSettingsChange, - selectedVoice, - onSelectedVoiceChange, onServerClipsChange, generating, - generated, generateError, progress, onRetry, generatedVideos, batchTasks, onRetryBatchTask, - previewCount, previewTitles, onPreviewTitlesChange, - voiceModePerVideo, - onVoiceModePerVideoChange, - voiceLibraryIds, - onVoiceLibraryIdsChange, previewCovers, onPreviewCoversChange, selectedVariantIds, + selectedScript, + ttsVoiceId, + ttsVoiceSource, } = props - // #1913:包装 onServerClipsChange,适配 hook 的 (clips, templateId?) 签名 - // 如果 hook 传回了后端兜底创建的 templateId,同时通知外层更新 selectedTemplate const handleClipsChange = React.useCallback( (clips: EditPlanClip[], _templateId?: string) => { onServerClipsChange(clips) @@ -138,8 +150,22 @@ export const GenerateStepContent: React.FC = (props) = switch (currentStep) { case 1: + return ( + + ) + case 2: return ( = (props) = onServerClipsChange={handleClipsChange} /> ) - case 2: - return ( - - ) case 3: return ( = (props) = /> ) case 4: - /* 确认生成页:批量=逐任务进度网格;单视频=仅渲染进度/失败状态 */ - if (previewCount > 1) { - return ( - - ) - } - if (generated && !generating && !generateError) return null return (
- {generating && ( + {/* 配置摘要(#1970) */} +
+
+ 📋 生成配置 +
+
🎬 剪辑模式:{editMode === "random" ? "🎲 随机混剪" : "📖 叙事剪辑"}
+ {editMode === "random" ? ( +
🎙️ 配音来源:配音库音频
+ ) : ( + <> +
📝 文案:{selectedScript?.title ?? "未选择"}
+
+ 🎙️ 合成配音音色: + {ttsVoiceId + ? `${ttsVoiceSource === "clone" ? "克隆音色" : "系统音色"}(${ttsVoiceId.slice(0, 8)}...)` + : "未选择"} +
+ + )} +
📱 视频比例:{videoRatio}
+
🎯 智能降重:{dedupEnabled ? "已开启" : "已关闭"}
+ {previewCount > 1 &&
📦 生成数量:{previewCount} 个
} +
+ + {previewCount > 1 ? ( + + ) : generating ? (
@@ -217,8 +260,7 @@ export const GenerateStepContent: React.FC = (props) = />
- )} - {generateError && !generating && ( + ) : generateError ? (
生成失败
@@ -228,7 +270,7 @@ export const GenerateStepContent: React.FC = (props) = 🔄 重试
- )} + ) : null}
) case 5: diff --git a/apps/web/src/pages/generate/components/ScriptSelectModal.tsx b/apps/web/src/pages/generate/components/ScriptSelectModal.tsx new file mode 100644 index 000000000..56faee30b --- /dev/null +++ b/apps/web/src/pages/generate/components/ScriptSelectModal.tsx @@ -0,0 +1,243 @@ +/** + * 叙事剪辑 — 文案选择弹窗(#1970) + * - 搜索框:防抖 300ms,命中文字黄色高亮 + * - 标签筛选行:全部/带货/工厂/测评/教程/口播/种草 + * - 数量统计 + 卡片列表(可滚动,max-height 420px) + * - 调用 GET /api/v1/scripts?keyword=&tag=&page_size=200 + */ +import React, { useState, useEffect, useMemo, useRef, useCallback } from "react" +import { Modal, Input, Tag, Spin } from "antd" +import { SearchOutlined, CheckCircleFilled } from "@ant-design/icons" +import { useQuery } from "@tanstack/react-query" +import { getScripts } from "@/api/scripts" +import type { ScriptItem } from "@/api/scripts" + +interface ScriptSelectModalProps { + open: boolean + selectedScriptId: string | null + onCancel: () => void + onConfirm: (script: ScriptItem) => void +} + +const SCRIPT_TABS = [ + { key: "all", label: "全部" }, + { key: "带货", label: "带货" }, + { key: "工厂", label: "工厂" }, + { key: "测评", label: "测评" }, + { key: "教程", label: "教程" }, + { key: "口播", label: "口播" }, + { key: "种草", label: "种草" }, +] + +/** 在文本中用 高亮关键词(黄色背景) */ +function highlight(text: string, keyword: string): React.ReactNode { + if (!keyword) return text + const idx = text.toLowerCase().indexOf(keyword.toLowerCase()) + if (idx < 0) return text + return ( + <> + {text.slice(0, idx)} + + {text.slice(idx, idx + keyword.length)} + + {text.slice(idx + keyword.length)} + + ) +} + +const ScriptSelectModal: React.FC = ({ + open, + selectedScriptId, + onCancel, + onConfirm, +}) => { + const [innerSelected, setInnerSelected] = useState(selectedScriptId) + const [activeTag, setActiveTag] = useState("all") + const [searchInput, setSearchInput] = useState("") + const [debouncedKw, setDebouncedKw] = useState("") + const debounceRef = useRef | null>(null) + + useEffect(() => { + if (open) { + setInnerSelected(selectedScriptId) + setActiveTag("all") + setSearchInput("") + setDebouncedKw("") + } + }, [open, selectedScriptId]) + + // 300ms 防抖 + useEffect(() => { + if (debounceRef.current) clearTimeout(debounceRef.current) + debounceRef.current = setTimeout(() => setDebouncedKw(searchInput.trim()), 300) + return () => { + if (debounceRef.current) clearTimeout(debounceRef.current) + } + }, [searchInput]) + + const { data, isLoading } = useQuery({ + queryKey: ["scripts", "select-modal", debouncedKw, activeTag], + queryFn: () => + getScripts({ + page: 1, + page_size: 200, + keyword: debouncedKw || undefined, + tag: activeTag === "all" ? undefined : activeTag, + }), + enabled: open, + }) + + const scripts: ScriptItem[] = useMemo(() => data?.items ?? [], [data]) + const selected = useMemo( + () => scripts.find((s) => s.id === innerSelected) ?? null, + [scripts, innerSelected], + ) + + const handleConfirm = useCallback(() => { + if (selected) onConfirm(selected) + }, [selected, onConfirm]) + + return ( + + {/* 搜索 */} + } + placeholder="搜索标题、内容或标签" + value={searchInput} + onChange={(e) => setSearchInput(e.target.value)} + style={{ marginBottom: 12 }} + /> + + {/* 标签筛选 */} +
+ {SCRIPT_TABS.map((t) => { + const active = activeTag === t.key + return ( + setActiveTag(t.key)} + style={{ + cursor: "pointer", + padding: "4px 14px", + borderRadius: 16, + border: active ? "1px solid #7c3aed" : "1px solid #e5e7eb", + background: active ? "#ede9fe" : "#fff", + color: active ? "#7c3aed" : "#4b5563", + margin: 0, + fontSize: 13, + }} + > + {t.label} + + ) + })} +
+ + {/* 数量统计 */} +
+ 共 {data?.total ?? scripts.length} 条文案 +
+ + {/* 卡片列表 */} +
+ {isLoading ? ( +
+ +
+ ) : scripts.length === 0 ? ( +
+ 暂无匹配文案 +
+ ) : ( +
+ {scripts.map((s) => { + const isSel = innerSelected === s.id + const preview = (s.content || "").replace(/\s+/g, " ").slice(0, 80) + return ( +
setInnerSelected(s.id)} + style={{ + padding: 14, + borderRadius: 8, + border: isSel ? "2px solid #7c3aed" : "1px solid #e5e7eb", + background: isSel ? "#faf5ff" : "#fff", + cursor: "pointer", + transition: "all 0.2s", + position: "relative", + }} + > + {isSel && ( + + )} +
+ {highlight(s.title || "未命名", debouncedKw)} +
+
+ {highlight(preview + ((s.content || "").length > 80 ? "..." : ""), debouncedKw)} +
+ {s.tags && s.tags.length > 0 && ( +
+ {s.tags.slice(0, 5).map((tg) => ( + + {tg} + + ))} +
+ )} +
+ ) + })} +
+ )} +
+
+ ) +} + +export default ScriptSelectModal diff --git a/apps/web/src/pages/generate/components/Step1EditMode.tsx b/apps/web/src/pages/generate/components/Step1EditMode.tsx new file mode 100644 index 000000000..ca9a59ac8 --- /dev/null +++ b/apps/web/src/pages/generate/components/Step1EditMode.tsx @@ -0,0 +1,266 @@ +/** + * Step 1 选择剪辑模式 + 生成设置(#1970 新流程第一步) + * - 剪辑模式:🎲随机混剪 / 📖叙事剪辑,二选一,选中紫底紫框 + * - 生成设置:生成数量(-/+ 1-10 默认1)、视频比例(9:16/16:9 默认9:16)、智能降重开关(默认开) + */ +import React from "react" +import { MinusOutlined, PlusOutlined } from "@ant-design/icons" + +export type EditMode = "random" | "narrative" + +interface Step1EditModeProps { + editMode: EditMode + onEditModeChange: (mode: EditMode) => void + /** 生成数量(1-10,默认1) */ + previewCount: number + onPreviewCountChange: (n: number) => void + /** 视频比例 */ + videoRatio: "9:16" | "16:9" + onVideoRatioChange: (ratio: "9:16" | "16:9") => void + /** 智能降重开关(默认 true) */ + dedupEnabled: boolean + onDedupEnabledChange: (v: boolean) => void +} + +const PURPLE = "#7c3aed" +const PURPLE_BG = "linear-gradient(135deg, #ede9fe, #ddd6fe)" +const PURPLE_BORDER = "2px solid #7c3aed" + +const MODE_CARDS: Array<{ + key: EditMode + emoji: string + title: string + desc: string + features: string[] +}> = [ + { + key: "random", + emoji: "🎲", + title: "随机混剪", + desc: "根据配音时长随机抽取素材片段,灵活组合", + features: ["随机抽帧组合", "每次画面不同", "适合批量生成"], + }, + { + key: "narrative", + emoji: "📖", + title: "叙事剪辑", + desc: "按文案内容匹配相关画面,有逻辑组织镜头", + features: ["画面匹配文案", "叙事感更强", "需要素材标签"], + }, +] + +const Step1EditMode: React.FC = ({ + editMode, + onEditModeChange, + previewCount, + onPreviewCountChange, + videoRatio, + onVideoRatioChange, + dedupEnabled, + onDedupEnabledChange, +}) => { + return ( +
+

🎬 选择剪辑模式

+

+ 选择适合您的剪辑方式,后续流程会根据模式自动调整 +

+ +
+ {MODE_CARDS.map((card) => { + const selected = editMode === card.key + return ( +
onEditModeChange(card.key)} + style={{ + padding: 20, + borderRadius: 12, + border: selected ? PURPLE_BORDER : "1px solid #e5e7eb", + background: selected ? PURPLE_BG : "#fff", + cursor: "pointer", + transition: "all 0.2s", + }} + > +
{card.emoji}
+
+ {card.title} +
+
{card.desc}
+
+ {card.features.map((f) => ( +
+ ✅ {f} +
+ ))} +
+
+ ) + })} +
+ +

⚙️ 生成设置

+ +
+ +
+
+ + + {previewCount} + + +
+ 最多一次生成 10 个 +
+
+ +
+ +
+ {[ + { key: "9:16" as const, emoji: "📱", label: "竖屏 9:16" }, + { key: "16:9" as const, emoji: "🖥️", label: "横屏 16:9" }, + ].map((opt) => { + const selected = videoRatio === opt.key + return ( + + ) + })} +
+
+ +
+
+
+ 🎯 智能降重 {dedupEnabled ? "已开启" : "已关闭"} +
+
+ 自动对画面做微调,避免查重不过 +
+
+ +
+
+ ) +} + +export default Step1EditMode diff --git a/apps/web/src/pages/generate/components/Step2MaterialSelect.tsx b/apps/web/src/pages/generate/components/Step2MaterialSelect.tsx index 76a771ae8..8f6fa3963 100644 --- a/apps/web/src/pages/generate/components/Step2MaterialSelect.tsx +++ b/apps/web/src/pages/generate/components/Step2MaterialSelect.tsx @@ -11,6 +11,8 @@ import SmartMatchInput from "./material/SmartMatchInput" import SmartMatchResults from "./material/SmartMatchResults" interface Step2MaterialSelectProps { + /** 剪辑模式:random 随机混剪 / narrative 叙事剪辑(#1970) */ + editMode?: "random" | "narrative" materialMode: "manual" | "auto" onMaterialModeChange: (mode: "manual" | "auto") => void selectedMaterials: string[] @@ -43,6 +45,27 @@ const Step2MaterialSelect: React.FC = (props) => {

📦 选择素材

+ {/* 叙事剪辑:AI 智能匹配提示卡(#1970) */} + {props.editMode === "narrative" && ( +
+ 🤖 + AI智能匹配:系统将根据您的文案内容,从素材库自动匹配合适的视频片段 +
+ )} + {/* 片段数量(#1899) */}
diff --git a/apps/web/src/pages/generate/components/TtsVoiceModal.tsx b/apps/web/src/pages/generate/components/TtsVoiceModal.tsx new file mode 100644 index 000000000..e99ad89b7 --- /dev/null +++ b/apps/web/src/pages/generate/components/TtsVoiceModal.tsx @@ -0,0 +1,491 @@ +/** + * 叙事剪辑 — TTS 音色选择 + 合成配音弹窗(#1970) + * - Tabs:✨系统音色 / 🎙️我的克隆音色 + * - 2列音色卡片(头像emoji+名称+描述+标签+▶试听+选中✓) + * - 底部:取消 / 🎧 合成配音(主按钮,必须选音色才能点) + * - 合成中:紫色 spinner + "正在合成配音..." + "请稍候,通常需要10-30秒" + * - 合成成功:保存到配音库并回调(voiceAssetId + ttsVoiceId + ttsVoiceSource) + * + * 复用现有 /api/tts 的 synthesizeSpeech + 轮询 getTTSJobStatus 逻辑; + * 不直接复用 TtsModal(它是页面配音弹窗,含文本输入/语速/情感等字段,叙事模式文本来自文案)。 + */ +import React, { useState, useEffect, useMemo, useRef, useCallback } from "react" +import { Modal, Tabs, Spin, message } from "antd" +import { CheckCircleFilled, SoundOutlined } from "@ant-design/icons" +import { useQuery } from "@tanstack/react-query" +import { fetchPresetVoices } from "@/api/voices" +import { getVoiceClones } from "@/api/voice-clone" +import { synthesizeSpeech, getTTSJobStatus, saveTtsToLibrary } from "@/api/tts" +import type { PresetVoiceItem } from "@/api/voices" +import type { VoiceClone } from "@/api/voice-clone" +import { VOICE_GENDER_ICON } from "../constants" + +interface TtsVoiceModalProps { + open: boolean + /** 需要合成的文本(来自选中的文案 content) */ + scriptText: string + scriptTitle: string + onCancel: () => void + /** 合成成功回调:asset_id 为保存到配音库后的素材ID */ + onSynthesized: (payload: { + voiceAssetId: string + ttsVoiceId: string + ttsVoiceSource: "preset" | "clone" + }) => void +} + +type TtsSynthStatus = "idle" | "synthesizing" | "saving" | "done" | "error" + +const TtsVoiceModal: React.FC = ({ + open, + scriptText, + scriptTitle, + onCancel, + onSynthesized, +}) => { + const [activeTab, setActiveTab] = useState<"preset" | "clone">("preset") + const [selectedVoiceId, setSelectedVoiceId] = useState("") + const [status, setStatus] = useState("idle") + const [error, setError] = useState(null) + const [previewingId, setPreviewingId] = useState(null) + const audioRef = useRef(null) + const timerRef = useRef | null>(null) + + /* 系统音色 */ + const { data: presetData } = useQuery({ + queryKey: ["preset-voices", "modal"], + queryFn: fetchPresetVoices, + enabled: open, + }) + const presetVoices: PresetVoiceItem[] = useMemo(() => presetData?.items ?? [], [presetData]) + + /* 克隆音色(仅 ready 状态可用) */ + const { data: cloneListRaw = [] } = useQuery({ + queryKey: ["voice-clones", "ready"], + queryFn: () => getVoiceClones({ status: "ready" }), + enabled: open, + }) + const cloneVoices: VoiceClone[] = useMemo( + () => cloneListRaw.filter((v: VoiceClone) => v.status === "ready"), + [cloneListRaw], + ) + + /* 打开时重置状态 */ + useEffect(() => { + if (open) { + setSelectedVoiceId("") + setStatus("idle") + setError(null) + setActiveTab("preset") + } else { + if (timerRef.current) { + clearInterval(timerRef.current) + timerRef.current = null + } + if (audioRef.current) { + audioRef.current.pause() + audioRef.current = null + } + setPreviewingId(null) + } + return () => { + if (timerRef.current) clearInterval(timerRef.current) + } + }, [open]) + + const handlePreview = useCallback( + (voiceId: string, previewUrl: string | null | undefined) => { + if (!previewUrl) { + message.info("该音色暂无试听音频") + return + } + if (previewingId === voiceId && audioRef.current) { + audioRef.current.pause() + setPreviewingId(null) + return + } + if (audioRef.current) audioRef.current.pause() + const a = new Audio(previewUrl) + audioRef.current = a + setPreviewingId(voiceId) + a.onended = () => { + setPreviewingId(null) + audioRef.current = null + } + a.play().catch(() => { + setPreviewingId(null) + audioRef.current = null + }) + }, + [previewingId], + ) + + const textToSynth = useMemo(() => { + // 文案内容取首段(过长会被 TTS 截断,保持和用户感知一致) + const t = (scriptText || "").trim() + return t.length > 500 ? t.slice(0, 500) : t + }, [scriptText]) + + const handleSynthesize = useCallback(async () => { + if (!selectedVoiceId) { + message.warning("请先选择一个音色") + return + } + if (!textToSynth) { + message.warning("文案内容为空,无法合成") + return + } + setStatus("synthesizing") + setError(null) + try { + const isClone = activeTab === "clone" + const payload: Record = { + text: textToSynth, + speed: 1.0, + language: "zh-CN", + } + if (isClone) { + payload.voice_clone_profile_id = selectedVoiceId + } else { + payload.voice_id = selectedVoiceId + } + const resp = await synthesizeSpeech( + payload as unknown as Parameters[0], + ) + const jobId = resp.job_id + + await new Promise((resolve, reject) => { + timerRef.current = setInterval(async () => { + try { + const job = await getTTSJobStatus(jobId) + if (job.status === "completed") { + if (timerRef.current) clearInterval(timerRef.current) + timerRef.current = null + resolve() + } else if (job.status === "failed") { + if (timerRef.current) clearInterval(timerRef.current) + timerRef.current = null + reject(new Error(job.error_message || "合成失败")) + } + } catch (e) { + if (timerRef.current) clearInterval(timerRef.current) + timerRef.current = null + reject(e) + } + }, 2000) + }) + + // 保存到配音库 + setStatus("saving") + await saveTtsToLibrary(jobId, { name: scriptTitle?.slice(0, 30) || "AI合成配音" }) + setStatus("done") + + // 合成成功后回调;voiceAssetId 由后端在保存时产出,这里用 ttsVoiceId 占位, + // 父流程会在下一次 asset 列表刷新后重新选取;前端直接以 ttsVoiceId 为 key 传给后端 + // (叙事模式后端通过 script_id + tts_voice_id 自行再合成,不依赖 asset_id)。 + onSynthesized({ + voiceAssetId: jobId, + ttsVoiceId: selectedVoiceId, + ttsVoiceSource: isClone ? "clone" : "preset", + }) + } catch (err: unknown) { + setStatus("error") + const msg = err instanceof Error ? err.message : "合成失败,请稍后重试" + setError(msg) + } + }, [selectedVoiceId, textToSynth, activeTab, scriptTitle, onSynthesized]) + + const renderVoiceCard = (v: { + id: string + name: string + description?: string + gender?: string + tags?: string[] + preview_url?: string | null + }) => { + const isSel = selectedVoiceId === v.id + const isPlaying = previewingId === v.id + const emoji = v.gender ? (VOICE_GENDER_ICON[v.gender] ?? "🎤") : "🎤" + return ( +
setSelectedVoiceId(v.id)} + style={{ + padding: 12, + borderRadius: 8, + border: isSel ? "2px solid #7c3aed" : "1px solid #e5e7eb", + background: isSel ? "#faf5ff" : "#fff", + cursor: "pointer", + transition: "all 0.2s", + position: "relative", + }} + > + {isSel && ( + + )} +
+
+ {emoji} +
+
+
+ {v.name} +
+ {v.description && ( +
+ {v.description} +
+ )} +
+ {v.preview_url && ( + + )} +
+ {v.tags && v.tags.length > 0 && ( +
+ {v.tags.slice(0, 3).map((tg) => ( + + {tg} + + ))} +
+ )} +
+ ) + } + + /* 合成中 loading 覆盖层 */ + const renderSynthOverlay = () => { + if (status !== "synthesizing" && status !== "saving") return null + return ( +
+ +
+ {status === "synthesizing" ? "正在合成配音..." : "正在保存到配音库..."} +
+
请稍候,通常需要 10-30 秒
+
+ ) + } + + return ( + +
+ {error && ( +
+ {error} +
+ )} + +
+ 将根据文案《{scriptTitle?.slice(0, 30) || "所选文案"}》合成配音,文本长度: + {textToSynth.length} 字 +
+ + { + setActiveTab(k as "preset" | "clone") + setSelectedVoiceId("") + }} + items={[ + { + key: "preset", + label: "✨ 系统音色", + children: ( +
+ {presetVoices.length === 0 ? ( +
+ 正在加载系统音色... +
+ ) : ( + presetVoices.map((v) => + renderVoiceCard({ + id: v.voice_id, + name: v.name, + description: v.description, + gender: v.gender, + tags: v.tags, + preview_url: v.preview_url, + }), + ) + )} +
+ ), + }, + { + key: "clone", + label: "🎙️ 我的克隆音色", + children: ( +
+ {cloneVoices.length === 0 ? ( +
+ 暂无就绪的克隆音色,请先在配音库完成音色克隆 +
+ ) : ( + cloneVoices.map((v) => + renderVoiceCard({ + id: v.id, + name: v.name, + description: v.description, + gender: "neutral", + tags: ["克隆"], + preview_url: v.sample_url || null, + }), + ) + )} +
+ ), + }, + ]} + /> + {renderSynthOverlay()} +
+
+ ) +} + +export default TtsVoiceModal diff --git a/apps/web/src/pages/generate/components/VoiceSelectModal.tsx b/apps/web/src/pages/generate/components/VoiceSelectModal.tsx new file mode 100644 index 000000000..15b79b20f --- /dev/null +++ b/apps/web/src/pages/generate/components/VoiceSelectModal.tsx @@ -0,0 +1,241 @@ +/** + * 随机混剪 — 配音选择弹窗(#1970) + * 内容复用 Step5VoiceSelect 的配音库音频卡片(图标+文件名+时长/大小+▶试听), + * 无 TTS / 克隆音色入口;确认后进入 Step2。 + */ +import React from "react" +import { Modal } from "antd" +import { AudioOutlined } from "@ant-design/icons" +import { useNavigate } from "react-router-dom" +import { useQuery } from "@tanstack/react-query" +import { useState, useRef, useCallback } from "react" +import { getAssetsByKind } from "@/api/assets" +import type { AssetItem } from "@/api/assets" + +interface VoiceSelectModalProps { + open: boolean + selectedVoice: string + onCancel: () => void + onConfirm: (voiceAssetId: string) => void +} + +const getDuration = (item: AssetItem): number => + item.duration ?? (item.metadata?.duration as number) ?? 0 +const getFileSize = (item: AssetItem): number => + item.file_size ?? (item.metadata?.file_size as number) ?? 0 +const isAiVoice = (item: AssetItem): boolean => { + const d = getDuration(item) + const s = getFileSize(item) + return (!d || d <= 0) && (!s || s <= 0) +} +const fmtDur = (s?: number): string => { + if (!s || s <= 0) return "时长未知" + return `${s.toFixed(1)}秒` +} +const fmtSize = (b?: number): string => { + if (!b || b <= 0) return "未知" + if (b < 1024) return `${b} B` + if (b < 1024 * 1024) return `${(b / 1024).toFixed(1)} KB` + if (b < 1024 * 1024 * 1024) return `${(b / (1024 * 1024)).toFixed(1)} MB` + return `${(b / (1024 * 1024 * 1024)).toFixed(1)} GB` +} + +const VoiceSelectModal: React.FC = ({ + open, + selectedVoice, + onCancel, + onConfirm, +}) => { + const navigate = useNavigate() + const [innerSelected, setInnerSelected] = React.useState(selectedVoice) + const [playingId, setPlayingId] = useState(null) + const audioRef = useRef(null) + + React.useEffect(() => { + if (open) setInnerSelected(selectedVoice) + }, [open, selectedVoice]) + + const { data: materials = [], isLoading } = useQuery({ + queryKey: ["assets", "voice", "modal"], + queryFn: () => getAssetsByKind("voice", { limit: 50 }), + enabled: open, + }) + + const togglePlay = useCallback( + (item: AssetItem) => { + if (playingId === item.id && audioRef.current) { + audioRef.current.pause() + setPlayingId(null) + return + } + if (audioRef.current) audioRef.current.pause() + if (!item.file_url) return + const audio = new Audio(item.file_url) + audioRef.current = audio + setPlayingId(item.id) + audio.onended = () => { + setPlayingId(null) + audioRef.current = null + } + audio.play().catch(() => { + setPlayingId(null) + audioRef.current = null + }) + }, + [playingId], + ) + + const handleGoUpload = () => navigate("/app/voices?tab=material&upload=1") + + const handleConfirm = () => { + if (!innerSelected) return + onConfirm(innerSelected) + } + + return ( + +

+ 从配音库中选择已上传的音频素材,点击 ▶ 可试听 +

+ {isLoading ? ( +
加载中...
+ ) : materials.length === 0 ? ( +
+ +

暂无配音素材

+ +
+ ) : ( +
+ {materials.map((item) => { + const isSel = innerSelected === item.id + const isPlaying = playingId === item.id + return ( +
setInnerSelected(item.id)} + style={{ + padding: 14, + borderRadius: 8, + border: isSel ? "2px solid #7c3aed" : "1px solid #e8e8e8", + background: isSel ? "#ede9fe" : "#fff", + cursor: "pointer", + transition: "all 0.2s", + }} + > +
+
+ +
+ {item.file_url && ( + + )} +
+
+ {item.name} +
+
+ {isAiVoice(item) ? ( + AI 音色 + ) : ( + {fmtDur(getDuration(item))} + )} + {isAiVoice(item) ? "按文本合成" : fmtSize(getFileSize(item))} +
+
+ ) + })} +
+ )} +
+ ) +} + +export default VoiceSelectModal diff --git a/apps/web/src/pages/generate/constants.ts b/apps/web/src/pages/generate/constants.ts index 337483ca4..680dbb384 100644 --- a/apps/web/src/pages/generate/constants.ts +++ b/apps/web/src/pages/generate/constants.ts @@ -27,10 +27,10 @@ export const VOICE_GENDER_ICON: Record = { neutral: "✨", } -/* ── 步骤定义(5步,#1899 简化:删除选模板步骤) ── */ +/* ── 步骤定义(5步,#1970 流程重构:选择模式 → 素材 → 标题 → 确认 → 封面) ── */ export const STEPS = [ - { key: 1, label: "选择素材" }, - { key: 2, label: "选择配音" }, + { key: 1, label: "选择模式" }, + { key: 2, label: "选择素材" }, { key: 3, label: "选择标题" }, { key: 4, label: "确认生成" }, { key: 5, label: "选择封面" }, diff --git a/apps/web/src/pages/generate/hooks/generate-video/buildPayload.ts b/apps/web/src/pages/generate/hooks/generate-video/buildPayload.ts index e057fb86e..65e32075a 100644 --- a/apps/web/src/pages/generate/hooks/generate-video/buildPayload.ts +++ b/apps/web/src/pages/generate/hooks/generate-video/buildPayload.ts @@ -1,7 +1,9 @@ import type { UseGenerateVideoProps } from "./types" /** - * 生成前置校验 + * 生成前置校验(#1970 适配新流程) + * - 随机混剪:需选配音(selectedVoice,配音库音频) + * - 叙事剪辑:需选文案 + TTS 音色 * 返回错误信息,通过则返回 null */ export const validateGenerateInputs = (props: UseGenerateVideoProps): string | null => { @@ -12,19 +14,30 @@ export const validateGenerateInputs = (props: UseGenerateVideoProps): string | n smartSelectedIds, voiceMode, selectedClonedVoice, + editMode = "random", + selectedScript, + ttsVoiceId, + selectedVoice, } = props - // AI 自动选择模式下,标题可以为空(后端会自行生成) if (!titleSettings.aiAutoSelect && !titleSettings.title?.trim()) { return "请先选择或输入标题" } - // 无论手动还是自动模式,都必须有素材 const materialIds = materialMode === "auto" ? smartSelectedIds || [] : selectedMaterials || [] if (materialIds.length === 0) { return materialMode === "auto" ? "AI 未匹配到素材,请手动选择素材后重试" : "请至少选择一个素材" } - if (voiceMode === "clone" && !selectedClonedVoice) { - return "请先选择一个克隆音色" + if (editMode === "narrative") { + if (!selectedScript?.id) return "请先选择文案" + if (!ttsVoiceId) return "请先合成配音" + } else { + // 随机混剪:配音库音频 + if (!selectedVoice && voiceMode !== "clone") { + return "请先选择配音" + } + if (voiceMode === "clone" && !selectedClonedVoice) { + return "请先选择一个克隆音色" + } } return null } diff --git a/apps/web/src/pages/generate/hooks/generate-video/types.ts b/apps/web/src/pages/generate/hooks/generate-video/types.ts index 9b0ed91f8..b0ba92201 100755 --- a/apps/web/src/pages/generate/hooks/generate-video/types.ts +++ b/apps/web/src/pages/generate/hooks/generate-video/types.ts @@ -13,7 +13,19 @@ export interface UseGenerateVideoProps { selectedVoice: string selectedClonedVoice: string coverSettings: CoverConfig - videoRatio: string + videoRatio: "9:16" | "16:9" | string + /** #1970 剪辑模式 */ + editMode?: "random" | "narrative" + /** 叙事模式下选中的文案 */ + selectedScript?: { id: string; title?: string; content?: string } | null + /** TTS 音色 ID(叙事模式) */ + ttsVoiceId?: string + /** TTS 音色来源 */ + ttsVoiceSource?: "preset" | "clone" + /** 合成后保存到配音库的 asset id / job id(叙事模式) */ + ttsVoiceAssetId?: string + /** 智能降重开关(默认 true) */ + dedupEnabled?: boolean style: string duration: number autoSubtitles: boolean diff --git a/apps/web/src/pages/generate/hooks/useGenerateFormState/index.ts b/apps/web/src/pages/generate/hooks/useGenerateFormState/index.ts index 83913ee85..a9ea75090 100755 --- a/apps/web/src/pages/generate/hooks/useGenerateFormState/index.ts +++ b/apps/web/src/pages/generate/hooks/useGenerateFormState/index.ts @@ -12,6 +12,7 @@ import { getEditingTemplates } from "@/api/editing-planner" import type { EditPlanClip } from "@/api/template-editor" import type { CoverConfig } from "../../types/cover" import type { PresetVoiceItem } from "@/api/voices" +import type { ScriptItem } from "@/api/scripts" import { DEFAULT_COVER_SETTINGS, DEFAULT_CLIP_COUNT } from "../../constants" import type { TitleSettings } from "../../types" import { usePlanConfigLoader } from "./usePlanConfigLoader" @@ -82,8 +83,28 @@ export interface GenerateFormState { cloneModalOpen: boolean setCloneModalOpen: (open: boolean) => void + /* ── 剪辑模式(#1970 流程重构)── */ + editMode: "random" | "narrative" + setEditMode: (mode: "random" | "narrative") => void + /** 叙事模式下选中的文案 */ + selectedScript: ScriptItem | null + setSelectedScript: (s: ScriptItem | null) => void + /** TTS 音色 ID */ + ttsVoiceId: string + setTtsVoiceId: (id: string) => void + /** TTS 音色来源:preset 系统 / clone 克隆 */ + ttsVoiceSource: "preset" | "clone" + setTtsVoiceSource: (src: "preset" | "clone") => void + /** 合成后配音库 asset id(叙事模式保存到库后获得;随机模式 = selectedVoice) */ + ttsVoiceAssetId: string + setTtsVoiceAssetId: (id: string) => void + /** 智能降重开关(默认 true) */ + dedupEnabled: boolean + setDedupEnabled: (v: boolean) => void + /* 高级设置 */ - videoRatio: string + videoRatio: "9:16" | "16:9" | string + setVideoRatio: (r: "9:16" | "16:9") => void duration: number style: string autoSubtitles: boolean @@ -201,13 +222,21 @@ export const useGenerateFormState = (): GenerateFormState => { /* ── 克隆声音弹窗 ── */ const [cloneModalOpen, setCloneModalOpen] = useState(false) - /* ── 高级设置(隐藏但保留) ── */ - const [videoRatio] = useState("9:16") + /* ── 高级设置 ── */ + const [videoRatio, setVideoRatio] = useState<"9:16" | "16:9">("9:16") const [duration] = useState(30) const [style] = useState("business") const [autoSubtitles] = useState(true) const [bgm] = useState(true) + /* ── 剪辑模式状态(#1970) ── */ + const [editMode, setEditMode] = useState<"random" | "narrative">("random") + const [selectedScript, setSelectedScript] = useState(null) + const [ttsVoiceId, setTtsVoiceId] = useState("") + const [ttsVoiceSource, setTtsVoiceSource] = useState<"preset" | "clone">("preset") + const [ttsVoiceAssetId, setTtsVoiceAssetId] = useState("") + const [dedupEnabled, setDedupEnabled] = useState(true) + /* ── 预览任务 ID ── */ const previewStorageKey = editPlanId ? `preview_task_id_${editPlanId}` @@ -274,9 +303,22 @@ export const useGenerateFormState = (): GenerateFormState => { selectedClonedVoice, setSelectedClonedVoice, presetVoices, + editMode, + setEditMode, + selectedScript, + setSelectedScript, + ttsVoiceId, + setTtsVoiceId, + ttsVoiceSource, + setTtsVoiceSource, + ttsVoiceAssetId, + setTtsVoiceAssetId, + dedupEnabled, + setDedupEnabled, cloneModalOpen, setCloneModalOpen, videoRatio, + setVideoRatio, duration, style, autoSubtitles, diff --git a/apps/web/src/pages/generate/hooks/useGenerateVideo.ts b/apps/web/src/pages/generate/hooks/useGenerateVideo.ts index f4ded6a95..8f51a2dbf 100755 --- a/apps/web/src/pages/generate/hooks/useGenerateVideo.ts +++ b/apps/web/src/pages/generate/hooks/useGenerateVideo.ts @@ -123,6 +123,8 @@ export function useGenerateVideo(props: UseGenerateVideoProps) { const { width: outputWidth, height: outputHeight } = calculateResolution( props.videoRatio || "9:16", ) + const editMode = props.editMode ?? "random" + const dedupEnabled = props.dedupEnabled !== false const assetIds = props.materialMode === "auto" ? props.smartSelectedIds : props.selectedMaterials @@ -151,10 +153,13 @@ export function useGenerateVideo(props: UseGenerateVideoProps) { const coverUrl = props.coverSettings?.thumbnail_url || props.coverSettings?.upload_url || "" + // #1970:叙事模式下 ttsVoiceId 作为配音 id;随机模式用 selectedVoice const voiceLibraryId = - props.voiceMode === "clone" - ? props.selectedClonedVoice || props.selectedVoice || "" - : props.selectedVoice || "" + editMode === "narrative" + ? props.ttsVoiceId || "" + : props.voiceMode === "clone" + ? props.selectedClonedVoice || props.selectedVoice || "" + : props.selectedVoice || "" /* ── 批量变体数组(长度1=共用,长度=count=独立,空=回退单值) ── */ const indexes = @@ -197,6 +202,15 @@ export function useGenerateVideo(props: UseGenerateVideoProps) { custom_title: props.titleSettings?.title || "", duration: props.duration || undefined, video_ratio: props.videoRatio, + assembly_mode: editMode, + ...(editMode === "narrative" && props.selectedScript?.id + ? { + script_id: props.selectedScript.id, + tts_voice_id: props.ttsVoiceId || undefined, + tts_voice_source: props.ttsVoiceSource || undefined, + } + : {}), + dedup_enabled: dedupEnabled, voice_library_id: voiceLibraryId, ...(props.selectedVoice && !voiceLibraryId ? { voice_ids: [props.selectedVoice] } : {}), bgm_config: { diff --git a/apps/web/src/pages/generate/hooks/useStepNavigation.ts b/apps/web/src/pages/generate/hooks/useStepNavigation.ts index 4d4a7b11a..9442bc7f1 100644 --- a/apps/web/src/pages/generate/hooks/useStepNavigation.ts +++ b/apps/web/src/pages/generate/hooks/useStepNavigation.ts @@ -1,17 +1,22 @@ /** - * GeneratePage 步骤导航(#1899 简化为 5 步,单视频与批量一致) - * 步骤:素材(1) → 配音(2) → 标题(3) → 确认生成(4) → 封面(5) + * GeneratePage 步骤导航(#1970 流程重构) + * 步骤:选择模式(1) → 选择素材(2) → 选择标题(3) → 确认生成(4) → 选择封面(5) * - * - 步骤3底部按钮是「确认生成视频」(由 GenerateStepActions 调 onConfirmGenerate), - * 创建成功后跳转步骤4;本 hook 的 goNext 只负责 1→2→3 和 4→5 的「下一步」。 - * - 步骤4(确认生成进度页):渲染全部完成(generated)后「下一步」解锁进封面。 + * - 步骤1(选择模式):下一步分支由外层弹窗处理(VoiceSelectModal / ScriptSelectModal), + * 本 hook 的 goNext 仅在未选模式时拦截;外层 Modal onConfirm 里主动 setCurrentStep(2)。 + * - 步骤2(选择素材):弹数量选择弹窗(PreviewCountModal),确认后跳步骤3。 + * - 步骤3 底部按钮是「确认生成视频」(由 GenerateStepActions 调 onConfirmGenerate), + * 创建成功后跳步骤4;本 hook 的 goNext 只负责 2→3 和 4→5 的「下一步」。 + * - 步骤4(确认生成进度页):全部渲染完成后「下一步」解锁进封面。 */ import { message } from "antd" import type { TitleSettings } from "../types" +import type { EditMode } from "../components/Step1EditMode" export interface UseStepNavigationOptions { currentStep: number setCurrentStep: (step: number | ((prev: number) => number)) => void + editMode: EditMode materialMode: "manual" | "auto" selectedMaterials: string[] smartSelectedIds: string[] @@ -20,6 +25,8 @@ export interface UseStepNavigationOptions { generated: boolean /** 点素材下一步时弹出数量选择弹窗 */ onOpenCountModal: () => void + /** 步骤1下一步:根据 editMode 打开对应弹窗(随机→配音 / 叙事→文案) */ + onOpenStep1Modal: () => void } export interface UseStepNavigationReturn { @@ -36,22 +43,29 @@ export const useStepNavigation = (options: UseStepNavigationOptions): UseStepNav smartSelectedIds, generated, onOpenCountModal, + onOpenStep1Modal, } = options const goNext = () => { if (currentStep === 1) { - // 选完素材弹数量选择弹窗 + // 步骤1:先校验素材/配音等由弹窗负责,goNext 只负责触发弹窗 + onOpenStep1Modal() + return + } + if (currentStep === 2) { + // 素材校验 + if (materialMode === "manual" && selectedMaterials.length === 0) { + message.warning("请至少选择一个素材") + return + } + if (materialMode === "auto" && smartSelectedIds.length === 0) { + message.warning("请先进行智能匹配并选择素材") + return + } + // 弹数量选择弹窗 onOpenCountModal() return } - if (currentStep === 1 && materialMode === "manual" && selectedMaterials.length === 0) { - message.warning("请至少选择一个素材") - return - } - if (currentStep === 1 && materialMode === "auto" && smartSelectedIds.length === 0) { - message.warning("请先进行智能匹配并选择素材") - return - } // 步骤4(确认生成):全部渲染完成后才能下一步进封面 if (currentStep === 4) { if (!generated) {