diff --git a/apps/web/src/pages/generate/hooks/generate-video/errorUtils.ts b/apps/web/src/pages/generate/hooks/generate-video/errorUtils.ts new file mode 100644 index 000000000..4c1e3de91 --- /dev/null +++ b/apps/web/src/pages/generate/hooks/generate-video/errorUtils.ts @@ -0,0 +1,64 @@ +/** + * 错误提取工具 + * 从各种响应格式中安全提取错误消息 + */ + +/** 安全提取字符串错误信息 */ +export const safeExtractError = (val: unknown): string => { + if (typeof val === "string") return val + if (typeof val === "object" && val !== null) { + 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 safeExtractError(obj.message) + return JSON.stringify(val) + } + return String(val ?? "") +} + +/** 错误信息翻译/友好化 */ +export 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 "生成失败,请稍后重试或联系管理员" +} + +/** 从 axios 错误对象中提取后端消息 */ +export const extractBackendError = (err: unknown): string => { + const axiosErr = err as { + response?: { + data?: { + message?: string | object + error?: string | object + detail?: string | object + msg?: string | object + } + } + message?: string + } + return ( + safeExtractError(axiosErr.response?.data?.message) || + safeExtractError(axiosErr.response?.data?.error) || + safeExtractError(axiosErr.response?.data?.detail) || + safeExtractError(axiosErr.response?.data?.msg) || + axiosErr.message || + "" + ) +} diff --git a/apps/web/src/pages/generate/hooks/generate-video/phase.ts b/apps/web/src/pages/generate/hooks/generate-video/phase.ts new file mode 100644 index 000000000..e58b10a02 --- /dev/null +++ b/apps/web/src/pages/generate/hooks/generate-video/phase.ts @@ -0,0 +1,9 @@ +import type { GenerationPhase } from "./types" + +/** 生成阶段映射 */ +export const getGenerationPhase = (p: number): GenerationPhase => { + if (p < 20) return { label: "分析素材与配置", icon: "🔍" } + if (p < 50) return { label: "智能剪辑合成", icon: "🎬" } + if (p < 80) return { label: "渲染视频中", icon: "⚡" } + return { label: "即将完成", icon: "✨" } +} diff --git a/apps/web/src/pages/generate/hooks/generate-video/types.ts b/apps/web/src/pages/generate/hooks/generate-video/types.ts new file mode 100644 index 000000000..c9434c5a6 --- /dev/null +++ b/apps/web/src/pages/generate/hooks/generate-video/types.ts @@ -0,0 +1,48 @@ +import type { GeneratedVideo, EditPlanConfig } from "@/api/template-editor" +import type { CoverConfig } from "../../../editing-planner/types" +import type { TitleSettings } from "../../types" + +/** useGenerateVideo 入参 */ +export 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 interface GenerationPhase { + label: string + icon: string +} + +/** useGenerateVideo 返回值 */ +export interface UseGenerateVideoResult { + generating: boolean + progress: number + generated: boolean + generateError: string | null + generatedVideos: GeneratedVideo[] + generate: () => Promise + retry: () => void + dismissError: () => void + download: () => Promise + share: () => Promise + getGenerationPhase: (p: number) => GenerationPhase +} + +export type EditPlanVoiceConfig = Pick< + EditPlanConfig, + "voice_id" | "voice_clone_profile_id" | "custom_audio_url" | "custom_text" +> diff --git a/apps/web/src/pages/generate/hooks/generate-video/useGenerationPolling.ts b/apps/web/src/pages/generate/hooks/generate-video/useGenerationPolling.ts new file mode 100644 index 000000000..54bf7a27d --- /dev/null +++ b/apps/web/src/pages/generate/hooks/generate-video/useGenerationPolling.ts @@ -0,0 +1,87 @@ +import { useRef, useCallback } from "react" +import { message } from "antd" +import { getGenerationStatus, getGenerationTaskResults } from "@/api/template-editor" +import { safeExtractError } from "./errorUtils" + +interface UseGenerationPollingOptions { + templateId: string + onProgress: (progress: number) => void + onComplete: (videos: unknown[]) => void + onFailed: (errorMsg: string) => void +} + +/** + * 生成状态轮询 Hook + * 轮询生成状态,更新进度,处理完成/失败 + */ +export const useGenerationPolling = ({ + templateId, + onProgress, + onComplete, + onFailed, +}: UseGenerationPollingOptions) => { + const progressTimer = useRef>() + + const clearTimer = useCallback(() => { + if (progressTimer.current) { + clearTimeout(progressTimer.current) + progressTimer.current = undefined + } + }, []) + + const startPolling = useCallback(() => { + const poll = async () => { + try { + const data = await getGenerationStatus(templateId) + + if (data.plan_status === "completed") { + onProgress(100) + // 获取生成的视频结果 + let videos: unknown[] = [] + if (data.generation_task_id) { + try { + videos = await getGenerationTaskResults(data.generation_task_id) + } catch (err) { + console.error("[获取生成结果失败]", err) + } + } + onComplete(videos) + message.success("视频生成完成!") + return + } + if (data.plan_status === "failed") { + const dataAny = data as unknown as Record + const rawMsg = + dataAny.error_message || + dataAny.error || + dataAny.message || + (Array.isArray(data.clips) + ? (data.clips as { status: string; error_message?: string }[]).find( + (c) => c.status === "failed", + )?.error_message + : undefined) || + "视频生成失败,请联系管理员或重试" + const errorMsg = safeExtractError(rawMsg) + console.error("[生成失败] templateId:", templateId, "响应:", data) + onFailed(errorMsg) + message.error(errorMsg) + return + } + + const clips = data.clips || [] + const total = clips.length || 1 + const done = (clips as { status: string }[]).filter((c) => c.status === "completed").length + onProgress(Math.round((done / total) * 100)) + + progressTimer.current = setTimeout(poll, 2000) + } catch (pollErr) { + console.error("[轮询出错] templateId:", templateId, pollErr) + progressTimer.current = setTimeout(poll, 3000) + } + } + + progressTimer.current = setTimeout(poll, 2000) + }, [templateId, onProgress, onComplete, onFailed]) + + return { startPolling, clearTimer } +} diff --git a/apps/web/src/pages/generate/hooks/generate-video/voiceConfig.ts b/apps/web/src/pages/generate/hooks/generate-video/voiceConfig.ts new file mode 100644 index 000000000..a84faa845 --- /dev/null +++ b/apps/web/src/pages/generate/hooks/generate-video/voiceConfig.ts @@ -0,0 +1,33 @@ +import type { EditPlanConfig } from "@/api/template-editor" + +type VoiceConfig = Pick< + EditPlanConfig, + "voice_id" | "voice_clone_profile_id" | "custom_audio_url" | "custom_text" +> + +interface BuildVoiceConfigOptions { + voiceMode: "preset" | "custom" | "clone" + selectedVoice: string + selectedClonedVoice: string +} + +/** + * 根据 voiceMode 构建配音配置 + */ +export const buildVoiceConfig = ({ + voiceMode, + selectedVoice, + selectedClonedVoice, +}: BuildVoiceConfigOptions): VoiceConfig => { + const voiceConfig: VoiceConfig = {} + 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 组件传回 + } + return voiceConfig +} diff --git a/apps/web/src/pages/generate/hooks/useGenerateVideo.ts b/apps/web/src/pages/generate/hooks/useGenerateVideo.ts index f7acd0cc5..1507d6e30 100644 --- a/apps/web/src/pages/generate/hooks/useGenerateVideo.ts +++ b/apps/web/src/pages/generate/hooks/useGenerateVideo.ts @@ -2,54 +2,35 @@ * 视频生成 Hook * 封装视频生成的核心逻辑、状态管理、轮询等 */ -import { useState, useRef, useCallback } from "react" +import { useState, useCallback } from "react" import { message } from "antd" -import type { GeneratedVideo, EditPlanConfig } 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" +import type { GeneratedVideo } from "@/api/template-editor" +import { generateEditPlan, updateEditPlan, getEditPlan } from "@/api/template-editor" +import type { UseGenerateVideoProps } from "./generate-video/types" +import { getGenerationPhase } from "./generate-video/phase" +import { useGenerationPolling } from "./generate-video/useGenerationPolling" +import { buildVoiceConfig } from "./generate-video/voiceConfig" +import { extractBackendError, translateError } from "./generate-video/errorUtils" -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(props: UseGenerateVideoProps) { + const { + titleSettings, + selectedTemplate, + selectedMaterials, + materialMode, + smartSelectedIds, + voiceMode, + selectedVoice, + selectedClonedVoice, + coverSettings, + videoRatio, + style, + duration, + autoSubtitles, + bgm, + generateCount, + } = props -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) @@ -57,24 +38,26 @@ export function useGenerateVideo({ const [generateError, setGenerateError] = useState(null) const [generatedVideos, setGeneratedVideos] = useState([]) - const progressTimer = useRef>(undefined) + const handleProgress = useCallback((p: number) => setProgress(p), []) + const handleComplete = useCallback((videos: unknown[]) => { + setGenerating(false) + setGenerated(true) + setGeneratedVideos(videos as GeneratedVideo[]) + }, []) + const handleFailed = useCallback((errorMsg: string) => { + setGenerating(false) + setGenerateError(errorMsg) + }, []) - /* ── 生成阶段映射 ── */ - 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 { startPolling, clearTimer } = useGenerationPolling({ + templateId: selectedTemplate, + onProgress: handleProgress, + onComplete: handleComplete, + onFailed: handleFailed, + }) /* ── 生成视频 ── */ const generate = useCallback(async () => { - console.log("[handleGenerate] 开始生成, 参数:", { - titleSettings, - selectedTemplate, - selectedMaterials, - voiceMode, - }) if (!titleSettings.title.trim()) { message.warning("请先选择或输入标题") return @@ -83,7 +66,6 @@ export function useGenerateVideo({ message.warning("请至少选择一个素材") return } - if (voiceMode === "clone" && !selectedClonedVoice) { message.warning("请先选择一个克隆音色") return @@ -93,21 +75,14 @@ export function useGenerateVideo({ setProgress(0) setGenerated(false) setGenerateError(null) + clearTimer() 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 组件传回 - } + const voiceConfig = buildVoiceConfig({ + voiceMode, + selectedVoice, + selectedClonedVoice, + }) // 获取或创建草稿 await getEditPlan(selectedTemplate) @@ -140,149 +115,13 @@ export function useGenerateVideo({ }) 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 + startPolling() } 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) + const backendMsg = extractBackendError(err) + console.error("[handleGenerate] 错误信息:", backendMsg, "完整错误:", err) + const finalMsg = translateError(backendMsg) setGenerateError(finalMsg) message.error(finalMsg) } @@ -302,6 +141,8 @@ export function useGenerateVideo({ materialMode, coverSettings, smartSelectedIds, + clearTimer, + startPolling, ]) /* 重新生成(失败后重试) */ diff --git a/apps/web/src/pages/titles/hooks/useTitleEdit.ts b/apps/web/src/pages/titles/hooks/useTitleEdit.ts index 40c11ea1d..d1b48286a 100644 --- a/apps/web/src/pages/titles/hooks/useTitleEdit.ts +++ b/apps/web/src/pages/titles/hooks/useTitleEdit.ts @@ -59,7 +59,7 @@ export const useTitleEdit = ({ updateMutation, createMutation }: UseTitleEditPro message.success("标题创建成功") }, }) - }, [newTitleContent, newTitleType, createMutation]) + }, [newTitleContent, createMutation]) /* 关闭新建弹窗 */ const handleCloseCreateModal = useCallback(() => {