af25045123
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 4s
CI/CD Pipeline / Check push changed paths (push) Successful in 5s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
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 / Build Staging Web Image (push) Successful in 25s
CI/CD Pipeline / Build Staging API Image (push) Successful in 25s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 30s
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 / Validate - Python (mypy + alembic) (push) Successful in 1m50s
CI/CD Pipeline / Integration Tests (push) Successful in 1m51s
CI/CD Pipeline / Validate - Style (push) Successful in 2m29s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 53s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m20s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Validate - Security (push) Successful in 4m27s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1m49s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m45s
AI Code Review / AI Code Review (pull_request) Successful in 6m21s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 6m51s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m49s
CI/CD Pipeline / Unit Tests (push) Successful in 9m32s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
Preview Deploy / Deploy Preview Environment (pull_request) Failing after 14m36s
CI/CD Pipeline / Check push changed paths (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 / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 4s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 4s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 25s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 22s
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 / 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 / PR Build Worker Image (pull_request) Successful in 21s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Successful in 1m58s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 2m2s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 2m15s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 2m23s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 2m44s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 3m31s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 6m50s
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
CI/CD Pipeline / CI Gate (pull_request) Successful in 2s
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com> Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
298 lines
10 KiB
TypeScript
298 lines
10 KiB
TypeScript
/**
|
|
* 服务器渲染预览 Hook
|
|
*
|
|
* 核心职责:
|
|
* 1. 调用 POST /generation/preview 创建服务器预览渲染任务
|
|
* 2. 轮询 GET /generation/preview/{task_id} 直到完成
|
|
* 3. 返回服务器渲染的真实视频 URL(供 <video> 标签播放)
|
|
* 4. 检测配置变更,标记预览失效(stale)或自动重新渲染
|
|
* 5. 网络错误自动重试 2 次
|
|
*
|
|
* 状态机:
|
|
* idle → loading → ready → stale (config changed)
|
|
* ↘ failed → idle (retry)
|
|
*/
|
|
import { useState, useCallback, useRef, useEffect } from "react"
|
|
import { createPreview, getPreviewStatus } from "@/api/generation/preview"
|
|
import type { CreatePreviewRequest } from "@/api/generation/types"
|
|
|
|
export type ServerPreviewStatus = "idle" | "loading" | "ready" | "stale" | "failed"
|
|
|
|
interface UseServerPreviewOptions {
|
|
/** 是否启用预览(Step4+ 且有素材和模板时) */
|
|
enabled: boolean
|
|
/** 构建预览请求参数(每次 render 调用,获取最新配置) */
|
|
buildRequest: () => CreatePreviewRequest
|
|
/** 预览任务创建成功回调 */
|
|
onPreviewTaskCreated?: (taskId: string, sourceEditPlanId?: string) => void
|
|
}
|
|
|
|
interface UseServerPreviewReturn {
|
|
status: ServerPreviewStatus
|
|
videoUrl: string | null
|
|
error: string | null
|
|
/** 进度 0-100 */
|
|
progress: number
|
|
/** 手动触发预览创建("重新预览"按钮或标题变更后手动刷新) */
|
|
triggerPreview: () => void
|
|
/** 当前预览任务 ID */
|
|
taskId: string | null
|
|
}
|
|
|
|
const POLL_INTERVAL = 2000
|
|
const POLL_TIMEOUT = 120_000
|
|
const MAX_NETWORK_RETRIES = 2
|
|
|
|
/**
|
|
* 对配置参数做指纹,用于检测配置是否变化
|
|
*/
|
|
function buildFingerprint(req: CreatePreviewRequest): string {
|
|
return JSON.stringify({
|
|
t: req.template_id,
|
|
a: [...req.asset_ids].sort(),
|
|
d: req.duration,
|
|
r: req.video_ratio,
|
|
v: req.voice_library_id,
|
|
b: req.bgm_config,
|
|
title: req.title_config,
|
|
})
|
|
}
|
|
|
|
export function useServerPreview({
|
|
enabled,
|
|
buildRequest,
|
|
onPreviewTaskCreated,
|
|
}: UseServerPreviewOptions): UseServerPreviewReturn {
|
|
const [status, setStatus] = useState<ServerPreviewStatus>("idle")
|
|
const [videoUrl, setVideoUrl] = useState<string | null>(null)
|
|
const [error, setError] = useState<string | null>(null)
|
|
const [progress, setProgress] = useState(0)
|
|
const [taskId, setTaskId] = useState<string | null>(null)
|
|
|
|
const pollTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
const timeoutTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
const requestSeqRef = useRef(0)
|
|
const renderedFingerprintRef = useRef<string>("")
|
|
const mountedRef = useRef(true)
|
|
const networkRetriesRef = useRef(0)
|
|
|
|
// 始终持有最新的 buildRequest 和回调
|
|
const buildRequestRef = useRef(buildRequest)
|
|
buildRequestRef.current = buildRequest
|
|
const onCreatedRef = useRef(onPreviewTaskCreated)
|
|
onCreatedRef.current = onPreviewTaskCreated
|
|
|
|
/* ── 清理 ── */
|
|
const clearTimers = useCallback(() => {
|
|
if (pollTimerRef.current) {
|
|
clearTimeout(pollTimerRef.current)
|
|
pollTimerRef.current = null
|
|
}
|
|
if (timeoutTimerRef.current) {
|
|
clearTimeout(timeoutTimerRef.current)
|
|
timeoutTimerRef.current = null
|
|
}
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
mountedRef.current = true
|
|
return () => {
|
|
mountedRef.current = false
|
|
clearTimers()
|
|
}
|
|
}, [clearTimers])
|
|
|
|
/* ── 创建预览 + 轮询 ── */
|
|
const createAndPoll = useCallback(
|
|
async (request: CreatePreviewRequest, seq: number) => {
|
|
setStatus("loading")
|
|
setProgress(0)
|
|
setError(null)
|
|
networkRetriesRef.current = 0
|
|
|
|
try {
|
|
const resp = await createPreview(request)
|
|
if (seq !== requestSeqRef.current || !mountedRef.current) return
|
|
|
|
// 兼容批量响应 {items, total}:取第一个变体
|
|
const firstTask = resp.items?.[0]
|
|
const taskId = firstTask?.task_id || ""
|
|
setTaskId(taskId)
|
|
onCreatedRef.current?.(taskId, resp.source_edit_plan_id)
|
|
|
|
let completed = false
|
|
|
|
// 超时保护
|
|
timeoutTimerRef.current = setTimeout(() => {
|
|
if (completed || seq !== requestSeqRef.current || !mountedRef.current) return
|
|
completed = true
|
|
clearTimers()
|
|
setStatus("failed")
|
|
setError("预览渲染超时(120秒),请重试")
|
|
}, POLL_TIMEOUT)
|
|
|
|
const poll = async () => {
|
|
if (completed || seq !== requestSeqRef.current || !mountedRef.current) return
|
|
|
|
try {
|
|
const st = await getPreviewStatus(taskId)
|
|
if (completed || seq !== requestSeqRef.current || !mountedRef.current) return
|
|
|
|
if (st.status === "completed" && st.video_url) {
|
|
completed = true
|
|
clearTimers()
|
|
renderedFingerprintRef.current = buildFingerprint(request)
|
|
setVideoUrl(st.video_url)
|
|
setProgress(100)
|
|
setStatus("ready")
|
|
setError(null)
|
|
return
|
|
}
|
|
|
|
if (st.status === "failed" || st.status === "cancelled") {
|
|
completed = true
|
|
clearTimers()
|
|
setStatus("failed")
|
|
setError(
|
|
st.status === "cancelled"
|
|
? "预览任务已取消"
|
|
: st.error_message || "预览渲染失败,请重试",
|
|
)
|
|
return
|
|
}
|
|
|
|
// pending / generating
|
|
if (typeof st.progress === "number") setProgress(st.progress)
|
|
pollTimerRef.current = setTimeout(poll, POLL_INTERVAL)
|
|
} catch (pollErr) {
|
|
if (completed || seq !== requestSeqRef.current || !mountedRef.current) return
|
|
if (networkRetriesRef.current < MAX_NETWORK_RETRIES) {
|
|
networkRetriesRef.current += 1
|
|
console.warn(
|
|
`[ServerPreview] 轮询网络错误,第 ${networkRetriesRef.current} 次重试`,
|
|
pollErr,
|
|
)
|
|
pollTimerRef.current = setTimeout(poll, POLL_INTERVAL * 2)
|
|
} else {
|
|
completed = true
|
|
clearTimers()
|
|
setStatus("failed")
|
|
setError("网络错误,无法获取预览状态,请重试")
|
|
}
|
|
}
|
|
}
|
|
|
|
poll()
|
|
} catch (createErr) {
|
|
if (seq !== requestSeqRef.current || !mountedRef.current) return
|
|
console.error("[ServerPreview] 创建预览任务失败:", createErr)
|
|
|
|
const isNetworkError =
|
|
!!(createErr as { request?: unknown })?.request ||
|
|
(createErr as { code?: string })?.code === "ERR_NETWORK"
|
|
|
|
if (isNetworkError && networkRetriesRef.current < MAX_NETWORK_RETRIES) {
|
|
networkRetriesRef.current += 1
|
|
console.warn(`[ServerPreview] 创建任务网络错误,第 ${networkRetriesRef.current} 次重试`)
|
|
setTimeout(() => {
|
|
if (seq === requestSeqRef.current && mountedRef.current) {
|
|
createAndPoll(request, seq)
|
|
}
|
|
}, POLL_INTERVAL * 2)
|
|
return
|
|
}
|
|
|
|
const errData = (
|
|
createErr as { response?: { data?: { detail?: string; message?: string } } }
|
|
)?.response?.data
|
|
setStatus("failed")
|
|
setError(errData?.detail || errData?.message || "预览任务创建失败,请重试")
|
|
}
|
|
},
|
|
[clearTimers],
|
|
)
|
|
|
|
/* ── 手动触发预览 ── */
|
|
const triggerPreview = useCallback(() => {
|
|
if (!enabled) return
|
|
const request = buildRequestRef.current()
|
|
if (!request.template_id || request.asset_ids.length === 0) return
|
|
|
|
clearTimers()
|
|
const seq = ++requestSeqRef.current
|
|
setVideoUrl(null)
|
|
setTaskId(null)
|
|
createAndPoll(request, seq)
|
|
}, [enabled, clearTimers, createAndPoll])
|
|
|
|
/* ── 自动触发 + 配置变更检测 ── */
|
|
// 每次 render 都检查最新配置 fingerprint,与已渲染的 fingerprint 比较
|
|
const request = enabled ? buildRequest() : null
|
|
const currentFingerprint = request
|
|
? request.template_id && request.asset_ids.length > 0
|
|
? buildFingerprint(request)
|
|
: ""
|
|
: ""
|
|
|
|
// 首次进入自动触发
|
|
const didInitRef = useRef(false)
|
|
useEffect(() => {
|
|
if (!enabled || !currentFingerprint) {
|
|
didInitRef.current = false
|
|
// 禁用时取消进行中的轮询,避免回到前序步骤后仍在后台轮询
|
|
requestSeqRef.current += 1
|
|
clearTimers()
|
|
return
|
|
}
|
|
if (!didInitRef.current) {
|
|
didInitRef.current = true
|
|
renderedFingerprintRef.current = currentFingerprint
|
|
triggerPreview()
|
|
}
|
|
}, [enabled, currentFingerprint, triggerPreview, clearTimers])
|
|
|
|
// 配置变更检测:素材/配音/BGM 等变化 → 自动重渲染;标题样式变化 → 标记 stale
|
|
const prevFingerprintRef = useRef(currentFingerprint)
|
|
useEffect(() => {
|
|
if (!enabled || !currentFingerprint) return
|
|
const prev = prevFingerprintRef.current
|
|
prevFingerprintRef.current = currentFingerprint
|
|
|
|
if (!prev || prev === currentFingerprint) return
|
|
if (currentFingerprint === renderedFingerprintRef.current) return
|
|
|
|
// 配置已变更
|
|
// 判断是标题样式变更还是素材/配音/BGM 变更
|
|
const prevParsed = JSON.parse(prev) as Record<string, unknown>
|
|
const currParsed = JSON.parse(currentFingerprint) as Record<string, unknown>
|
|
const nonTitleChanged =
|
|
prevParsed.t !== currParsed.t ||
|
|
prevParsed.a !== currParsed.a ||
|
|
prevParsed.d !== currParsed.d ||
|
|
prevParsed.r !== currParsed.r ||
|
|
prevParsed.v !== currParsed.v ||
|
|
JSON.stringify(prevParsed.b) !== JSON.stringify(currParsed.b)
|
|
|
|
if (nonTitleChanged) {
|
|
// 素材/配音/BGM/模板等变化 → 自动重新渲染
|
|
renderedFingerprintRef.current = currentFingerprint
|
|
triggerPreview()
|
|
} else {
|
|
// 仅标题文字/样式变化 → 标记 stale,不自动重渲染(避免频繁请求)
|
|
// 实时预览由 CSS TitleOverlay 提供
|
|
setStatus((s) => (s === "ready" ? "stale" : s))
|
|
}
|
|
}, [enabled, currentFingerprint, triggerPreview])
|
|
|
|
return {
|
|
status,
|
|
videoUrl,
|
|
error,
|
|
progress,
|
|
triggerPreview,
|
|
taskId,
|
|
}
|
|
}
|
|
|
|
export default useServerPreview
|