feat(viral-video): #2041 爆款视频创作页 浅色紫调三栏向导 UI + 全流程 #2107
@@ -1,91 +1,47 @@
|
||||
/**
|
||||
* 爆款视频 API 封装
|
||||
* 所有端点:/api/v1/viral-video/*
|
||||
*/
|
||||
import apiClient from "../client"
|
||||
import apiClient from "@/api/client"
|
||||
import type {
|
||||
AnalyzeStyleResponse,
|
||||
ConfirmIntentRequest,
|
||||
CreateViralVideoRequest,
|
||||
StyleTemplateListResponse,
|
||||
ViralVideoHistoryResponse,
|
||||
GenerateViralVideoRequest,
|
||||
HistoryResponse,
|
||||
StyleTemplate,
|
||||
ViralVideoJob,
|
||||
} from "./types"
|
||||
|
||||
/** 上传图片/视频资源并返回可访问 URL(复用资产上传接口) */
|
||||
export const uploadViralAsset = async (
|
||||
file: File,
|
||||
kind: "image" | "video" = "image",
|
||||
): Promise<string> => {
|
||||
const formData = new FormData()
|
||||
formData.append("file", file)
|
||||
formData.append("kind", kind)
|
||||
// 复用通用资产上传;若后端有专用 /viral-video/upload 端点可替换
|
||||
const { data } = await apiClient.post<{ url: string; id?: string }>("/assets/upload", formData, {
|
||||
headers: { "Content-Type": "multipart/form-data" },
|
||||
timeout: 120_000,
|
||||
})
|
||||
return data.url
|
||||
}
|
||||
|
||||
/** 创建爆款视频任务 */
|
||||
export const createViralVideoJob = async (
|
||||
params: CreateViralVideoRequest,
|
||||
): Promise<ViralVideoJob> => {
|
||||
const { data } = await apiClient.post<ViralVideoJob>("/viral-video/generate", params, {
|
||||
timeout: 60_000,
|
||||
})
|
||||
return data
|
||||
export function generateViralVideo(payload: GenerateViralVideoRequest) {
|
||||
return apiClient.post<ViralVideoJob>("/viral-video/generate", payload).then((r) => r.data)
|
||||
}
|
||||
|
||||
/** 查询爆款视频任务 */
|
||||
export const getViralVideoJob = async (jobId: string): Promise<ViralVideoJob> => {
|
||||
const { data } = await apiClient.get<ViralVideoJob>(`/viral-video/${jobId}`)
|
||||
return data
|
||||
/** 查询单个任务 */
|
||||
export function getViralVideoJob(id: string) {
|
||||
return apiClient.get<ViralVideoJob>(`/viral-video/${id}`).then((r) => r.data)
|
||||
}
|
||||
|
||||
/** 用户确认/修改 AI 理解的意图后继续 */
|
||||
export function confirmViralVideoIntent(
|
||||
id: string,
|
||||
payload: { confirmed_copy?: string; edits?: Record<string, unknown> },
|
||||
) {
|
||||
return apiClient
|
||||
.post<ViralVideoJob>(`/viral-video/${id}/confirm-intent`, payload)
|
||||
.then((r) => r.data)
|
||||
}
|
||||
|
||||
/** 重试失败任务 */
|
||||
export const retryViralVideoJob = async (jobId: string): Promise<ViralVideoJob> => {
|
||||
const { data } = await apiClient.post<ViralVideoJob>(`/viral-video/${jobId}/retry`)
|
||||
return data
|
||||
export function retryViralVideo(id: string) {
|
||||
return apiClient.post<ViralVideoJob>(`/viral-video/${id}/retry`).then((r) => r.data)
|
||||
}
|
||||
|
||||
/** 用户确认 AI 意图后继续流水线 */
|
||||
export const confirmViralIntent = async (
|
||||
jobId: string,
|
||||
body: ConfirmIntentRequest = {},
|
||||
): Promise<ViralVideoJob> => {
|
||||
const { data } = await apiClient.post<ViralVideoJob>(`/viral-video/${jobId}/confirm-intent`, body)
|
||||
return data
|
||||
/** 历史记录(分页) */
|
||||
export function getViralVideoHistory(params?: { page?: number; page_size?: number }) {
|
||||
return apiClient.get<HistoryResponse>("/viral-video/history", { params }).then((r) => r.data)
|
||||
}
|
||||
|
||||
/** 触发参考视频风格分析 */
|
||||
export const analyzeViralStyle = async (
|
||||
jobId: string,
|
||||
referenceVideoUrl: string,
|
||||
styleTemplateId: string = "",
|
||||
): Promise<AnalyzeStyleResponse> => {
|
||||
const { data } = await apiClient.post<AnalyzeStyleResponse>(
|
||||
`/viral-video/${jobId}/analyze-style`,
|
||||
{ reference_video_url: referenceVideoUrl, style_template_id: styleTemplateId },
|
||||
{ timeout: 120_000 },
|
||||
)
|
||||
return data
|
||||
/** 预设风格模板 */
|
||||
export function getViralStyleTemplates() {
|
||||
return apiClient.get<StyleTemplate[]>("/viral-video/style-templates").then((r) => r.data)
|
||||
}
|
||||
|
||||
/** 获取风格模板列表 */
|
||||
export const listStyleTemplates = async (): Promise<StyleTemplateListResponse> => {
|
||||
const { data } = await apiClient.get<StyleTemplateListResponse>("/viral-video/style-templates")
|
||||
return data
|
||||
}
|
||||
|
||||
/** 获取历史记录 */
|
||||
export const listViralVideoHistory = async (
|
||||
limit = 50,
|
||||
offset = 0,
|
||||
): Promise<ViralVideoHistoryResponse> => {
|
||||
const { data } = await apiClient.get<ViralVideoHistoryResponse>("/viral-video/history", {
|
||||
params: { limit, offset },
|
||||
})
|
||||
return data
|
||||
/** 上传参考视频后触发风格分析(返回带 style_guide 的任务详情) */
|
||||
export function analyzeViralStyle(id: string) {
|
||||
return apiClient.post<ViralVideoJob>(`/viral-video/${id}/analyze-style`).then((r) => r.data)
|
||||
}
|
||||
|
||||
@@ -1,19 +1,20 @@
|
||||
/**
|
||||
* 爆款视频 API 类型定义
|
||||
* 与后端 apps/api/app/schemas/viral_video.py 对齐
|
||||
*/
|
||||
export type FusionLevel = "full_ai" | "polish" | "as_is"
|
||||
export const FUSION_LEVELS: { value: FusionLevel; label: string; desc: string }[] = [
|
||||
{ value: "full_ai", label: "AI 全写", desc: "给我方向,全由AI创作" },
|
||||
{ value: "polish", label: "AI润色", desc: "我写草稿,AI帮我润色" },
|
||||
{ value: "as_is", label: "按我写的来", desc: "几乎不改我的文案" },
|
||||
]
|
||||
|
||||
/** 文案融合级别 */
|
||||
export type FusionLevel = "ai_full" | "ai_polish" | "user_primary"
|
||||
|
||||
/** 参考视频风格强度 */
|
||||
export type StyleStrength = "light" | "medium" | "strict"
|
||||
export const STYLE_STRENGTHS: { value: StyleStrength; label: string }[] = [
|
||||
{ value: "light", label: "轻度借鉴" },
|
||||
{ value: "medium", label: "中度参考" },
|
||||
{ value: "strict", label: "深度模仿" },
|
||||
]
|
||||
|
||||
/** 任务状态 */
|
||||
export type ViralVideoStatus =
|
||||
"pending" | "running" | "wait_user_confirm" | "completed" | "failed" | "cancelled"
|
||||
|
||||
/** 流水线阶段(对应后端 VALID_STAGES) */
|
||||
export type ViralVideoStage =
|
||||
| "image_analysis"
|
||||
| "video_analysis"
|
||||
@@ -27,136 +28,94 @@ export type ViralVideoStage =
|
||||
| "musetalk"
|
||||
| "uploading"
|
||||
|
||||
/** 创建爆款视频任务请求 */
|
||||
export interface CreateViralVideoRequest {
|
||||
images: string[]
|
||||
industry?: string
|
||||
target_customer?: string
|
||||
persona_id?: string
|
||||
viral_structure?: string
|
||||
marketing_purpose?: string
|
||||
bgm_preference?: string
|
||||
duration?: number
|
||||
user_copy_text?: string
|
||||
fusion_level?: FusionLevel
|
||||
reference_audio_path?: string
|
||||
/** v1.3 参考爆款视频 */
|
||||
reference_video_url?: string
|
||||
style_strength?: StyleStrength
|
||||
style_template_id?: string
|
||||
export interface StageDisplay {
|
||||
label: string
|
||||
/** 插值到的总体进度百分比 */
|
||||
pct: number
|
||||
}
|
||||
|
||||
/** 爆款视频任务详情 */
|
||||
export interface ViralVideoJob {
|
||||
id: string
|
||||
user_id: string
|
||||
images: string[]
|
||||
industry: string
|
||||
target_customer: string
|
||||
persona_id: string
|
||||
viral_structure: string
|
||||
marketing_purpose: string
|
||||
bgm_preference: string
|
||||
duration: number
|
||||
user_copy_text: string
|
||||
fusion_level: FusionLevel
|
||||
reference_audio_path: string
|
||||
reference_video_url: string
|
||||
style_strength: StyleStrength
|
||||
style_guide: Record<string, unknown> | null
|
||||
style_template_id: string
|
||||
status: ViralVideoStatus
|
||||
intent_result: IntentResult | null
|
||||
result_video_url: string
|
||||
credits_cost: number
|
||||
error_msg: string
|
||||
retry_count: number
|
||||
started_at: string | null
|
||||
completed_at: string | null
|
||||
created_at: string | null
|
||||
updated_at: string | null
|
||||
export const STAGE_DISPLAYS: Record<ViralVideoStage, StageDisplay> = {
|
||||
image_analysis: { label: "图片分析", pct: 15 },
|
||||
video_analysis: { label: "参考视频风格分析", pct: 25 },
|
||||
intent_parsing: { label: "意图理解", pct: 35 },
|
||||
copy_fusion: { label: "文案融合创作", pct: 50 },
|
||||
storyboard: { label: "分镜生成", pct: 60 },
|
||||
review: { label: "AI审核", pct: 70 },
|
||||
tts: { label: "配音生成", pct: 78 },
|
||||
bgm_select: { label: "BGM匹配", pct: 85 },
|
||||
rendering: { label: "视频渲染", pct: 92 },
|
||||
musetalk: { label: "口型同步", pct: 97 },
|
||||
uploading: { label: "上传成片", pct: 100 },
|
||||
}
|
||||
|
||||
/** AI 意图摘要卡片(intent_result 字段) */
|
||||
export interface IntentResult {
|
||||
/** 核心卖点 */
|
||||
selling_points?: string[]
|
||||
/** 目标人群 */
|
||||
target_audience?: string
|
||||
/** 营销钩子 */
|
||||
hook?: string
|
||||
/** 视频节奏/结构 */
|
||||
structure?: string
|
||||
/** AI 生成的润色后文案 */
|
||||
ai_copy?: string
|
||||
/** 视觉建议 */
|
||||
visual_notes?: string[]
|
||||
/** 其它字段 */
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
/** 确认意图请求 */
|
||||
export interface ConfirmIntentRequest {
|
||||
confirmed_copy?: string
|
||||
adjustments?: string
|
||||
}
|
||||
|
||||
/** 风格模板 */
|
||||
export interface StyleTemplate {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
thumbnail_url: string
|
||||
style_config: Record<string, unknown>
|
||||
description?: string
|
||||
preview_url?: string
|
||||
tags?: string[]
|
||||
}
|
||||
|
||||
export interface StyleTemplateListResponse {
|
||||
items: StyleTemplate[]
|
||||
export interface IntentResult {
|
||||
product: string
|
||||
selling_points: string[]
|
||||
target_audience: string
|
||||
tone: string
|
||||
structure: string
|
||||
duration: number
|
||||
suggested_title?: string
|
||||
suggested_copy?: string
|
||||
}
|
||||
|
||||
/** 风格分析响应 */
|
||||
export interface AnalyzeStyleResponse {
|
||||
job_id: string
|
||||
status: string
|
||||
style_guide: Record<string, unknown> | null
|
||||
export interface ViralVideoJob {
|
||||
id: string
|
||||
status: ViralVideoStatus
|
||||
images: string[]
|
||||
reference_video_url?: string
|
||||
style_strength?: StyleStrength
|
||||
style_template_id?: string
|
||||
style_guide?: string
|
||||
user_copy_text?: string
|
||||
final_copy_text?: string
|
||||
fusion_level?: FusionLevel
|
||||
voice_id?: string
|
||||
voice_mode?: "global" | "per_video"
|
||||
bgm_preference?: string
|
||||
intent_result?: IntentResult
|
||||
intent_text?: string
|
||||
progress_stage?: ViralVideoStage
|
||||
progress_percent?: number
|
||||
progress_message?: string
|
||||
output_url?: string
|
||||
error_message?: string
|
||||
credits_cost?: number
|
||||
created_at?: string
|
||||
updated_at?: string
|
||||
}
|
||||
|
||||
/** 历史记录列表响应 */
|
||||
export interface ViralVideoHistoryResponse {
|
||||
export interface GenerateViralVideoRequest {
|
||||
images: string[]
|
||||
reference_video_url?: string
|
||||
style_strength?: StyleStrength
|
||||
style_template_id?: string
|
||||
user_copy_text?: string
|
||||
fusion_level?: FusionLevel
|
||||
voice_id?: string
|
||||
bgm_preference?: string
|
||||
industry?: string
|
||||
target_customer?: string
|
||||
language?: string
|
||||
persona_id?: string
|
||||
viral_structure?: string
|
||||
marketing_purpose?: string
|
||||
duration?: number
|
||||
video_model?: string
|
||||
video_ratio?: string
|
||||
}
|
||||
|
||||
export interface HistoryResponse {
|
||||
items: ViralVideoJob[]
|
||||
total: number
|
||||
page: number
|
||||
page_size: number
|
||||
}
|
||||
|
||||
/** WebSocket/Redis 推送的进度事件 */
|
||||
export interface WSProgressEvent {
|
||||
type: "viral_video:progress"
|
||||
job_id: string
|
||||
stage: ViralVideoStage
|
||||
progress: number
|
||||
message: string
|
||||
data: Record<string, unknown>
|
||||
}
|
||||
|
||||
/** 前端阶段展示配置 */
|
||||
export interface StageDisplay {
|
||||
key: ViralVideoStage
|
||||
label: string
|
||||
/** 该阶段在总进度条中的起始百分比 */
|
||||
startPct: number
|
||||
/** 该阶段在总进度条中的结束百分比 */
|
||||
endPct: number
|
||||
}
|
||||
|
||||
export const STAGE_DISPLAYS: StageDisplay[] = [
|
||||
{ key: "image_analysis", label: "图片分析", startPct: 0, endPct: 15 },
|
||||
{ key: "video_analysis", label: "视频风格分析", startPct: 15, endPct: 25 },
|
||||
{ key: "intent_parsing", label: "意图理解", startPct: 25, endPct: 35 },
|
||||
{ key: "copy_fusion", label: "文案创作", startPct: 35, endPct: 50 },
|
||||
{ key: "storyboard", label: "分镜脚本", startPct: 50, endPct: 60 },
|
||||
{ key: "review", label: "合规审核", startPct: 60, endPct: 70 },
|
||||
{ key: "tts", label: "配音生成", startPct: 70, endPct: 75 },
|
||||
{ key: "bgm_select", label: "BGM 选择", startPct: 75, endPct: 78 },
|
||||
{ key: "rendering", label: "视频渲染", startPct: 78, endPct: 88 },
|
||||
{ key: "musetalk", label: "数字人口型", startPct: 88, endPct: 93 },
|
||||
{ key: "uploading", label: "成片上传", startPct: 93, endPct: 100 },
|
||||
]
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,229 +1,72 @@
|
||||
/**
|
||||
* 爆款视频任务轮询 Hook
|
||||
* 后端目前通过 Redis pub/sub 推送进度,但没有暴露 WebSocket 端点(仅有 TTS WS),
|
||||
* 所以先使用 HTTP 轮询(间隔 1.5s),等后端暴露 /ws/viral-video/{id} 再切 WS。
|
||||
*/
|
||||
import { useCallback, useEffect, useRef, useState } from "react"
|
||||
import { useCallback, useEffect, useRef } from "react"
|
||||
import { getViralVideoJob } from "@/api/viral-video"
|
||||
import type { ViralVideoJob, ViralVideoStage, STAGE_DISPLAYS } from "@/api/viral-video/types"
|
||||
import type { ViralVideoJob, ViralVideoStatus } from "@/api/viral-video/types"
|
||||
|
||||
const POLL_INTERVAL_MS = 1500
|
||||
const INITIAL_DELAY_MS = 800
|
||||
const ERROR_RETRY_BASE_MS = 2000
|
||||
const ERROR_RETRY_MAX_MS = 15000
|
||||
const MAX_CONSECUTIVE_ERRORS = 20
|
||||
const TERMINAL: ViralVideoStatus[] = ["completed", "failed", "cancelled"]
|
||||
|
||||
export interface PollingState {
|
||||
job: ViralVideoJob | null
|
||||
loading: boolean
|
||||
error: string | null
|
||||
/** 当前阶段 */
|
||||
stage: ViralVideoStage | null
|
||||
/** 聚合后的总进度 0-100(根据 stage + 后端 progress 插值) */
|
||||
overallProgress: number
|
||||
/** 当前阶段内的消息 */
|
||||
stageMessage: string
|
||||
}
|
||||
|
||||
export interface UseViralVideoPollingResult extends PollingState {
|
||||
startPolling: (jobId: string) => void
|
||||
stopPolling: () => void
|
||||
refresh: () => Promise<ViralVideoJob | null>
|
||||
}
|
||||
|
||||
interface Options {
|
||||
onComplete?: (job: ViralVideoJob) => void
|
||||
onWaitConfirm?: (job: ViralVideoJob) => void
|
||||
onFailed?: (errorMsg: string, job?: ViralVideoJob) => void
|
||||
onProgress?: (overallProgress: number, stage: ViralVideoStage | null, message: string) => void
|
||||
export interface UseViralVideoPollingOptions {
|
||||
/** 轮询间隔(毫秒),默认 1500 */
|
||||
intervalMs?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 将后端阶段+阶段内 progress 插值为 0-100 的总进度。
|
||||
* 后端 _emit_progress 传的 progress 是全局百分比(0-100),直接用即可;
|
||||
* 如果缺失则按阶段 startPct 兜底。
|
||||
* 爆款视频任务 HTTP 轮询 hook(后端暂未暴露 WS 桥,轮询兜底)。
|
||||
* 任务进入终态(completed/failed/cancelled)后自动停止。
|
||||
*/
|
||||
function resolveOverallProgress(
|
||||
job: ViralVideoJob,
|
||||
stages: typeof STAGE_DISPLAYS,
|
||||
): { pct: number; stage: ViralVideoStage | null } {
|
||||
const raw =
|
||||
typeof (job as unknown as Record<string, unknown>).current_stage_progress === "number"
|
||||
? ((job as unknown as Record<string, unknown>).current_stage_progress as number)
|
||||
: -1
|
||||
// 后端字段 current_stage_progress 未在 schema 中声明,回退到按阶段估算
|
||||
const status = job.status
|
||||
if (status === "completed") return { pct: 100, stage: "uploading" }
|
||||
if (status === "failed" || status === "cancelled") return { pct: 0, stage: null }
|
||||
if (status === "pending") return { pct: 2, stage: null }
|
||||
if (status === "wait_user_confirm") return { pct: 35, stage: "intent_parsing" }
|
||||
// running:如果后端没有明确 stage,返回 50 兜底
|
||||
const stageField = (job as unknown as Record<string, unknown>).current_stage as
|
||||
ViralVideoStage | undefined
|
||||
if (!stageField) return { pct: 50, stage: null }
|
||||
const sd = stages.find((s) => s.key === stageField)
|
||||
if (!sd) return { pct: 50, stage: stageField }
|
||||
if (raw >= 0 && raw <= 100) {
|
||||
return { pct: Math.max(sd.startPct, Math.min(sd.endPct, raw)), stage: stageField }
|
||||
}
|
||||
return { pct: (sd.startPct + sd.endPct) / 2, stage: stageField }
|
||||
}
|
||||
export function useViralVideoPolling(
|
||||
jobId: string | null | undefined,
|
||||
onUpdate: (job: ViralVideoJob) => void,
|
||||
options: UseViralVideoPollingOptions = {},
|
||||
) {
|
||||
const { intervalMs = 1500 } = options
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const stoppedRef = useRef(false)
|
||||
const failCountRef = useRef(0)
|
||||
|
||||
// 轻量阶段表(本地常量避免循环 import)
|
||||
const STAGES = [
|
||||
{ key: "image_analysis" as const, label: "图片分析", startPct: 0, endPct: 15 },
|
||||
{ key: "video_analysis" as const, label: "视频风格", startPct: 15, endPct: 25 },
|
||||
{ key: "intent_parsing" as const, label: "意图理解", startPct: 25, endPct: 35 },
|
||||
{ key: "copy_fusion" as const, label: "文案创作", startPct: 35, endPct: 50 },
|
||||
{ key: "storyboard" as const, label: "分镜脚本", startPct: 50, endPct: 60 },
|
||||
{ key: "review" as const, label: "合规审核", startPct: 60, endPct: 70 },
|
||||
{ key: "tts" as const, label: "配音", startPct: 70, endPct: 75 },
|
||||
{ key: "bgm_select" as const, label: "BGM", startPct: 75, endPct: 78 },
|
||||
{ key: "rendering" as const, label: "渲染", startPct: 78, endPct: 88 },
|
||||
{ key: "musetalk" as const, label: "数字人", startPct: 88, endPct: 93 },
|
||||
{ key: "uploading" as const, label: "成片上传", startPct: 93, endPct: 100 },
|
||||
]
|
||||
|
||||
export function useViralVideoPolling(options: Options = {}): UseViralVideoPollingResult {
|
||||
const { onComplete, onWaitConfirm, onFailed, onProgress } = options
|
||||
const [job, setJob] = useState<ViralVideoJob | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [stage, setStage] = useState<ViralVideoStage | null>(null)
|
||||
const [overallProgress, setOverallProgress] = useState(0)
|
||||
const [stageMessage, setStageMessage] = useState("")
|
||||
|
||||
const timerRef = useRef<number | null>(null)
|
||||
const jobIdRef = useRef<string>("")
|
||||
const consecutiveErrorsRef = useRef(0)
|
||||
const cancelledRef = useRef(false)
|
||||
const completedRef = useRef(false)
|
||||
|
||||
const clearTimer = useCallback(() => {
|
||||
if (timerRef.current != null) {
|
||||
const stop = useCallback(() => {
|
||||
stoppedRef.current = true
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current)
|
||||
timerRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
const refresh = useCallback(async (): Promise<ViralVideoJob | null> => {
|
||||
if (!jobIdRef.current) return null
|
||||
try {
|
||||
const data = await getViralVideoJob(jobIdRef.current)
|
||||
if (cancelledRef.current) return data
|
||||
consecutiveErrorsRef.current = 0
|
||||
setJob(data)
|
||||
setError(null)
|
||||
const { pct, stage: s } = resolveOverallProgress(data, STAGES)
|
||||
setStage(s)
|
||||
setOverallProgress(pct)
|
||||
const msg = (data as unknown as Record<string, unknown>).stage_message as string | undefined
|
||||
if (msg) setStageMessage(msg)
|
||||
onProgress?.(pct, s, msg || "")
|
||||
// 终态判断
|
||||
if (data.status === "completed" && !completedRef.current) {
|
||||
completedRef.current = true
|
||||
setOverallProgress(100)
|
||||
clearTimer()
|
||||
setLoading(false)
|
||||
onComplete?.(data)
|
||||
} else if (data.status === "wait_user_confirm" && !completedRef.current) {
|
||||
clearTimer()
|
||||
setLoading(false)
|
||||
onWaitConfirm?.(data)
|
||||
} else if (data.status === "failed" && !completedRef.current) {
|
||||
completedRef.current = true
|
||||
clearTimer()
|
||||
setLoading(false)
|
||||
onFailed?.(data.error_msg || "生成失败", data)
|
||||
} else if (data.status === "cancelled" && !completedRef.current) {
|
||||
completedRef.current = true
|
||||
clearTimer()
|
||||
setLoading(false)
|
||||
setError("任务已取消")
|
||||
}
|
||||
return data
|
||||
} catch (err) {
|
||||
consecutiveErrorsRef.current += 1
|
||||
if (consecutiveErrorsRef.current > MAX_CONSECUTIVE_ERRORS) {
|
||||
clearTimer()
|
||||
setLoading(false)
|
||||
const msg = err instanceof Error ? err.message : "轮询失败"
|
||||
setError(msg)
|
||||
onFailed?.(msg)
|
||||
return null
|
||||
}
|
||||
return null
|
||||
}
|
||||
}, [clearTimer, onComplete, onWaitConfirm, onFailed, onProgress])
|
||||
|
||||
const scheduleNext = useCallback(
|
||||
(delayMs: number) => {
|
||||
if (cancelledRef.current || completedRef.current) return
|
||||
clearTimer()
|
||||
timerRef.current = window.setTimeout(() => {
|
||||
refresh().finally(() => {
|
||||
if (!completedRef.current && !cancelledRef.current) {
|
||||
scheduleNext(
|
||||
consecutiveErrorsRef.current > 0
|
||||
? Math.min(
|
||||
ERROR_RETRY_BASE_MS * 2 ** (consecutiveErrorsRef.current - 1),
|
||||
ERROR_RETRY_MAX_MS,
|
||||
)
|
||||
: POLL_INTERVAL_MS,
|
||||
)
|
||||
}
|
||||
})
|
||||
}, delayMs)
|
||||
},
|
||||
[clearTimer, refresh],
|
||||
)
|
||||
|
||||
const startPolling = useCallback(
|
||||
(jobId: string) => {
|
||||
cancelledRef.current = false
|
||||
completedRef.current = false
|
||||
consecutiveErrorsRef.current = 0
|
||||
jobIdRef.current = jobId
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
setOverallProgress(0)
|
||||
setStage(null)
|
||||
setStageMessage("")
|
||||
// 首次拉取
|
||||
refresh().finally(() => {
|
||||
if (!completedRef.current && !cancelledRef.current) {
|
||||
scheduleNext(INITIAL_DELAY_MS)
|
||||
const pollOnce = useCallback(
|
||||
async (id: string) => {
|
||||
try {
|
||||
const job = await getViralVideoJob(id)
|
||||
failCountRef.current = 0
|
||||
onUpdate(job)
|
||||
if (TERMINAL.includes(job.status)) {
|
||||
stop()
|
||||
return
|
||||
}
|
||||
})
|
||||
if (stoppedRef.current) return
|
||||
// 后端在做 GPU 推理/合成阶段拉长间隔
|
||||
const inRender = job.progress_stage === "rendering" || job.progress_stage === "musetalk"
|
||||
const nextDelay = inRender ? 3000 : intervalMs
|
||||
timerRef.current = setTimeout(() => pollOnce(id), nextDelay)
|
||||
} catch (err) {
|
||||
failCountRef.current += 1
|
||||
if (stoppedRef.current) return
|
||||
// 指数退避,最多退到 10s
|
||||
const delay = Math.min(intervalMs * 2 ** Math.min(failCountRef.current, 3), 10000)
|
||||
timerRef.current = setTimeout(() => pollOnce(id), delay)
|
||||
}
|
||||
},
|
||||
[refresh, scheduleNext],
|
||||
[intervalMs, onUpdate, stop],
|
||||
)
|
||||
|
||||
const stopPolling = useCallback(() => {
|
||||
cancelledRef.current = true
|
||||
clearTimer()
|
||||
setLoading(false)
|
||||
}, [clearTimer])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
cancelledRef.current = true
|
||||
clearTimer()
|
||||
stoppedRef.current = false
|
||||
failCountRef.current = 0
|
||||
if (!jobId) {
|
||||
stop()
|
||||
return
|
||||
}
|
||||
}, [clearTimer])
|
||||
pollOnce(jobId)
|
||||
return stop
|
||||
}, [jobId, pollOnce, stop])
|
||||
|
||||
return {
|
||||
job,
|
||||
loading,
|
||||
error,
|
||||
stage,
|
||||
overallProgress,
|
||||
stageMessage,
|
||||
startPolling,
|
||||
stopPolling,
|
||||
refresh,
|
||||
}
|
||||
return { stop }
|
||||
}
|
||||
|
||||
export { STAGES as VIRAL_VIDEO_STAGES }
|
||||
|
||||
Reference in New Issue
Block a user