0e619a2da2
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 / 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 / Check if frontend-only change (pull_request) Successful in 33s
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m50s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m58s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 2m5s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Successful in 1m45s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 3m15s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m25s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 3m1s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 3m34s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 6m5s
CI/CD Pipeline / Integration Tests (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 / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
AI Code Review / AI Code Review (pull_request) Failing after 6m11s
CI/CD Pipeline / CI Gate (pull_request) Failing after 8s
1. Step2选素材后防抖500ms自动保存asset_ids到草稿
2. Step4标题变化后防抖800ms自动保存title到草稿
3. Step6封面请求带上title_config(初始调用+预览缺失后重试)
4. Step7创建任务带source_edit_plan_id + cover_url优先thumbnail_url
5. 轮询改用GET /generation/tasks/{task_id},task_id从创建响应获取
6. 删除7个幽灵API函数:getEditPlans/createEditPlan/deleteEditPlan/
cancelGeneration/copyEditPlan/generateFromTemplate,BGM预设路径改为
/templates/{id}/editor/bgm/presets
7. CreateGenerationTaskResponse对齐后端批量结构{items,total}
8. 所有自动保存失败console.warn静默,不弹窗不阻塞
203 lines
6.4 KiB
TypeScript
Executable File
203 lines
6.4 KiB
TypeScript
Executable File
/**
|
|
* 视频生成 Hook
|
|
* 封装视频生成的核心逻辑、状态管理、轮询等
|
|
*/
|
|
import { useState, useCallback } from "react"
|
|
import { message } from "antd"
|
|
import type { GeneratedVideo } 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 } from "./generate-video/useGenerationPolling"
|
|
import { validateGenerateInputs } from "./generate-video/buildPayload"
|
|
import { extractBackendError, translateError } from "./generate-video/errorUtils"
|
|
|
|
export function useGenerateVideo(props: UseGenerateVideoProps) {
|
|
const { selectedTemplate } = 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[]>([])
|
|
|
|
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 { startPolling, clearTimer } = useGenerationPolling({
|
|
onProgress: handleProgress,
|
|
onComplete: handleComplete,
|
|
onFailed: handleFailed,
|
|
})
|
|
|
|
/* ── 生成视频 ── */
|
|
const generate = useCallback(async () => {
|
|
const errorMsg = validateGenerateInputs(props)
|
|
if (errorMsg) {
|
|
message.warning(errorMsg)
|
|
return
|
|
}
|
|
|
|
setGenerating(true)
|
|
setProgress(0)
|
|
setGenerated(false)
|
|
setGenerateError(null)
|
|
clearTimer()
|
|
|
|
try {
|
|
// 解析分辨率
|
|
const ratio = props.videoRatio || "9:16"
|
|
let outputWidth: number
|
|
let outputHeight: number
|
|
|
|
if (ratio.includes(":")) {
|
|
const [rw, rh] = ratio.split(":").map(Number)
|
|
if (rw > 0 && rh > 0) {
|
|
const [longSide, shortSide] = rw < rh ? [rh, rw] : [rw, rh]
|
|
const baseLong = 1920
|
|
const baseShort = Math.round((baseLong * shortSide) / longSide)
|
|
const evenShort = baseShort - (baseShort % 2)
|
|
if (rw < rh) {
|
|
outputWidth = evenShort
|
|
outputHeight = baseLong
|
|
} else {
|
|
outputWidth = baseLong
|
|
outputHeight = evenShort
|
|
}
|
|
} else {
|
|
outputWidth = 1080
|
|
outputHeight = 1920
|
|
}
|
|
} else if (ratio.includes("x")) {
|
|
const [wStr, hStr] = ratio.split("x")
|
|
outputWidth = parseInt(wStr, 10) || 1080
|
|
outputHeight = parseInt(hStr, 10) || 1920
|
|
} else {
|
|
outputWidth = 1080
|
|
outputHeight = 1920
|
|
}
|
|
|
|
const assetIds =
|
|
props.materialMode === "auto" ? props.smartSelectedIds : props.selectedMaterials
|
|
|
|
// 封面 URL:优先 AI 生成缩略图,兜底用户上传
|
|
const coverUrl = props.coverSettings?.thumbnail_url || props.coverSettings?.upload_url || ""
|
|
|
|
// 直接创建正式生成任务
|
|
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,
|
|
...(props.sourceEditPlanId ? { source_edit_plan_id: props.sourceEditPlanId } : {}),
|
|
...(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,
|
|
bold: props.titleSettings.bold,
|
|
stroke: props.titleSettings.stroke,
|
|
shadow: props.titleSettings.shadow,
|
|
},
|
|
}
|
|
: {}),
|
|
})
|
|
|
|
// 从创建响应直接拿 task_id,改用新接口轮询
|
|
const taskId = taskResp.items?.[0]?.id
|
|
if (!taskId) {
|
|
throw new Error("创建任务成功但未返回任务 ID,请稍后在任务列表查看")
|
|
}
|
|
startPolling(taskId)
|
|
} 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)
|
|
}
|
|
}, [props, clearTimer, startPolling, selectedTemplate])
|
|
|
|
/* 重新生成(失败后重试) */
|
|
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
|