9a51cad137
CI/CD Pipeline / Frontend Lint (push) Successful in 48s
CI/CD Pipeline / Validate - Code Quality (push) Failing after 2m24s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 2m25s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 2m27s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 3m8s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 3m16s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 1m20s
CI/CD Pipeline / Integration Tests (push) Successful in 3m53s
CI/CD Pipeline / Unit Tests (push) Failing after 7m53s
CI/CD Pipeline / Build Staging API Image (push) Successful in 11m29s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m13s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 26s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1m3s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m15s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1294h4m36s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 1294h5m22s
CI/CD Pipeline / Build Production Web Image (push) Failing after 1294h5m22s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 1294h7m45s
CI/CD Pipeline / PR Build Web Image (push) Failing after 1294h7m47s
CI/CD Pipeline / PR Build API Image (push) Failing after 1294h7m47s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1294h7m49s
CI/CD Pipeline / Deploy Production (push) Failing after 1294h37m5s
CI/CD Pipeline / Build Production API Image (push) Failing after 1294h37m51s
371 lines
12 KiB
TypeScript
371 lines
12 KiB
TypeScript
/**
|
|
* 视频生成 Hook
|
|
* 封装视频生成的核心逻辑、状态管理、轮询等
|
|
*/
|
|
import { useState, useRef, 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"
|
|
|
|
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<string | null>(null)
|
|
const [generatedVideos, setGeneratedVideos] = useState<GeneratedVideo[]>([])
|
|
|
|
const progressTimer = useRef<ReturnType<typeof setInterval>>(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<string, any>
|
|
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<string, any>
|
|
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<typeof setInterval>
|
|
} 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<string, any>
|
|
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<string, any>
|
|
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
|