48a9d11dae
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 2m21s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 2m50s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 6m11s
CI/CD Pipeline / Frontend Unit Tests (push) Failing after 6m36s
CI/CD Pipeline / Integration Tests (push) Successful in 2m42s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 9m51s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 10m17s
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (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 / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (push) Failing after 708h52m15s
CI/CD Pipeline / PR Build Web Image (push) Failing after 708h52m17s
CI/CD Pipeline / Frontend Lint (push) Failing after 708h52m17s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 708h52m20s
CI/CD Pipeline / PR Build API Image (push) Failing after 709h25m57s
- 新建 FrontendPreviewPlayer + useSegmentScheduler + usePreviewAssets - 删除 useStep5Preview.ts(后端FFmpeg预览轮询逻辑) - Step5预览改为前端直接播放素材片段 + CSS标题叠加 - 预览等待时间从数十秒降到即时,标题样式调整实时生效
201 lines
6.3 KiB
TypeScript
Executable File
201 lines
6.3 KiB
TypeScript
Executable File
/**
|
|
* 视频生成 Hook
|
|
* 封装视频生成的核心逻辑、状态管理、轮询等
|
|
*/
|
|
import { useState, useCallback } from "react"
|
|
import { message } from "antd"
|
|
import type { GeneratedVideo } from "@/api/template-editor"
|
|
import { confirmGeneration, createPreview } from "@/api/generation"
|
|
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({
|
|
templateId: selectedTemplate,
|
|
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 {
|
|
// 解析分辨率:videoRatio 可能是 "9:16"(宽高比)或 "1080x1920"(分辨率)
|
|
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
|
|
}
|
|
|
|
// 获取或创建后端任务 ID
|
|
// 预览改为前端播放后,不再有预览任务,需要在此处创建
|
|
let taskId = props.previewTaskId
|
|
if (!taskId) {
|
|
const assetIds =
|
|
props.materialMode === "auto" ? props.smartSelectedIds : props.selectedMaterials
|
|
const previewResp = await createPreview({
|
|
template_id: selectedTemplate,
|
|
asset_ids: assetIds,
|
|
duration: props.duration || undefined,
|
|
video_ratio: props.videoRatio,
|
|
voice_ids: undefined,
|
|
title_config: props.titleSettings?.title
|
|
? {
|
|
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,
|
|
}
|
|
: undefined,
|
|
})
|
|
taskId = previewResp.task_id
|
|
}
|
|
|
|
await confirmGeneration(taskId, {
|
|
output_width: outputWidth,
|
|
output_height: outputHeight,
|
|
cover_url: props.coverSettings.upload_url || "",
|
|
custom_title: props.titleSettings.title || "",
|
|
})
|
|
|
|
startPolling()
|
|
} 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
|