/** * 视频生成 Hook * 封装视频生成的核心逻辑、状态管理、轮询等 */ import { useState, useCallback, useEffect } from "react" import { message } from "antd" import { type GeneratedVideo, getEditPlanClips, createClipsFromAssets } from "@/api/template-editor" import { createGenerationTask } from "@/api/tasks/tasks" import type { UseGenerateVideoProps } from "./generate-video/types" import { getGenerationPhase } from "./generate-video/phase" import { useGenerationPolling, type BatchTaskState } from "./generate-video/useGenerationPolling" import { validateGenerateInputs } from "./generate-video/buildPayload" import { calculateResolution } from "../utils/calculateResolution" import { extractBackendError, translateError } from "./generate-video/errorUtils" export function useGenerateVideo(props: UseGenerateVideoProps) { const { selectedTemplate, onGenerationSuccess } = props /* ── 生成状态 ── */ const [generating, setGenerating] = useState(false) const [progress, setProgress] = useState(0) const [generated, setGenerated] = useState(false) const [generateError, setGenerateError] = useState(null) const [generatedVideos, setGeneratedVideos] = useState([]) /** 批量模式:每个正式生成任务的独立状态(第5步逐卡片展示) */ const [batchTasks, setBatchTasks] = useState([]) const handleBatchTaskUpdate = useCallback((taskId: string, patch: Partial) => { setBatchTasks((prev) => { const list = prev || [] const idx = list.findIndex((t) => t.taskId === taskId) if (idx === -1) { return [ ...list, { taskId, variantIndex: patch.variantIndex ?? 0, status: "running", progress: 0, error: null, videos: [], ...patch, }, ] } const next = [...list] next[idx] = { ...next[idx], ...patch } return next }) }, []) const handleProgress = useCallback((p: number) => setProgress(p), []) const handleComplete = useCallback( (videos: unknown[]) => { setGenerating(false) setGenerated(true) setGeneratedVideos(videos as GeneratedVideo[]) // 批量:成功任务的 videos 已通过 onBatchTaskUpdate 写入,这里同步兜底 setBatchTasks((prev) => (prev || []).map((t) => t.status === "completed" && t.videos.length === 0 ? { ...t, videos: (videos as GeneratedVideo[]).filter( (v) => v.generation_task_id === t.taskId, ), } : t, ), ) onGenerationSuccess?.() }, [onGenerationSuccess], ) const handleFailed = useCallback((errorMsg: string) => { setGenerating(false) setGenerateError(errorMsg) }, []) /* 批量:任务状态变化时聚合已完成成片(含失败重试成功后补入), 按变体索引排序,供步骤6封面按勾选顺序逐个取视频 */ useEffect(() => { if (batchTasks.length === 0) return const byVariant = new Map() batchTasks.forEach((t) => { if (t.status === "completed" && t.videos && t.videos.length > 0) { byVariant.set(t.variantIndex, t.videos[0] as GeneratedVideo) } }) const ordered = [...byVariant.entries()].sort((a, b) => a[0] - b[0]).map(([, v]) => v) setGeneratedVideos((prev) => { if (prev.length === ordered.length && prev.every((v, i) => v.id === ordered[i].id)) { return prev } return ordered }) }, [batchTasks]) const { startPolling, startPollingBatch, retryTask, clearTimer } = useGenerationPolling({ onProgress: handleProgress, onComplete: handleComplete, onFailed: handleFailed, onBatchTaskUpdate: handleBatchTaskUpdate, }) /* ── 生成视频 ── 返回 true 表示任务创建成功并已开始轮询;false 表示校验未通过或创建失败 */ const generate = useCallback(async (): Promise => { const errorMsg = validateGenerateInputs(props) if (errorMsg) { message.warning(errorMsg) return false } setGenerating(true) setProgress(0) setGenerated(false) setGenerateError(null) setBatchTasks([]) clearTimer() try { 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 // from-assets 已由 useStep2Materials 在用户选素材时(debounce 800ms)调用, // 后端已改为异步秒级返回,这里做一次轻量兜底: // 单次查 clips,已有则直接放行;没有则再调一次 from-assets。 if (assetIds.length > 0 && selectedTemplate) { try { const clipList = await getEditPlanClips(selectedTemplate, { limit: 500 }) if (clipList.items.length === 0) { // 片段不存在(极端情况:useStep2Materials 的 debounce 还没触发) // 手动补一次 from-assets(后端秒级返回) await createClipsFromAssets(selectedTemplate, assetIds, "main") } } catch { // 查询失败不阻塞,继续生成 } } const isBatch = (props.previewCount || 1) > 1 const hide = message.loading( isBatch ? `正在生成 ${props.previewCount} 个视频...` : "正在生成预览视频...", 0, ) const coverUrl = props.coverSettings?.thumbnail_url || props.coverSettings?.upload_url || "" // #1970:叙事模式下 ttsVoiceId 作为配音 id;随机模式用 selectedVoice const voiceLibraryId = editMode === "narrative" ? props.ttsVoiceId || "" : props.voiceMode === "clone" ? props.selectedClonedVoice || props.selectedVoice || "" : props.selectedVoice || "" /* ── 批量变体数组(长度1=共用,长度=count=独立,空=回退单值) ── */ const indexes = isBatch && props.selectedVariantIndexes?.length ? props.selectedVariantIndexes : Array.from({ length: props.previewCount || 1 }, (_, i) => i) const batchCount = isBatch ? indexes.length : 1 // 标题文字数组:批量时按勾选顺序 const titlesArr = isBatch && (props.variantTitles?.length || 0) >= batchCount ? indexes.map((i) => props.variantTitles![i] || props.titleSettings?.title || "") : [] // 配音数组:独立配音模式按勾选顺序;否则不传(回退共用 voice_library_id) const voiceArr = isBatch && props.voiceModePerVideo && props.variantVoiceLibraryIds?.length ? indexes.map((i) => props.variantVoiceLibraryIds![i] || voiceLibraryId) : [] // 封面数组:批量时按勾选顺序(未设置封面的变体传空串,后端回退智能封面) const coversArr = isBatch && props.variantCoverUrls?.length ? indexes.map((i) => props.variantCoverUrls![i] || "") : [] // #1744 变体 plan 数组:预览阶段后端独立选片产出的 plan id,按勾选顺序回传, // 后端直接关联这些 plan 渲染(不再重新选片)→ 预览所见即成片。 // 全部为空(降级本地模拟/后端端点未上线)时不传,后端走自身独立选片。 const variantPlansArr = isBatch && props.variantPlanIds?.length ? indexes.map((i) => props.variantPlanIds![i] || "") : [] const hasVariantPlans = variantPlansArr.some((id) => !!id) try { const taskResp = await createGenerationTask({ template_id: selectedTemplate, asset_ids: assetIds, output_width: outputWidth, output_height: outputHeight, cover_url: coverUrl, 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, tts_style: props.ttsStyle || undefined, } : {}), dedup_enabled: dedupEnabled, voice_library_id: voiceLibraryId, ...(props.selectedVoice && !voiceLibraryId ? { voice_ids: [props.selectedVoice] } : {}), bgm_config: { enabled: props.bgm !== false, ...(props.bgmConfig?.music_id ? { preset_id: props.bgmConfig.music_id } : {}), }, ...(props.sourceEditPlanId ? { source_edit_plan_id: props.sourceEditPlanId } : {}), ...(isBatch ? { count: batchCount } : {}), ...(titlesArr.length ? { titles: titlesArr } : {}), ...(voiceArr.length ? { voice_library_ids: voiceArr } : {}), ...(coversArr.length ? { cover_urls: coversArr } : {}), ...(hasVariantPlans ? { variant_plan_ids: variantPlansArr } : {}), ...(props.titleSettings?.title ? { title_config: { text: props.titleSettings.title, font: props.titleSettings.font, font_size: props.titleSettings.size, font_color: props.titleSettings.color, position: props.titleSettings.position, ...(props.titleSettings.position === "custom" && props.titleSettings.posX != null && props.titleSettings.posY != null ? { pos_x: Math.round(props.titleSettings.posX), pos_y: Math.round(props.titleSettings.posY), } : {}), bold: props.titleSettings.bold, italic: props.titleSettings.italic, stroke: props.titleSettings.stroke ? { enabled: true, width: props.titleSettings.strokeWidth ?? 4, color: props.titleSettings.strokeColor ?? "#000000", } : { enabled: false }, shadow: props.titleSettings.shadow ? { enabled: true, offset_x: props.titleSettings.shadowOffsetX ?? 2, offset_y: props.titleSettings.shadowOffsetY ?? 2, blur: props.titleSettings.shadowBlur ?? 4, color: props.titleSettings.shadowColor ?? "rgba(0,0,0,0.8)", } : { enabled: false }, line_height: props.titleSettings.lineHeight ?? 1.2, margin_top: props.titleSettings.marginTop ?? 24, max_chars_per_line: props.titleSettings.maxCharsPerLine ?? 0, ...(props.titleSettings.bgEnabled ? { background: { enabled: true, color: props.titleSettings.bgColor, padding: props.titleSettings.bgPadding, radius: props.titleSettings.bgRadius, }, } : { background: { enabled: false } }), line_overrides: (props.titleSettings.lineOverrides ?? []).map((lo) => ({ line_index: lo.line_index, text: lo.text, size: lo.size, color: lo.color, bold: lo.bold, italic: lo.italic, stroke: lo.stroke, highlights: lo.highlights?.map((h) => ({ word: h.word, color: h.color, bold: h.bold, scale: h.scale, })), })), ...(props.titleSettings.coverTitle ? { cover_title_config: { title: props.titleSettings.coverTitle.title, font: props.titleSettings.coverTitle.font, font_size: props.titleSettings.coverTitle.size, font_color: props.titleSettings.coverTitle.color, bold: props.titleSettings.coverTitle.bold, italic: props.titleSettings.coverTitle.italic, position: props.titleSettings.coverTitle.position, stroke: props.titleSettings.coverTitle.stroke ? { enabled: true, width: props.titleSettings.coverTitle.strokeWidth ?? 4, color: props.titleSettings.coverTitle.strokeColor ?? "#000000", } : { enabled: false }, shadow: props.titleSettings.coverTitle.shadow ? { enabled: true, offset_x: props.titleSettings.coverTitle.shadowOffsetX ?? 2, offset_y: props.titleSettings.coverTitle.shadowOffsetY ?? 2, blur: props.titleSettings.coverTitle.shadowBlur ?? 4, color: props.titleSettings.coverTitle.shadowColor ?? "rgba(0,0,0,0.8)", } : { enabled: false }, ...(props.titleSettings.coverTitle.bgEnabled ? { background: { enabled: true, color: props.titleSettings.coverTitle.bgColor, padding: props.titleSettings.coverTitle.bgPadding, radius: props.titleSettings.coverTitle.bgRadius, }, } : { background: { enabled: false } }), }, } : {}), }, } : {}), }) hide() const taskIds = (taskResp.items || []).map((it) => it.id).filter(Boolean) if (taskIds.length === 0) { throw new Error("创建任务成功但未返回任务 ID,请稍后在任务列表查看") } if (taskIds.length > 1) { // 批量:任务按创建顺序与勾选变体一一对应(后端按 count 顺序创建) startPollingBatch(taskIds.map((taskId, i) => ({ taskId, variantIndex: indexes[i] ?? i }))) } else { startPolling(taskIds[0]) } } catch (err) { hide() throw err } } catch (err: unknown) { console.error("[handleGenerate] 生成失败:", err) setGenerating(false) const backendMsg = extractBackendError(err) console.error("[handleGenerate] 错误信息:", backendMsg, "完整错误:", err) const finalMsg = translateError(backendMsg) setGenerateError(finalMsg) message.error(finalMsg) return false } return true }, [props, clearTimer, startPolling, startPollingBatch, selectedTemplate]) const retry = useCallback(() => { setGenerateError(null) generate() }, [generate]) /** 第5步:单独重试某个失败任务 */ const retryBatchTask = useCallback( (taskId: string) => { retryTask(taskId) }, [retryTask], ) 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, retryBatchTask, batchTasks, dismissError, download, share, getGenerationPhase, } } export default useGenerateVideo