Compare commits

...

1 Commits

Author SHA1 Message Date
xiaoxia-frontend 1278de76ae feat(viral-video): 分镜脚本生成阶段接入 WebSocket 流式预览
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 1s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 2s
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 / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m36s
CI/CD Pipeline / PR Build API Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
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
PR Automation / Auto Approve on CI Green (pull_request) Successful in 2m53s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Successful in 1m22s
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 Web Image (pull_request) Successful in 1m32s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 4m0s
AI Code Review / AI Code Review (pull_request) Successful in 6m56s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 8m2s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 9m4s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 10m46s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 17m25s
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 1s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 15s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 1m26s
- 新增 useViralVideoWebSocket hook:订阅 /api/v1/viral-video/ws/{job_id},
  消费 viral_video:script_delta 事件,失败自动重连最多3次(指数退避),
  超出后静默交给 HTTP 轮询 fallback(与后端降级策略一致)
- 文案生成中(step2_generating):WebSocket 有 delta 时在文案框内实时渲染
  LLM 原始输出(等宽字体+闪烁光标+字数统计),无 delta 时保持原 spinner loading
- 离开生成态时自动清空流式缓冲,终态 copy_generated 由轮询接管,继续用原
  storyboard 结构化渲染,不影响后续编辑/出片流程
- CSS:vv-streaming-* 系列类,复用 vv-storyboard 固定高度+内部滚动布局
2026-10-10 17:40:09 +08:00
3 changed files with 282 additions and 6 deletions
@@ -2069,3 +2069,81 @@
border-top: 1px solid #e5e7eb;
margin: 12px 0;
}
/* ── 分镜脚本流式预览(WebSocket delta 实时输出) ── */
.vv-streaming-box {
outline: 2px solid #ede9fe;
background: linear-gradient(180deg, #fafafe 0%, #ffffff 100%);
}
.vv-streaming-header {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 0 10px 0;
border-bottom: 1px dashed #ede9fe;
margin-bottom: 8px;
flex-shrink: 0;
}
.vv-streaming-title {
font-size: 13px;
color: #7c3aed;
font-weight: 500;
flex: 1 1 auto;
}
.vv-streaming-count {
font-size: 12px;
color: #9ca3af;
background: #f5f0ff;
padding: 1px 8px;
border-radius: 10px;
}
.vv-streaming-doc {
flex: 1 1 auto;
overflow-y: auto;
padding: 4px 2px 8px 2px;
/* 复用 vv-sb-doc 的滚动条样式 */
scrollbar-width: thin;
scrollbar-color: #d8c4ff transparent;
}
.vv-streaming-doc::-webkit-scrollbar {
width: 6px;
}
.vv-streaming-doc::-webkit-scrollbar-thumb {
background: #d8c4ff;
border-radius: 3px;
}
.vv-streaming-pre {
margin: 0;
padding: 4px 6px;
white-space: pre-wrap;
word-break: break-word;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
font-size: 13px;
line-height: 1.65;
color: #1f2937;
tab-size: 2;
}
.vv-streaming-caret {
display: inline-block;
width: 2px;
height: 14px;
background: #7c3aed;
vertical-align: middle;
margin-left: 2px;
animation: vv-caret-blink 1s steps(2, start) infinite;
}
@keyframes vv-caret-blink {
to {
visibility: hidden;
}
}
.vv-streaming-actions {
flex-shrink: 0;
background: #fff;
padding-top: 8px;
border-top: 1px solid #f3f4f6;
}
.vv-streaming-hint {
font-size: 12px;
color: #9ca3af;
}
@@ -56,6 +56,7 @@ import {
getViralVideoModels,
} from "@/api/viral-video"
import { useViralVideoPolling } from "./hooks/useViralVideoPolling"
import { useViralVideoWS, type ViralVideoDeltaEvent } from "./hooks/useViralVideoWebSocket"
import CloneModal from "@/components/voice/CloneModal"
import AssetPickerModal from "./components/AssetPickerModal"
import PresetVoicePickerModal, { type PresetVoice } from "./components/PresetVoicePickerModal"
@@ -149,6 +150,10 @@ type TabTask = {
jobId: string | null
playingVoiceId: string | null
audioInst: HTMLAudioElement | null
// ── 分镜脚本流式预览(script_delta WebSocket 推送累积) ──
scriptStreamText: string
scriptStreamProgress: number
scriptStreamMessage: string
}
/* ── marked 配置:禁用 mangle/headerIds,输出干净 HTML ── */
@@ -469,6 +474,9 @@ const emptyTask = (id: string, title: string): TabTask => ({
jobId: null,
playingVoiceId: null,
audioInst: null,
scriptStreamText: "",
scriptStreamProgress: 0,
scriptStreamMessage: "",
})
/* ─────────── 主页面 ─────────── */
@@ -681,6 +689,37 @@ const ViralVideoPage: React.FC = () => {
)
useViralVideoPolling(task.jobId, onPollUpdate)
/* ── WebSocket 订阅 script_delta 事件,用于分镜脚本生成时的流式预览 ── */
const onScriptDelta = useCallback(
(ev: ViralVideoDeltaEvent) => {
setTask((t) => ({
...t,
scriptStreamText: ev.data.full_text || "",
scriptStreamProgress:
typeof ev.progress === "number" ? ev.progress : t.scriptStreamProgress,
scriptStreamMessage: ev.message || t.scriptStreamMessage,
}))
},
[setTask],
)
useViralVideoWS(task.uiStep === "step2_generating" ? task.jobId : null, {
onDelta: onScriptDelta,
})
// 进入/离开"文案生成中"时重置流式缓冲,避免上一个任务的残留
const prevUIStepRef = useRef(task.uiStep)
useEffect(() => {
const prev = prevUIStepRef.current
if (prev !== "step2_generating" && task.uiStep === "step2_generating") {
setTask({ scriptStreamText: "", scriptStreamProgress: 0, scriptStreamMessage: "" })
}
if (prev === "step2_generating" && task.uiStep !== "step2_generating") {
// 离开 generating 态时清空(copy_generated 终态已经有 storyboard 渲染,无需继续显示流式)
setTask({ scriptStreamText: "", scriptStreamProgress: 0, scriptStreamMessage: "" })
}
prevUIStepRef.current = task.uiStep
}, [task.uiStep, setTask])
/* ── 积分动态预估:STEP2文案生成完成后首次调用;STEP3参数变化时防抖300ms刷新 ── */
const creditsTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const creditsAbortRef = useRef<number>(0)
@@ -1336,13 +1375,36 @@ const ViralVideoPage: React.FC = () => {
const renderCopyResult = () => {
if (task.uiStep === "step2_generating") {
const hasStream = task.scriptStreamText.trim().length > 0
const stageMsg =
task.scriptStreamMessage ||
task.job?.progress_message ||
getCopyStageText(task.job?.progress_stage)
if (!hasStream) {
return (
<div className="vv-copy-box vv-copy-loading">
<span className="vv-spinner" />
<span className="vv-copy-loading-text">{stageMsg}</span>
<span className="vv-copy-loading-hint">预计 10-30 秒,请稍候</span>
</div>
)
}
return (
<div className="vv-copy-box vv-copy-loading">
<span className="vv-spinner" />
<span className="vv-copy-loading-text">
{task.job?.progress_message || getCopyStageText(task.job?.progress_stage)}
</span>
<span className="vv-copy-loading-hint">预计 10-30 秒,请稍候</span>
<div className="vv-copy-box vv-storyboard vv-streaming-box">
<div className="vv-streaming-header">
<span className="vv-spinner" />
<span className="vv-streaming-title">{stageMsg}</span>
<span className="vv-streaming-count">{task.scriptStreamText.length} 字</span>
</div>
<div className="vv-sb-doc vv-streaming-doc">
<pre className="vv-streaming-pre">
{task.scriptStreamText}
<span className="vv-streaming-caret" aria-hidden="true" />
</pre>
</div>
<div className="vv-sb-actions vv-streaming-actions">
<span className="vv-streaming-hint">AI 实时输出中,完成后可编辑分镜与口播文案</span>
</div>
</div>
)
}
@@ -0,0 +1,136 @@
import { useCallback, useEffect, useRef } from "react"
export interface ViralVideoDeltaEvent {
type: "viral_video:script_delta"
job_id: string
stage: string
progress: number
message?: string
data: {
delta: string
full_text: string
text_length: number
}
}
export interface ViralVideoWSError {
message: string
}
export interface UseViralVideoWSOptions {
/** delta 事件回调(script_delta 推送时触发) */
onDelta?: (ev: ViralVideoDeltaEvent) => void
/** 连接关闭/失败回调 */
onError?: (err: ViralVideoWSError) => void
/** 连接建立回调 */
onOpen?: () => void
}
const WS_SCHEME =
typeof window !== "undefined" && window.location.protocol === "https:" ? "wss:" : "ws:"
function buildWsUrl(jobId: string): string {
const token = localStorage.getItem("access_token") || ""
const host = window.location.host
return `${WS_SCHEME}//${host}/api/v1/viral-video/ws/${jobId}?token=${encodeURIComponent(token)}`
}
/**
* 爆款视频 WebSocket 订阅 hook。
* 仅在 jobId 非空时建立连接;组件卸载/ jobId 变更时自动关闭。
* 失败静默:后端已说明流式异常会自动 fallback 到 HTTP 轮询的 copy_generated 终态,
* 前端不需要中断主流程,连接失败时只记录不影响轮询继续推进 UI。
*/
export function useViralVideoWS(
jobId: string | null | undefined,
{ onDelta, onError, onOpen }: UseViralVideoWSOptions = {},
) {
const wsRef = useRef<WebSocket | null>(null)
const manualCloseRef = useRef(false)
const onDeltaRef = useRef(onDelta)
const onErrorRef = useRef(onError)
const onOpenRef = useRef(onOpen)
onDeltaRef.current = onDelta
onErrorRef.current = onError
onOpenRef.current = onOpen
const close = useCallback(() => {
manualCloseRef.current = true
if (wsRef.current) {
try {
wsRef.current.close()
} catch {
/* noop */
}
wsRef.current = null
}
}, [])
useEffect(() => {
if (!jobId) {
close()
return
}
manualCloseRef.current = false
let closed = false
let retryTimer: ReturnType<typeof setTimeout> | null = null
let startTimer: ReturnType<typeof setTimeout> | null = null
let retries = 0
const MAX_RETRIES = 3
const connect = () => {
if (closed) return
try {
const ws = new WebSocket(buildWsUrl(jobId))
wsRef.current = ws
ws.onopen = () => {
if (closed) return
retries = 0
onOpenRef.current?.()
}
ws.onmessage = (ev) => {
try {
const payload = JSON.parse(ev.data)
if (payload?.type === "viral_video:script_delta" && payload.data) {
onDeltaRef.current?.(payload as ViralVideoDeltaEvent)
}
// copy_generated/completed/failed 等终态事件由 HTTP 轮询统一处理,
// 此处仅消费 delta 做流式预览,不重复推状态。
} catch {
// 心跳/非 JSON 消息忽略
}
}
ws.onerror = () => {
onErrorRef.current?.({ message: "websocket error" })
}
ws.onclose = () => {
wsRef.current = null
if (closed || manualCloseRef.current) return
if (retries < MAX_RETRIES) {
retries += 1
const delay = 500 * 2 ** (retries - 1)
retryTimer = setTimeout(connect, delay)
}
}
} catch (e) {
onErrorRef.current?.({ message: (e as Error).message })
}
}
// 延迟 100ms 再连,给后端 job 初始化留一点时间
startTimer = setTimeout(connect, 100)
return () => {
closed = true
if (startTimer) clearTimeout(startTimer)
if (retryTimer) clearTimeout(retryTimer)
close()
}
}, [jobId, close])
return { close }
}