diff --git a/apps/web/src/pages/generate/GeneratePage.tsx b/apps/web/src/pages/generate/GeneratePage.tsx index 4c178d700..7d2e81b8a 100644 --- a/apps/web/src/pages/generate/GeneratePage.tsx +++ b/apps/web/src/pages/generate/GeneratePage.tsx @@ -1,68 +1,40 @@ /** * 智能剪辑页面 — V21 原型 1:1 还原 - * 5 步向导:选择模板 → 选择素材 → 选择标题 → 选择配音 → 确认生成 + * 7 步向导:选择模板 → 选择素材 → 生成预览 → 选择标题 → 选择配音 → 选择封面 → 确认生成 * 左右布局:左侧 generate-form + 右侧 generate-preview - * 保留所有现有业务逻辑(API 调用、URL 参数、CloneModal、TTS 等) + * 主组件仅保留共享状态、步骤切换、整体布局 + * 各 Step 的 UI 与业务逻辑拆分至 components/ + hooks/ + * 生成核心逻辑封装在 useGenerateVideo hook */ -import React, { useState, useRef, useCallback, useEffect, useMemo } from "react" -import { useQuery, useMutation } from "@tanstack/react-query" -import { Typography, message, Select, Modal } from "antd" -import { - AudioOutlined, - ThunderboltOutlined, - CheckCircleFilled, - CloseCircleOutlined, - LoadingOutlined, - PlayCircleOutlined, - PauseCircleOutlined, - SaveOutlined, - PlusOutlined, - MinusOutlined, - CloseOutlined, -} from "@ant-design/icons" -import type { AssetItem } from "@/api/assets" -import { getAssets, getAssetLibraries } from "@/api/assets" -import { - generateEditPlan, - updateEditPlan, - getGenerationTaskResults, - getGenerationStatus, - getEditPlan, -} from "@/api/template-editor" -import type { GeneratedVideo, EditPlanConfig, TitleConfig } from "@/api/template-editor" +import React, { useState, useEffect, useMemo } from "react" +import { useQuery } from "@tanstack/react-query" +import { message, Modal } from "antd" +import { ThunderboltOutlined } from "@ant-design/icons" +import type { GeneratedVideo, TitleConfig } from "@/api/template-editor" +import { getEditPlan } from "@/api/template-editor" import type { CoverConfig } from "../editing-planner/types" import { getEditingTemplates } from "@/api/editing-planner" -import { getTitles } from "@/api/titles" -import { fetchPresetVoices } from "@/api/voices" import type { PresetVoiceItem } from "@/api/voices" -import { formatDuration } from "@/api/voice-clone" +import { fetchPresetVoices } from "@/api/voices" import type { VoiceClone } from "@/api/voice-clone" -import CloneModal from "@/components/voice/CloneModal" -import { synthesizeSpeech, getTTSJobStatus, saveTtsToLibrary } from "@/api/tts" -import { getTags, createTag } from "@/api/tags" import { useCloneProgress } from "@/hooks/useCloneProgress" +import CloneModal from "@/components/voice/CloneModal" import { useSearchParams, useNavigate } from "react-router-dom" import GenerateHeader from "./components/GenerateHeader" import GenerateStepsBar from "./components/GenerateStepsBar" import GenerateResultPanel from "./components/GenerateResultPanel" -import { - CLONE_STATUS_CONFIG, - MODE_GRADIENTS, - VOICE_GENDER_ICON, - POSITION_OPTIONS, - FONT_OPTIONS, - TITLE_PRESETS, - COVER_MODE_LABELS, - COVER_MODE_ICONS, - DEFAULT_COVER_SETTINGS, - SMART_MATCH_REASONS, - AI_TITLE_TEMPLATES, -} from "./constants" +import Step1TemplateSelect from "./components/Step1TemplateSelect" +import Step2MaterialSelect from "./components/Step2MaterialSelect" +import Step3GeneratePreview from "./components/Step3GeneratePreview" +import Step4TitleSettings from "./components/Step4TitleSettings" +import Step5VoiceSelect from "./components/Step5VoiceSelect" +import Step6CoverSettings from "./components/Step6CoverSettings" +import Step7ConfirmGenerate from "./components/Step7ConfirmGenerate" +import { DEFAULT_COVER_SETTINGS } from "./constants" import type { TitleSettings } from "./types" +import { useGenerateVideo } from "./hooks/useGenerateVideo" import "./generate.css" -const { Text } = Typography - const DEFAULT_TITLE_SETTINGS: TitleSettings = { aiAutoSelect: false, title: "", @@ -76,26 +48,6 @@ const DEFAULT_TITLE_SETTINGS: TitleSettings = { color: "#ffffff", } -function getActivePreset(settings: TitleSettings): string | null { - for (const p of TITLE_PRESETS) { - if ( - settings.size === p.style.size && - settings.color === p.style.color && - settings.bold === p.style.bold && - settings.italic === p.style.italic && - settings.stroke === p.style.stroke && - settings.shadow === p.style.shadow - ) { - return p.key - } - } - return null -} - -/* ================================================================ - 组件 - ================================================================ */ - const GeneratePage: React.FC = () => { const navigate = useNavigate() @@ -116,47 +68,18 @@ const GeneratePage: React.FC = () => { } }, [userTemplates, selectedTemplate]) - /* ── 素材 ── */ + /* ── 素材(共享:step2 选择、step7 展示、生成使用) ── */ const [selectedMaterials, setSelectedMaterials] = useState([]) - /* 素材选择模式:手动选择 / 自动匹配 */ const [materialMode, setMaterialMode] = useState<"manual" | "auto">("manual") - /* 智能素材匹配:用户描述输入 */ - const [smartMatchInput, setSmartMatchInput] = useState("") - /* 智能素材匹配:是否正在匹配中 */ - const [smartMatching, setSmartMatching] = useState(false) - /* 智能素材匹配:推荐结果列表 */ - const [smartMatchedResults, setSmartMatchedResults] = useState< - Array<{ asset: AssetItem; matchScore: number; matchReason: string }> - >([]) - /* 智能素材匹配:已选择的素材ID(auto模式下使用) */ const [smartSelectedIds, setSmartSelectedIds] = useState([]) - /* 智能素材匹配:是否已执行过匹配 */ - const [hasMatched, setHasMatched] = useState(false) - /* ── 智能标题生成 ── */ - const [aiTitleInput, setAiTitleInput] = useState("") - const [aiTitleGenerating, setAiTitleGenerating] = useState(false) - const [aiTitleResults, setAiTitleResults] = useState< - Array<{ title: string; highlight: string; style: "catchy" | "emotional" | "informative" }> - >([]) - const [hasGeneratedTitles, setHasGeneratedTitles] = useState(false) - - /* ── 智能配音推荐 ── */ - const [voiceRecommendLoading, setVoiceRecommendLoading] = useState(false) - const [voiceRecommendations, setVoiceRecommendations] = useState([]) - const [hasVoiceRecommend, setHasVoiceRecommend] = useState(false) - - /* ── 标题设置 ── */ + /* ── 标题设置(共享:step4 编辑、step7 展示、生成使用) ── */ const [titleSettings, setTitleSettings] = useState(DEFAULT_TITLE_SETTINGS) - /* ── 封面设置 ── */ + /* ── 封面设置(共享:step6 编辑、step7 展示、生成使用) ── */ const [coverSettings, setCoverSettings] = useState(DEFAULT_COVER_SETTINGS) - const { data: userTitles = [] } = useQuery({ - queryKey: ["titles"], - queryFn: () => getTitles(), - staleTime: 30_000, - }) - /* 当选中模板开启了「AI自动匹配标题」,自动填入模板预设标题 */ + + /* 当选中模板开启了「AI自动匹配标题」,自动填入模板预设标题 / 封面 */ useEffect(() => { const tpl = userTemplates.find((t) => t.id === selectedTemplate) if (tpl?.title_config) { @@ -183,20 +106,28 @@ const GeneratePage: React.FC = () => { } }, [selectedTemplate, userTemplates]) - /* ── 配音 ── */ + /* ── 配音(共享:step5 选择、step7 展示、生成使用) ── */ const [selectedVoice, setSelectedVoice] = useState("") - const [playingVoice, setPlayingVoice] = useState(null) const [voiceMode, setVoiceMode] = useState<"preset" | "custom" | "clone">("preset") - const [customVoiceText, setCustomVoiceText] = useState("") + const [selectedClonedVoice, setSelectedClonedVoice] = useState("") + + /* ── 预置音色 API(共享:step5 选择、step7 展示) ── */ + const { data: presetVoicesData } = useQuery({ + queryKey: ["preset-voices"], + queryFn: fetchPresetVoices, + }) + const presetVoices: PresetVoiceItem[] = useMemo( + () => presetVoicesData?.items ?? [], + [presetVoicesData], + ) + + /* ── 克隆声音(共享:step5 管理、step7 展示) ── */ + const [cloneModalOpen, setCloneModalOpen] = useState(false) + const { clones: clonedVoices, addClone, hasProcessing } = useCloneProgress() /* ── 生成数量 ── */ const [generateCount, setGenerateCount] = useState(1) - /* ── 克隆声音 ── */ - const [selectedClonedVoice, setSelectedClonedVoice] = useState("") - const [cloneModalOpen, setCloneModalOpen] = useState(false) - const { clones: clonedVoices, addClone, hasProcessing } = useCloneProgress() - /* ── 高级设置(隐藏但保留) ── */ const [videoRatio] = useState("16:9") const [duration] = useState(30) @@ -204,18 +135,6 @@ const GeneratePage: React.FC = () => { const [autoSubtitles] = useState(true) const [bgm] = useState(true) - /* ── 生成状态 ── */ - const [generating, setGenerating] = useState(false) - const [progress, setProgress] = useState(0) - const [generated, setGenerated] = useState(false) - const [generateError, setGenerateError] = useState(null) - const [generatedVideos, setGeneratedVideos] = useState([]) - const [previewVideo, setPreviewVideo] = useState(null) - const [previewModalOpen, setPreviewModalOpen] = useState(false) - - const progressTimer = useRef>(undefined) - const audioRef = useRef(null) - /* ── URL 参数:从模板编辑器跳转过来时携带 edit_plan_id + plan_config ── */ const [searchParams] = useSearchParams() const editPlanId = searchParams.get("edit_plan_id") @@ -300,771 +219,49 @@ const GeneratePage: React.FC = () => { loadPlanConfig() }, [editPlanId, planConfigStr]) - /* ── 预置音色 API ── */ - const { data: presetVoicesData, isLoading: presetVoicesLoading } = useQuery({ - queryKey: ["preset-voices"], - queryFn: fetchPresetVoices, - }) - const presetVoices: PresetVoiceItem[] = useMemo( - () => presetVoicesData?.items ?? [], - [presetVoicesData], - ) - - /* ── TTS 自定义合成状态 ── */ - const [customAudioUrl, setCustomAudioUrl] = useState(null) - const [ttsError, setTtsError] = useState(null) - const [ttsJobId, setTtsJobId] = useState(null) - /** 合成完成后保留的 job ID,用于"存为素材" */ - const [completedTtsJobId, setCompletedTtsJobId] = useState(null) - - /* ── 存为素材弹窗状态 ── */ - const [saveModalOpen, setSaveModalOpen] = useState(false) - const [saveName, setSaveName] = useState("") - const [saveTagIds, setSaveTagIds] = useState([]) - const [saveNewTag, setSaveNewTag] = useState("") - - /* ── 标签列表(用于存为素材弹窗) ── */ - const { data: allTags = [] } = useQuery({ - queryKey: ["generate-save-tags"], - queryFn: getTags, - staleTime: 30_000, - }) - - /* ── 素材数据 API ── */ - const { data: libraries = [] } = useQuery({ - queryKey: ["asset-libraries"], - queryFn: getAssetLibraries, - }) - const [selectedLibraryId, setSelectedLibraryId] = useState("") - - // 自动选中第一个视频库 - useEffect(() => { - if (libraries.length > 0 && !selectedLibraryId) { - setSelectedLibraryId(libraries[0].id) - } - }, [libraries, selectedLibraryId]) - - const { data: materials = { items: [], total: 0 }, isLoading: materialsLoading } = useQuery<{ - items: AssetItem[] - total: number - }>({ - queryKey: ["generate-assets", selectedLibraryId], - queryFn: () => getAssets(selectedLibraryId), - enabled: !!selectedLibraryId, - }) - - /* ── 智能素材匹配 ── */ - - const handleSmartMatch = useCallback(async () => { - if (!smartMatchInput.trim()) { - message.warning("请先输入视频内容描述") - return - } - if (materials.items.length === 0) { - message.warning("当前视频库暂无素材") - return - } - - setSmartMatching(true) - setHasMatched(true) - - // 模拟 AI 匹配延迟 - await new Promise((resolve) => setTimeout(resolve, 1500)) - - // 从素材库中随机选取 5-8 个作为推荐结果 - const shuffled = [...materials.items].sort(() => Math.random() - 0.5) - const count = Math.min(shuffled.length, 5 + Math.floor(Math.random() * 4)) - const picked = shuffled.slice(0, count) - - const results = picked.map((asset, idx) => ({ - asset, - matchScore: Math.round(85 + Math.random() * 14), // 85-99 分 - matchReason: - SMART_MATCH_REASONS[idx % SMART_MATCH_REASONS.length] + - (Math.random() > 0.5 ? ",画面质感优秀" : ""), - })) - - // 按匹配度从高到低排序 - results.sort((a, b) => b.matchScore - a.matchScore) - - setSmartMatchedResults(results) - // 默认选中匹配度 >= 90 的素材 - const defaultSelected = results.filter((r) => r.matchScore >= 90).map((r) => r.asset.id) - setSmartSelectedIds( - defaultSelected.length > 0 ? defaultSelected : results.slice(0, 3).map((r) => r.asset.id), - ) - setSmartMatching(false) - }, [smartMatchInput, materials.items]) - - const handleToggleSmartSelect = useCallback((assetId: string) => { - setSmartSelectedIds((prev) => - prev.includes(assetId) ? prev.filter((id) => id !== assetId) : [...prev, assetId], - ) - }, []) - - const handleRefreshMatch = useCallback(async () => { - if (materials.items.length <= 5) { - message.info("视频库素材较少,无法换一批") - return - } - setSmartMatching(true) - await new Promise((resolve) => setTimeout(resolve, 800)) - - const remaining = materials.items.filter( - (m) => !smartMatchedResults.some((r) => r.asset.id === m.id), - ) - const shuffled = [...remaining].sort(() => Math.random() - 0.5) - const count = Math.min(shuffled.length, 5 + Math.floor(Math.random() * 3)) - const picked = shuffled.slice(0, count) - - const results = picked.map((asset, idx) => ({ - asset, - matchScore: Math.round(80 + Math.random() * 19), - matchReason: - SMART_MATCH_REASONS[(idx + 2) % SMART_MATCH_REASONS.length] + - (Math.random() > 0.5 ? ",节奏明快" : ""), - })) - results.sort((a, b) => b.matchScore - a.matchScore) - - setSmartMatchedResults(results) - setSmartSelectedIds([]) - setSmartMatching(false) - }, [materials.items, smartMatchedResults]) - - const handleSelectAllMatched = useCallback(() => { - setSmartSelectedIds(smartMatchedResults.map((r) => r.asset.id)) - }, [smartMatchedResults]) - - const handleClearSmartSelect = useCallback(() => { - setSmartSelectedIds([]) - }, []) - - /* ── 智能标题生成 ── */ - - const extractTopic = (text: string): string => { - const keywords = text - .replace(/[,。!?、,.!?]/g, " ") - .split(/\s+/) - .filter(Boolean) - if (keywords.length === 0) return "这个话题" - // 取前3个关键词组合 - return keywords.slice(0, 3).join("") + /* ── 克隆成功回调 ── */ + const handleCloneSuccess = (voice: VoiceClone) => { + addClone(voice) + setCloneModalOpen(false) + message.success("音色克隆成功!") } - const handleGenerateAiTitles = useCallback(async () => { - if (!aiTitleInput.trim()) { - message.warning("请先输入视频描述或关键词") - return - } - setAiTitleGenerating(true) - setHasGeneratedTitles(true) - - // 模拟 AI 生成延迟 - await new Promise((resolve) => setTimeout(resolve, 1200)) - - const topic = extractTopic(aiTitleInput) - const results: Array<{ - title: string - highlight: string - style: "catchy" | "emotional" | "informative" - }> = [] - - const styles: Array<"catchy" | "emotional" | "informative"> = [ - "catchy", - "emotional", - "informative", - ] - styles.forEach((style) => { - const templates = AI_TITLE_TEMPLATES[style] - // 每种风格随机选2个 - const shuffled = [...templates].sort(() => Math.random() - 0.5).slice(0, 2) - shuffled.forEach((tpl) => { - const title = tpl.replace(/\{topic\}/g, topic) - const highlights = { - catchy: "吸睛标题", - emotional: "情感共鸣", - informative: "知识干货", - } - results.push({ - title, - highlight: highlights[style], - style, - }) - }) - }) - - // 打乱顺序 - results.sort(() => Math.random() - 0.5) - setAiTitleResults(results) - setAiTitleGenerating(false) - }, [aiTitleInput]) - - const handleSelectAiTitle = useCallback((title: string) => { - setTitleSettings((prev) => ({ ...prev, title, aiAutoSelect: false })) - message.success("已选用此标题") - }, []) - - const handleRefreshAiTitles = useCallback(async () => { - if (!aiTitleInput.trim()) return - setAiTitleGenerating(true) - await new Promise((resolve) => setTimeout(resolve, 800)) - // 重新生成一批 - const topic = extractTopic(aiTitleInput) - const results: typeof aiTitleResults = [] - const styles: Array<"catchy" | "emotional" | "informative"> = [ - "catchy", - "emotional", - "informative", - ] - const highlights = { catchy: "吸睛标题", emotional: "情感共鸣", informative: "知识干货" } - styles.forEach((style) => { - const templates = AI_TITLE_TEMPLATES[style] - const shuffled = [...templates].sort(() => Math.random() - 0.5).slice(0, 2) - shuffled.forEach((tpl) => { - results.push({ - title: tpl.replace(/\{topic\}/g, topic), - highlight: highlights[style], - style, - }) - }) - }) - results.sort(() => Math.random() - 0.5) - setAiTitleResults(results) - setAiTitleGenerating(false) - }, [aiTitleInput]) - - /* ── 智能配音推荐 ── */ - const handleVoiceRecommend = useCallback(async () => { - if (presetVoices.length === 0) return - setVoiceRecommendLoading(true) - setHasVoiceRecommend(true) - - await new Promise((resolve) => setTimeout(resolve, 1000)) - - // 根据标题内容风格模拟推荐:情感类→温柔女声,知识类→沉稳男声,活力类→阳光少年 - const title = titleSettings.title.toLowerCase() - let recommended: string[] = [] - - const femaleVoices = presetVoices.filter((v) => v.gender === "female").map((v) => v.voice_id) - const maleVoices = presetVoices.filter((v) => v.gender === "male").map((v) => v.voice_id) - const childVoices = presetVoices.filter((v) => v.gender === "child").map((v) => v.voice_id) - - if (/情感|感人|温暖|治愈|故事|回忆/.test(title)) { - recommended = femaleVoices.slice(0, 3) - } else if (/教程|知识|科普|干货|讲解|分析/.test(title)) { - recommended = maleVoices.slice(0, 2).concat(femaleVoices.slice(0, 1)) - } else if (/活力|热血|运动|搞笑|有趣/.test(title)) { - recommended = childVoices.slice(0, 1).concat(maleVoices.slice(0, 1), femaleVoices.slice(0, 1)) - } else { - // 默认推荐前3个 - recommended = presetVoices.slice(0, 3).map((v) => v.voice_id) - } - - // 不足3个时补足 - if (recommended.length < 3) { - const others = presetVoices - .filter((v) => !recommended.includes(v.voice_id)) - .map((v) => v.voice_id) - recommended = recommended.concat(others.slice(0, 3 - recommended.length)) - } - - setVoiceRecommendations(recommended) - setVoiceRecommendLoading(false) - }, [presetVoices, titleSettings.title]) - - const handleSelectRecommendedVoice = useCallback((voiceId: string) => { - setVoiceMode("preset") - setSelectedVoice(voiceId) - }, []) - - /* ── 生成阶段映射 ── */ - const getGenerationPhase = (p: number) => { - if (p < 20) return { label: "分析素材与配置", icon: "🔍" } - if (p < 50) return { label: "智能剪辑合成", icon: "🎬" } - if (p < 80) return { label: "渲染视频中", icon: "⚡" } - return { label: "即将完成", icon: "✨" } - } - - const handleCloneSuccess = useCallback( - (voice: VoiceClone) => { - addClone(voice) - setCloneModalOpen(false) - message.success("音色克隆成功!") - }, - [addClone], - ) - - /* ── 事件 ── */ - - const toggleVoicePlay = useCallback( - (voiceId: string, previewUrl: string | null) => { - if (playingVoice === voiceId) { - audioRef.current?.pause() - audioRef.current = null - setPlayingVoice(null) - return - } - audioRef.current?.pause() - if (!previewUrl) { - message.warning("该音色暂无试听音频") - return - } - const audio = new Audio(previewUrl) - audioRef.current = audio - audio.play().catch(() => { - message.error("播放失败,请检查网络") - }) - audio.onended = () => { - setPlayingVoice(null) - audioRef.current = null - } - setPlayingVoice(voiceId) - }, - [playingVoice], - ) - - /* ── TTS mutation ── */ - const synthesizeMutation = useMutation({ - mutationFn: synthesizeSpeech, - onSuccess: (data) => { - setTtsJobId(data.job_id) - message.info("语音合成已提交,等待处理…") - }, - onError: () => { - setTtsError("语音合成请求失败,请重试") - }, - }) - - /** 轮询 TTS 任务状态 */ - useEffect(() => { - if (!ttsJobId) return - let cancelled = false - let timer: ReturnType - - const poll = async () => { - try { - const status = await getTTSJobStatus(ttsJobId) - if (cancelled) return - if (status.status === "completed") { - setCustomAudioUrl(status.output_audio_url) - setCompletedTtsJobId(ttsJobId) - setTtsJobId(null) - setTtsError(null) - message.success("语音合成完成!") - return - } - if (status.status === "failed" || status.status === "cancelled") { - setTtsError(status.error_message || "语音合成失败") - setTtsJobId(null) - return - } - timer = setTimeout(poll, 2000) - } catch { - if (!cancelled) { - setTtsError("查询合成状态失败") - setTtsJobId(null) - } - } - } - - timer = setTimeout(poll, 2000) - return () => { - cancelled = true - clearTimeout(timer) - } - }, [ttsJobId]) - - /** 触发自定义文本 TTS 合成 */ - const handleSynthesizeVoice = useCallback(() => { - if (!customVoiceText.trim()) { - message.warning("请先输入配音文案") - return - } - setTtsError(null) - setCustomAudioUrl(null) - synthesizeMutation.mutate({ - text: customVoiceText.trim(), - voice_id: selectedVoice || undefined, - language: "zh-CN", - }) - }, [customVoiceText, selectedVoice, synthesizeMutation]) - - /* ── 存为素材 mutation ── */ - const saveToLibraryMutation = useMutation({ - mutationFn: (params: { name?: string; tag_ids?: string[] }) => - saveTtsToLibrary(completedTtsJobId!, params), - onSuccess: () => { - message.success({ - content: ( - - 已保存到配音库!{" "} - - 去视频库查看 - - - ), - duration: 5, - }) - setSaveModalOpen(false) - setSaveName("") - setSaveTagIds([]) - setSaveNewTag("") - setCompletedTtsJobId(null) - setCustomAudioUrl(null) - }, - onError: (err: Error) => { - message.error(`保存失败:${err.message || "请重试"}`) - }, - }) - - /** 打开存为素材弹窗 */ - const handleOpenSaveModal = useCallback(() => { - setSaveName("") - setSaveTagIds([]) - setSaveNewTag("") - setSaveModalOpen(true) - }, []) - - /** 确认保存 */ - const handleConfirmSave = useCallback(() => { - if (!completedTtsJobId) return - saveToLibraryMutation.mutate({ - name: saveName.trim() || undefined, - tag_ids: saveTagIds.length > 0 ? saveTagIds : undefined, - }) - }, [completedTtsJobId, saveName, saveTagIds, saveToLibraryMutation]) - - /** 在弹窗中新增标签(先创建再选中) */ - const handleAddTagInModal = useCallback( - async (tagName: string) => { - const trimmed = tagName.trim() - if (!trimmed) return - /* 已在选中列表则跳过 */ - const existing = allTags.find((t) => t.name === trimmed) - if (existing) { - if (!saveTagIds.includes(existing.id)) { - setSaveTagIds((prev) => [...prev, existing.id]) - } - return - } - try { - const created = await createTag(trimmed) - setSaveTagIds((prev) => [...prev, created.id]) - setSaveNewTag("") - } catch { - message.error(`创建标签"${trimmed}"失败`) - } - }, - [allTags, saveTagIds], - ) - - /** 保存成功后跳转到视频库 */ - const handleGoToLibrary = useCallback(() => { - navigate("/app/voice-materials") - }, [navigate]) - - const handleGenerate = useCallback(async () => { - console.log("[handleGenerate] 开始生成, 参数:", { - titleSettings, - selectedTemplate, - selectedMaterials, - voiceMode, - }) - if (!titleSettings.title.trim()) { - message.warning("请先选择或输入标题") - return - } - if (materialMode === "manual" && selectedMaterials.length === 0) { - message.warning("请至少选择一个素材") - return - } - - if (voiceMode === "clone" && !selectedClonedVoice) { - message.warning("请先选择一个克隆音色") - return - } - - setGenerating(true) - setProgress(0) - setGenerated(false) - setGenerateError(null) - - try { - const voiceConfig: Pick< - EditPlanConfig, - "voice_id" | "voice_clone_profile_id" | "custom_audio_url" | "custom_text" - > = {} - if (voiceMode === "preset") { - voiceConfig.voice_id = selectedVoice || undefined - } else if (voiceMode === "clone") { - voiceConfig.voice_clone_profile_id = selectedClonedVoice || undefined - } else if (voiceMode === "custom") { - voiceConfig.voice_id = selectedVoice || undefined - if (customAudioUrl) voiceConfig.custom_audio_url = customAudioUrl - if (customVoiceText.trim()) voiceConfig.custom_text = customVoiceText.trim() - } - - // 获取或创建草稿(新架构:GET /templates/{templateId}/editor 自动创建) - await getEditPlan(selectedTemplate) - - // 更新草稿内容 + 切换到 editing 状态 - await updateEditPlan(selectedTemplate, { - name: titleSettings.title.trim(), - config: { - asset_ids: materialMode === "auto" ? smartSelectedIds : selectedMaterials, - title_config: { - ai_auto_select: titleSettings.aiAutoSelect, - content: titleSettings.title, - position: titleSettings.position, - font_preset: titleSettings.font, - font_color: titleSettings.color, - font_size: titleSettings.size, - }, - cover_config: coverSettings, - ...voiceConfig, - ratio: videoRatio, - style, - duration, - auto_subtitles: autoSubtitles, - bgm, - generate_count: generateCount, - material_mode: materialMode, - }, - total_duration: duration, - status: "editing", - }) - - await generateEditPlan(selectedTemplate) - - const poll = async () => { - try { - const data = await getGenerationStatus(selectedTemplate) - - if (data.plan_status === "completed") { - setProgress(100) - setGenerating(false) - setGenerated(true) - - // 获取生成的视频结果 - if (data.generation_task_id) { - try { - const videos = await getGenerationTaskResults(data.generation_task_id) - setGeneratedVideos(videos) - } catch (err) { - console.error("[获取生成结果失败]", err) - } - } - - message.success("视频生成完成!") - return - } - if (data.plan_status === "failed") { - setGenerating(false) - // 提取后端返回的错误详情,便于排查 - // 注意:后端返回的 error_message/error/message 可能是对象而非字符串 - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- 防御性错误提取,后端错误格式不固定 - const dataAny = data as Record - const rawMsg = - dataAny.error_message || - dataAny.error || - dataAny.message || - (data.clips || []).find((c) => c.status === "failed")?.error_message || - "视频生成失败,请联系管理员或重试" - // 安全提取字符串:递归处理嵌套对象(后端可能返回 {code, message: {code, message}} 等嵌套结构) - const safeExtract = (val: unknown): string => { - if (typeof val === "string") return val - if (typeof val === "object" && val !== null) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- 防御性错误提取,后端错误格式不固定 - const obj = val as Record - if (typeof obj.message === "string") return obj.message - if (typeof obj.msg === "string") return obj.msg - if (typeof obj.detail === "string") return obj.detail - if (obj.message && typeof obj.message === "object") return safeExtract(obj.message) - return JSON.stringify(val) - } - return String(val ?? "") - } - const errorMsg = safeExtract(rawMsg) - console.error("[生成失败] templateId:", selectedTemplate, "响应:", data) - setGenerateError(errorMsg) - message.error(errorMsg) - return - } - - const clips = data.clips || [] - const total = clips.length || 1 - const done = clips.filter((c: { status: string }) => c.status === "completed").length - setProgress(Math.round((done / total) * 100)) - - progressTimer.current = setTimeout(poll, 2000) as unknown as ReturnType< - typeof setInterval - > - } catch (pollErr) { - // 轮询接口本身出错(网络/鉴权等),记录并继续轮询一次 - console.error("[轮询出错] templateId:", selectedTemplate, pollErr) - progressTimer.current = setTimeout(poll, 3000) as unknown as ReturnType< - typeof setInterval - > - } - } - - progressTimer.current = setTimeout(poll, 2000) as unknown as ReturnType - } catch (err: unknown) { - console.error("[handleGenerate] 生成失败:", err) - setGenerating(false) - // 提取 axios 响应中的后端错误信息 - const axiosErr = err as { - response?: { - data?: { - message?: string | object - error?: string | object - detail?: string | object - msg?: string | object - } - } - message?: string - } - // 安全提取错误消息:递归处理嵌套对象(后端可能返回 {code, message: {code, message}} 等) - const extractString = (val: unknown): string => { - if (typeof val === "string") return val - if (typeof val === "object" && val !== null) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- 防御性错误提取,后端错误格式不固定 - const obj = val as Record - if (typeof obj.message === "string") return obj.message - if (typeof obj.msg === "string") return obj.msg - if (typeof obj.detail === "string") return obj.detail - // 嵌套对象:递归提取 - if (typeof obj.message === "object" && obj.message !== null) - return extractString(obj.message) - if (typeof obj.msg === "object" && obj.msg !== null) return extractString(obj.msg) - return JSON.stringify(val) - } - return "" - } - const backendMsg = - extractString(axiosErr.response?.data?.message) || - extractString(axiosErr.response?.data?.error) || - extractString(axiosErr.response?.data?.detail) || - extractString(axiosErr.response?.data?.msg) || - axiosErr.message || - "" - console.error("[handleGenerate] 错误信息:", backendMsg, "完整错误:", axiosErr) - // 确保 errorMsg 一定是字符串(后端可能返回 {code, message} 嵌套对象) - const safeExtractErr = (val: unknown): string => { - if (typeof val === "string") return val - if (typeof val === "object" && val !== null) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- 防御性错误提取,后端错误格式不固定 - const obj = val as Record - if (typeof obj.message === "string") return obj.message - if (typeof obj.msg === "string") return obj.msg - if (typeof obj.detail === "string") return obj.detail - // 嵌套对象:递归提取 - if (typeof obj.message === "object") return safeExtractErr(obj.message) - return JSON.stringify(val) - } - return String(val ?? "") - } - const rawError = safeExtractErr(backendMsg) - // 将技术错误翻译为用户友好提示(不暴露状态机、字段名等内部概念) - const translateError = (msg: string): string => { - if (!msg) return "生成失败,请检查网络后重试或联系管理员" - // 状态机相关错误 - if (msg.includes("editing") || msg.includes("draft") || msg.includes("状态")) { - return "正在准备生成,请稍候再试" - } - // 参数校验错误 - if (msg.includes("template_id") || msg.includes("not found") || msg.includes("不存在")) { - return "所选模板或素材不可用,请重新选择" - } - if (msg.includes("asset") && (msg.includes("not found") || msg.includes("missing"))) { - return "素材数据异常,请返回视频库重新检查" - } - // 网络/超时 - if (msg.includes("timeout") || msg.includes("network") || msg.includes("ECONN")) { - return "网络连接超时,请检查网络后重试" - } - // 配额/限制 - if (msg.includes("quota") || msg.includes("limit") || msg.includes("exceed")) { - return "已达到生成次数上限,请稍后再试或联系客服" - } - // 兜底:返回原始消息(如果已经是中文人话)或默认提示 - if (msg.length > 0 && msg.length < 100 && !msg.includes("{")) return msg - return "生成失败,请稍后重试或联系管理员" - } - const finalMsg = translateError(rawError) - setGenerateError(finalMsg) - message.error(finalMsg) - } - }, [ + /* ── 视频生成核心逻辑 ── */ + const { + generating, + progress, + generated, + generateError, + generatedVideos, + generate: handleGenerate, + retry: handleRetryGenerate, + dismissError: handleDismissError, + download: handleDownload, + share: handleShare, + } = useGenerateVideo({ titleSettings, + selectedTemplate, selectedMaterials, - selectedVoice, + materialMode, + smartSelectedIds, voiceMode, + selectedVoice, selectedClonedVoice, - customAudioUrl, - customVoiceText, + coverSettings, videoRatio, style, duration, autoSubtitles, bgm, - selectedTemplate, generateCount, - materialMode, - coverSettings, - smartSelectedIds, - ]) + }) - /* 重新生成(失败后重试) */ - const handleRetryGenerate = useCallback(() => { - setGenerateError(null) - handleGenerate() - }, [handleGenerate]) - - /* ── 下载视频 ── */ - const handleDownload = useCallback(async () => { - if (!generatedVideos.length) return - const video = generatedVideos[0] - try { - // 优先使用 download_url(签名 URL),回退到 file_url - const url = video.download_url || video.file_url - if (url) { - const a = document.createElement("a") - a.href = url - a.download = video.name || "generated-video.mp4" - a.target = "_blank" - document.body.appendChild(a) - a.click() - document.body.removeChild(a) - } - } catch (err) { - console.error("[下载失败]", err) - message.error("下载失败,请重试") - } - }, [generatedVideos]) - - /* ── 分享视频 ── */ - const handleShare = useCallback(async () => { - if (!generatedVideos.length) return - const video = generatedVideos[0] - const shareUrl = video.file_url || window.location.href - try { - await navigator.clipboard.writeText(shareUrl) - message.success("视频链接已复制到剪贴板") - } catch { - // fallback: 显示 URL 让用户手动复制 - message.info(`视频链接: ${shareUrl}`) - } - }, [generatedVideos]) + /* ── 预览弹窗状态 ── */ + const [previewVideo, setPreviewVideo] = useState(null) + const [previewModalOpen, setPreviewModalOpen] = useState(false) /* ── 步骤导航 ── */ - const goNext = useCallback(() => { + const goNext = () => { if (currentStep === 1 && !selectedTemplate) { message.warning("请先选择一个模板") return @@ -1084,1498 +281,106 @@ const GeneratePage: React.FC = () => { if (currentStep < 7) { setCurrentStep((s) => s + 1) } - }, [ - currentStep, - selectedTemplate, - selectedMaterials.length, - titleSettings, - materialMode, - smartSelectedIds.length, - ]) + } - const goPrev = useCallback(() => { + const goPrev = () => { if (currentStep > 1) { setCurrentStep((s) => s - 1) } - }, [currentStep]) - - /* ── 辅助 ── */ - const getTemplateName = () => - userTemplates.find((t) => t.id === selectedTemplate)?.name ?? "未选择" - - const getVoiceName = () => { - if (voiceMode === "clone") { - const cv = clonedVoices.find((v) => v.id === selectedClonedVoice) - return cv ? cv.name : "未选择" - } - const pv = presetVoices.find((v) => v.voice_id === selectedVoice) - return pv ? pv.name : "未选择" } - /* ================================================================ - 渲染 — 步骤内容 - ================================================================ */ - - /** 步骤 1:选择模板 */ - const renderStep1 = () => ( -
-

🎨 选择模板

- {userTemplates.length === 0 ? ( -
-

暂无可用模板

-

- 请先在「模板编辑器」中创建模板 -

-
- ) : ( -
- {userTemplates.map((tpl) => ( -
setSelectedTemplate(tpl.id)} - role="button" - tabIndex={0} - aria-pressed={selectedTemplate === tpl.id} - onKeyDown={(e) => { - if (e.key === "Enter" || e.key === " ") { - e.preventDefault() - setSelectedTemplate(tpl.id) - } - }} - > - -
- 🎬 -
-

{tpl.name}

-

- {tpl.estimated_duration}s · {tpl.segments.length}片段 -

- {tpl.tags.length > 0 && ( -
- {tpl.tags.map((tag) => ( - - {tag} - - ))} -
- )} -
- ))} -
- )} -
- ) - - /** 步骤 2:选择素材(双模式:手动选择 / 自动匹配) */ - const renderStep2 = () => ( -
-

📦 选择素材

- - {/* ── 模式切换 Tab ── */} -
- - -
- - {/* ── 视频库选择(两种模式共用) ── */} -
- - -
- - {/* ── 手动选择模式 ── */} - {materialMode === "manual" && ( - <> -
- 已选 {selectedMaterials.length} 个素材 -
- - {/* 素材列表 */} -
- {materialsLoading ? ( - 加载素材中… - ) : materials.items.length === 0 ? ( - - 暂无素材,请先在视频库中上传 - - ) : ( -
- {materials.items.map((m) => { - const checked = selectedMaterials.includes(m.id) - return ( - - ) - })} -
- )} -
- - )} - - {/* ── 自动匹配模式 ── */} - {materialMode === "auto" && ( -
- {/* 描述输入区 */} -
- -