diff --git a/apps/web/src/pages/generate/GeneratePage.tsx b/apps/web/src/pages/generate/GeneratePage.tsx index 1ff9fc9ac..fd2fcdd93 100644 --- a/apps/web/src/pages/generate/GeneratePage.tsx +++ b/apps/web/src/pages/generate/GeneratePage.tsx @@ -4,19 +4,14 @@ * 左右布局:左侧 generate-form + 右侧 generate-preview * 主组件仅保留共享状态、步骤切换、整体布局 * 各 Step 的 UI 与业务逻辑拆分至 components/ + hooks/ + * 生成核心逻辑封装在 useGenerateVideo hook */ -import React, { useState, useRef, useCallback, useEffect, useMemo } from "react" +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, EditPlanConfig, TitleConfig } from "@/api/template-editor" -import { - generateEditPlan, - updateEditPlan, - getGenerationTaskResults, - getGenerationStatus, - getEditPlan, -} from "@/api/template-editor" +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 type { PresetVoiceItem } from "@/api/voices" @@ -37,6 +32,7 @@ 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 DEFAULT_TITLE_SETTINGS: TitleSettings = { @@ -72,15 +68,15 @@ const GeneratePage: React.FC = () => { } }, [userTemplates, selectedTemplate]) - /* ── 素材 ── */ + /* ── 素材(共享:step2 选择、step7 展示、生成使用) ── */ const [selectedMaterials, setSelectedMaterials] = useState([]) const [materialMode, setMaterialMode] = useState<"manual" | "auto">("manual") const [smartSelectedIds, setSmartSelectedIds] = useState([]) - /* ── 标题设置 ── */ + /* ── 标题设置(共享:step4 编辑、step7 展示、生成使用) ── */ const [titleSettings, setTitleSettings] = useState(DEFAULT_TITLE_SETTINGS) - /* ── 封面设置 ── */ + /* ── 封面设置(共享:step6 编辑、step7 展示、生成使用) ── */ const [coverSettings, setCoverSettings] = useState(DEFAULT_COVER_SETTINGS) /* 当选中模板开启了「AI自动匹配标题」,自动填入模板预设标题 / 封面 */ @@ -110,7 +106,7 @@ const GeneratePage: React.FC = () => { } }, [selectedTemplate, userTemplates]) - /* ── 配音(共享状态:在 step5 选择、step7 展示、handleGenerate 使用) ── */ + /* ── 配音(共享:step5 选择、step7 展示、生成使用) ── */ const [selectedVoice, setSelectedVoice] = useState("") const [voiceMode, setVoiceMode] = useState<"preset" | "custom" | "clone">("preset") const [selectedClonedVoice, setSelectedClonedVoice] = useState("") @@ -139,17 +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) - /* ── URL 参数:从模板编辑器跳转过来时携带 edit_plan_id + plan_config ── */ const [searchParams] = useSearchParams() const editPlanId = searchParams.get("edit_plan_id") @@ -235,306 +220,48 @@ const GeneratePage: React.FC = () => { }, [editPlanId, planConfigStr]) /* ── 克隆成功回调 ── */ - const handleCloneSuccess = useCallback( - (voice: VoiceClone) => { - addClone(voice) - setCloneModalOpen(false) - message.success("音色克隆成功!") - }, - [addClone], - ) - - /* ── 生成阶段映射 ── */ - 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 = (voice: VoiceClone) => { + addClone(voice) + setCloneModalOpen(false) + message.success("音色克隆成功!") } - /* ── 生成视频 ── */ - 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 - // 注意:customAudioUrl / customVoiceText 在 step5 hook 中, - // 如需使用自定义配音,需从 step5 组件传回 - } - - // 获取或创建草稿(新架构: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) - // 提取后端返回的错误详情,便于排查 - // 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: { status: string }) => c.status === "failed") - ?.error_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 - } - 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) - 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, + 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 { - 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 { - 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 @@ -554,20 +281,13 @@ 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 renderCurrentStep = () => { @@ -658,7 +378,7 @@ const GeneratePage: React.FC = () => { progress={progress} generatedVideos={generatedVideos} onRetry={handleRetryGenerate} - onDismissError={() => setGenerateError(null)} + onDismissError={handleDismissError} /> ) default: diff --git a/apps/web/src/pages/generate/hooks/useGenerateVideo.ts b/apps/web/src/pages/generate/hooks/useGenerateVideo.ts new file mode 100644 index 000000000..4936ecef3 --- /dev/null +++ b/apps/web/src/pages/generate/hooks/useGenerateVideo.ts @@ -0,0 +1,371 @@ +/** + * 视频生成 Hook + * 封装视频生成的核心逻辑、状态管理、轮询等 + */ +import { useState, useRef, useCallback } from "react" +import { message } from "antd" +import type { GeneratedVideo, EditPlanConfig, TitleConfig } from "@/api/template-editor" +import { + generateEditPlan, + updateEditPlan, + getGenerationTaskResults, + getGenerationStatus, + getEditPlan, +} from "@/api/template-editor" +import type { CoverConfig } from "../../editing-planner/types" +import type { TitleSettings } from "../types" + +interface UseGenerateVideoProps { + titleSettings: TitleSettings + selectedTemplate: string + selectedMaterials: string[] + materialMode: "manual" | "auto" + smartSelectedIds: string[] + voiceMode: "preset" | "custom" | "clone" + selectedVoice: string + selectedClonedVoice: string + coverSettings: CoverConfig + videoRatio: string + style: string + duration: number + autoSubtitles: boolean + bgm: boolean + generateCount: number +} + +export function useGenerateVideo({ + titleSettings, + selectedTemplate, + selectedMaterials, + materialMode, + smartSelectedIds, + voiceMode, + selectedVoice, + selectedClonedVoice, + coverSettings, + videoRatio, + style, + duration, + autoSubtitles, + bgm, + generateCount, +}: UseGenerateVideoProps) { + /* ── 生成状态 ── */ + 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 progressTimer = useRef>(undefined) + + /* ── 生成阶段映射 ── */ + 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 generate = 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 + // 注意:customAudioUrl / customVoiceText 在 step5 hook 中, + // 自定义配音模式需从 step5 组件传回 + } + + // 获取或创建草稿 + 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) + // 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: { status: string }) => c.status === "failed") + ?.error_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) + const axiosErr = err as { + response?: { + data?: { + message?: string | object + error?: string | object + detail?: string | object + msg?: string | object + } + } + message?: string + } + 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) + 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) + } + }, [ + titleSettings, + selectedMaterials, + selectedVoice, + voiceMode, + selectedClonedVoice, + videoRatio, + style, + duration, + autoSubtitles, + bgm, + selectedTemplate, + generateCount, + materialMode, + coverSettings, + smartSelectedIds, + ]) + + /* 重新生成(失败后重试) */ + const retry = useCallback(() => { + setGenerateError(null) + generate() + }, [generate]) + + /* 清除错误 */ + const dismissError = useCallback(() => { + setGenerateError(null) + }, []) + + /* ── 下载视频 ── */ + const download = useCallback(async () => { + if (!generatedVideos.length) return + const video = generatedVideos[0] + try { + 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 share = 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 { + message.info(`视频链接: ${shareUrl}`) + } + }, [generatedVideos]) + + return { + // 状态 + generating, + progress, + generated, + generateError, + generatedVideos, + // 操作 + generate, + retry, + dismissError, + download, + share, + // 工具 + getGenerationPhase, + } +} + +export default useGenerateVideo