c321ac3af8
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 1s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 9s
CI/CD Pipeline / Build Staging API Image (push) Successful in 24s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 43s
AI Code Review / AI Code Review (pull_request) Failing after 1m53s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 2m11s
CI/CD Pipeline / Integration Tests (push) Successful in 2m37s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m21s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Validate - Style (push) Successful in 3m1s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m30s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m25s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 4m5s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Validate - Security (push) Successful in 5m9s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 5m43s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m33s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m20s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 3m59s
CI/CD Pipeline / Unit Tests (push) Successful in 8m28s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 1s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 2s
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 26s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 30s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 30s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 1m36s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Successful in 1m40s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 1m44s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 1m58s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 2m23s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 4m58s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 8m21s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 4s
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com> Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
322 lines
12 KiB
TypeScript
Executable File
322 lines
12 KiB
TypeScript
Executable File
/**
|
||
* 视频生成 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<string | null>(null)
|
||
const [generatedVideos, setGeneratedVideos] = useState<GeneratedVideo[]>([])
|
||
/** 批量模式:每个正式生成任务的独立状态(第5步逐卡片展示) */
|
||
const [batchTasks, setBatchTasks] = useState<BatchTaskState[]>([])
|
||
|
||
const handleBatchTaskUpdate = useCallback((taskId: string, patch: Partial<BatchTaskState>) => {
|
||
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<number, GeneratedVideo>()
|
||
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<boolean> => {
|
||
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 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 || ""
|
||
|
||
const voiceLibraryId =
|
||
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] || "")
|
||
: []
|
||
|
||
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,
|
||
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 } : {}),
|
||
...(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,
|
||
stroke: props.titleSettings.stroke,
|
||
shadow: props.titleSettings.shadow,
|
||
},
|
||
}
|
||
: {}),
|
||
})
|
||
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
|