feat(viral-video): #2041 爆款视频创作页 浅色紫调三栏向导 UI + 全流程 #2107

Merged
xiaoxia merged 1 commits from feat/viral-video-page-2041 into develop 2026-09-30 18:30:39 +08:00
5 changed files with 1826 additions and 1852 deletions
+30 -74
View File
@@ -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)
}
+86 -127
View File
@@ -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 }