8c715474f4
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 2s
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 1s
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 1s
CI/CD Pipeline / Check push changed paths (push) Successful in 4s
CI/CD Pipeline / Validate - Style (pull_request) Has been skipped
CI/CD Pipeline / Validate - Security (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 16s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 19s
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 19s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 2m49s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 3m53s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 33s
AI Code Review / AI Code Review (pull_request) Successful in 6m43s
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 / 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 2s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Successful in 41s
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 / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 36s
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 / Frontend Unit Tests (push) Successful in 3m55s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 8m8s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m38s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m48s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 4m45s
CI/CD Pipeline / Validate - Style (push) Successful in 27m21s
CI/CD Pipeline / Integration Tests (push) Successful in 26m59s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 31m35s
CI/CD Pipeline / Unit Tests (push) Failing after 35m54s
CI/CD Pipeline / Validate - Security (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
429 lines
17 KiB
TypeScript
Executable File
429 lines
17 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 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
|