Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1278de76ae |
@@ -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 }
|
||||
}
|
||||
Reference in New Issue
Block a user