Files
xiaoxia-saas/apps/web/src/pages/generate/hooks/useStep3Preview.ts
T
前端Agent 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
fix(#1197): 修复预览结果渲染 React Error #31 — 对象被直接渲染为 React child
根因: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 守卫
2026-08-02 08:17:42 +08:00

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