d8a4c21556
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 33s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m1s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m2s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m44s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Successful in 1m19s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 1m59s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 1m38s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 2m16s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 1m46s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 4m0s
CI/CD Pipeline / CI Gate (pull_request) Successful in 6s
AI Code Review / AI Code Review (pull_request) Successful in 4m29s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 39s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 45s
CI/CD Pipeline / Production Browser E2E (pull_request) Failing after 1087h5m23s
CI/CD Pipeline / Canary Release to Production (pull_request) Failing after 1087h5m24s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 1087h5m24s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 1087h5m25s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 1087h5m25s
CI/CD Pipeline / PR Build API Image (pull_request) Failing after 1087h8m52s
CI/CD Pipeline / Integration Tests (pull_request) Failing after 1087h5m25s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 1087h8m52s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 1087h9m22s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 1087h9m22s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 1087h9m26s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 1087h9m26s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 1087h9m26s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 1087h38m39s
CI/CD Pipeline / PR Build Worker Image (pull_request) Failing after 1087h42m5s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 1087h42m37s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 1087h42m39s
根因:useStep3Preview 轮询失败时直接将 data.error_message 设为 state,
当后端返回对象类型(如 {} 或 {detail: "..."})时,
Step3GeneratePreview / PreviewVideoPanel 将其作为 React child 渲染,
触发 Error #31 "Objects are not valid as a React child"。
修复:
1. useStep3Preview.ts — 复用 safeExtractError 封装 safeString/safeNumber,
确保 error_message、task_id、video_url 等字段始终为字符串,
clip_count、duration 等字段始终为数字
2. Step3GeneratePreview.tsx — previewError 增加 typeof 守卫
3. PreviewVideoPanel.tsx — previewError 增加 typeof 守卫
309 lines
8.6 KiB
TypeScript
309 lines
8.6 KiB
TypeScript
/**
|
|
* Step 3 生成预览 Hook
|
|
* 调用 /generation/preview 接口创建预览任务,轮询状态直到完成
|
|
*/
|
|
import { useState, useCallback, useMemo, useEffect, useRef } from "react"
|
|
import { createPreview, getPreviewStatus } from "@/api/generation"
|
|
import type { PreviewTaskResponse, PreviewStatus as ApiPreviewStatus } from "@/api/generation"
|
|
import type { EditingTemplate } from "@/api/editing-planner"
|
|
import { safeExtractError } from "./generate-video/errorUtils"
|
|
|
|
/** 安全地将值转为字符串,防止对象被直接渲染导致 React Error #31 */
|
|
const safeString = (val: unknown, fallback: string): string => {
|
|
if (val == null) return fallback
|
|
const s = safeExtractError(val)
|
|
return s || fallback
|
|
}
|
|
|
|
/** 安全地将值转为数字,防止非数字值进入渲染 */
|
|
const safeNumber = (val: unknown, fallback = 0): number => {
|
|
if (typeof val === "number" && !Number.isNaN(val)) return val
|
|
if (typeof val === "string") {
|
|
const n = Number(val)
|
|
return Number.isNaN(n) ? fallback : n
|
|
}
|
|
return fallback
|
|
}
|
|
|
|
interface UseStep3PreviewProps {
|
|
templates: EditingTemplate[]
|
|
selectedTemplate: string
|
|
materialMode: "manual" | "auto"
|
|
selectedMaterials: string[]
|
|
smartSelectedIds: string[]
|
|
duration: number
|
|
videoRatio: string
|
|
titleText?: string
|
|
voiceId?: string
|
|
}
|
|
|
|
export type PreviewStatus = "idle" | "pending" | "generating" | "ready" | "error"
|
|
|
|
/** 预览生成结果 */
|
|
export interface PreviewResult {
|
|
taskId: string
|
|
videoUrl: string
|
|
clipCount: number
|
|
transitionCount: number
|
|
materialUsage: number
|
|
duration: number
|
|
fileSize: number
|
|
generateDuration: number
|
|
progress: number
|
|
}
|
|
|
|
// 轮询超时时间(10 分钟)
|
|
const POLL_TIMEOUT_MS = 10 * 60 * 1000
|
|
export function useStep3Preview({
|
|
templates,
|
|
selectedTemplate,
|
|
materialMode,
|
|
selectedMaterials,
|
|
smartSelectedIds,
|
|
duration,
|
|
videoRatio,
|
|
}: UseStep3PreviewProps) {
|
|
const templateName = useMemo(
|
|
() => templates.find((t) => t.id === selectedTemplate)?.name ?? "未选择",
|
|
[templates, selectedTemplate],
|
|
)
|
|
|
|
const materialCount = useMemo(() => {
|
|
if (materialMode === "auto") {
|
|
return `${smartSelectedIds.length} 个素材(智能匹配)`
|
|
}
|
|
return `${selectedMaterials.length} 个素材`
|
|
}, [materialMode, selectedMaterials.length, smartSelectedIds.length])
|
|
|
|
const materialTotal = materialMode === "auto" ? smartSelectedIds.length : selectedMaterials.length
|
|
|
|
/* ── 预览生成状态 ── */
|
|
const [previewStatus, setPreviewStatus] = useState<PreviewStatus>("idle")
|
|
const [previewResult, setPreviewResult] = useState<PreviewResult | null>(null)
|
|
const [previewError, setPreviewError] = useState<string>("")
|
|
const [progress, setProgress] = useState(0)
|
|
|
|
// 任务 ID + 轮询定时器,用于防止竞态条件
|
|
const currentTaskIdRef = useRef<string | null>(null)
|
|
const pollTimerRef = useRef<ReturnType<typeof setTimeout>>()
|
|
const startTimeRef = useRef<number>(0)
|
|
|
|
const clearPollTimer = useCallback(() => {
|
|
if (pollTimerRef.current) {
|
|
clearTimeout(pollTimerRef.current)
|
|
pollTimerRef.current = undefined
|
|
}
|
|
}, [])
|
|
|
|
/* ── 参数变化时重置预览状态 ── */
|
|
const prevDepsRef = useRef({
|
|
selectedTemplate,
|
|
materialMode,
|
|
selectedMaterials: [...selectedMaterials].sort().join(","),
|
|
smartSelectedIds: [...smartSelectedIds].sort().join(","),
|
|
duration,
|
|
videoRatio,
|
|
})
|
|
|
|
useEffect(() => {
|
|
const currentKey = [
|
|
selectedTemplate,
|
|
materialMode,
|
|
[...selectedMaterials].sort().join(","),
|
|
[...smartSelectedIds].sort().join(","),
|
|
duration,
|
|
videoRatio,
|
|
].join("|")
|
|
|
|
const prevKey = [
|
|
prevDepsRef.current.selectedTemplate,
|
|
prevDepsRef.current.materialMode,
|
|
prevDepsRef.current.selectedMaterials,
|
|
prevDepsRef.current.smartSelectedIds,
|
|
prevDepsRef.current.duration,
|
|
prevDepsRef.current.videoRatio,
|
|
].join("|")
|
|
|
|
if (prevKey !== currentKey && previewStatus !== "idle") {
|
|
// 参数变化,作废当前任务
|
|
currentTaskIdRef.current = null
|
|
clearPollTimer()
|
|
setPreviewStatus("idle")
|
|
setPreviewResult(null)
|
|
setPreviewError("")
|
|
setProgress(0)
|
|
}
|
|
|
|
prevDepsRef.current = {
|
|
selectedTemplate,
|
|
materialMode,
|
|
selectedMaterials: [...selectedMaterials].sort().join(","),
|
|
smartSelectedIds: [...smartSelectedIds].sort().join(","),
|
|
duration,
|
|
videoRatio,
|
|
}
|
|
}, [
|
|
selectedTemplate,
|
|
materialMode,
|
|
selectedMaterials,
|
|
smartSelectedIds,
|
|
duration,
|
|
videoRatio,
|
|
previewStatus,
|
|
clearPollTimer,
|
|
])
|
|
|
|
// 组件卸载时清理轮询
|
|
useEffect(() => {
|
|
return () => {
|
|
clearPollTimer()
|
|
}
|
|
}, [clearPollTimer])
|
|
|
|
/** 轮询预览任务状态 */
|
|
const pollPreviewStatus = useCallback((taskId: string) => {
|
|
const poll = async () => {
|
|
// 检查是否已被取消(参数变化或重新生成)
|
|
if (currentTaskIdRef.current !== taskId) return
|
|
|
|
// 超时检查
|
|
if (Date.now() - startTimeRef.current > POLL_TIMEOUT_MS) {
|
|
setPreviewError("预览生成超时,请重试")
|
|
setPreviewStatus("error")
|
|
return
|
|
}
|
|
|
|
try {
|
|
const data: PreviewTaskResponse = await getPreviewStatus(taskId)
|
|
|
|
if (currentTaskIdRef.current !== taskId) return
|
|
|
|
const status = data.status as ApiPreviewStatus
|
|
|
|
if (status === "completed") {
|
|
setProgress(100)
|
|
setPreviewResult({
|
|
taskId: safeString(data.task_id, ""),
|
|
videoUrl: safeString(data.video_url, ""),
|
|
clipCount: safeNumber(data.clip_count),
|
|
transitionCount: safeNumber(data.transition_count),
|
|
materialUsage: safeNumber(data.material_usage),
|
|
duration: safeNumber(data.duration),
|
|
fileSize: safeNumber(data.file_size),
|
|
generateDuration: safeNumber(data.generate_duration),
|
|
progress: 100,
|
|
})
|
|
setPreviewStatus("ready")
|
|
return
|
|
}
|
|
|
|
if (status === "failed") {
|
|
setPreviewError(safeString(data.error_message, "预览生成失败,请重试"))
|
|
setPreviewStatus("error")
|
|
return
|
|
}
|
|
|
|
if (status === "cancelled") {
|
|
setPreviewError("预览任务已取消")
|
|
setPreviewStatus("error")
|
|
return
|
|
}
|
|
|
|
// pending / generating 状态继续轮询
|
|
setProgress(safeNumber(data.progress))
|
|
if (status === "pending") {
|
|
setPreviewStatus("pending")
|
|
pollTimerRef.current = setTimeout(poll, 5000)
|
|
} else {
|
|
setPreviewStatus("generating")
|
|
pollTimerRef.current = setTimeout(poll, 2000)
|
|
}
|
|
} catch (pollErr) {
|
|
if (currentTaskIdRef.current !== taskId) return
|
|
// 轮询出错,延迟后重试
|
|
pollTimerRef.current = setTimeout(poll, 3000)
|
|
}
|
|
}
|
|
|
|
// 首次延迟 1 秒开始轮询
|
|
pollTimerRef.current = setTimeout(poll, 1000)
|
|
}, [])
|
|
|
|
/** 生成预览 */
|
|
const generatePreview = useCallback(async () => {
|
|
if (!selectedTemplate) {
|
|
setPreviewError("请先选择模板")
|
|
setPreviewStatus("error")
|
|
return
|
|
}
|
|
if (materialTotal === 0) {
|
|
setPreviewError("请先选择素材")
|
|
setPreviewStatus("error")
|
|
return
|
|
}
|
|
|
|
// 取消之前的轮询
|
|
clearPollTimer()
|
|
|
|
setPreviewStatus("pending")
|
|
setPreviewError("")
|
|
setProgress(0)
|
|
setPreviewResult(null)
|
|
startTimeRef.current = Date.now()
|
|
|
|
try {
|
|
const assetIds = materialMode === "auto" ? smartSelectedIds : selectedMaterials
|
|
const response = await createPreview({
|
|
template_id: selectedTemplate,
|
|
asset_ids: assetIds,
|
|
duration: duration || undefined,
|
|
video_ratio: videoRatio,
|
|
})
|
|
|
|
// 竞态检查
|
|
if (startTimeRef.current === 0) return // 已被重置
|
|
|
|
currentTaskIdRef.current = response.task_id
|
|
pollPreviewStatus(response.task_id)
|
|
} catch (e) {
|
|
setPreviewError(safeString(e instanceof Error ? e.message : e, "预览生成失败"))
|
|
setPreviewStatus("error")
|
|
}
|
|
}, [
|
|
selectedTemplate,
|
|
materialTotal,
|
|
materialMode,
|
|
smartSelectedIds,
|
|
selectedMaterials,
|
|
duration,
|
|
videoRatio,
|
|
clearPollTimer,
|
|
pollPreviewStatus,
|
|
])
|
|
|
|
/** 重新生成预览 */
|
|
const regeneratePreview = useCallback(() => {
|
|
generatePreview()
|
|
}, [generatePreview])
|
|
|
|
/** 是否可以下一步(预览已生成) */
|
|
const canProceed = previewStatus === "ready"
|
|
|
|
return {
|
|
templateName,
|
|
materialCount,
|
|
duration,
|
|
videoRatio,
|
|
// 预览生成状态
|
|
previewStatus,
|
|
previewResult,
|
|
previewError,
|
|
progress,
|
|
canProceed,
|
|
generatePreview,
|
|
regeneratePreview,
|
|
}
|
|
}
|
|
|
|
export default useStep3Preview
|