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
17 changed files with 674 additions and 1210 deletions
+1 -8
View File
@@ -28,17 +28,10 @@ depends_on = None
def _tpl(prompt_type: str, version: int) -> dict:
"""取模板:先按指定版本找,找不到则取该类型最新版本(兼容 v3→v4 升级)。"""
# 先按指定版本找
for t in DEFAULT_TEMPLATES:
if t["prompt_type"] == prompt_type and t["version"] == version:
return t
# 找不到则取最新版本
candidates = [t for t in DEFAULT_TEMPLATES if t["prompt_type"] == prompt_type]
if candidates:
latest = max(candidates, key=lambda x: x["version"])
return latest
raise RuntimeError("default template missing: %s" % prompt_type)
raise RuntimeError("default template missing: %s v%s" % (prompt_type, version))
def _upsert(bind, t: dict) -> None:
@@ -1,235 +0,0 @@
# -*- coding: utf-8 -*-
"""storyboard v4 - 多角色对话 + 废除旁白 + visual 5要素
Revision ID: 108_storyboard_v4_multivoice
Revises: 107
Create Date: 2026-10-10
变更:
1. storyboard v4: 废除旁白思维,所有voiceover必须是角色台词
- 增加<speaker>标签,每镜必须标注说话人
- visual强制5要素结构(景别/运镜/动作/环境/光线),每镜不少于30字
- voiceover_script用[speaker:xxx]标记格式
2. 旧版storyboard模板is_active设为false
3. 检查image_analysis和review是否有active模板,没有则插入保底版本
"""
from sqlalchemy import text
from alembic import op
revision = "108_storyboard_v4_multivoice"
down_revision = "107"
branch_labels = None
depends_on = None
# ── storyboard v4 system_prompt ──────────────────────────────────────
V4_STORYBOARD_SYSTEM = """你是一名懂短视频的编导和口播文案高手。你会拿到图片的真实观察、营销目的和用户参数,请一次性完成对营销意图的理解,并产出可直接拍摄/生成的分镜脚本。不要单独输出"意图解析",意图要直接体现在台词和分镜里。
## 核心设计原则(必须严格遵守)
1. **废除旁白思维**:所有视频类型——无论对话短剧/口播带货/获客引流/品牌故事——voiceover 必须是人物说的话(第一人称或角色对白),绝对不能出现第三人称旁白解说。观众看的是人在演、在说。
2. **严禁第三人称解说性台词**:如"接下来展示...""这款产品..."这类上帝视角描述禁止出现在 voiceover 中。
3. **短剧类营销目的**(对话短剧/反转短剧/悬念短剧/情绪短片):双角色对话格式"甲:xxx 乙:xxx",镜头在角色间切换。
4. **口播类**(口播带货/促销转化/功能演示/痛点解决/获客引流/账号涨粉/活动通知/场景种草):第一人称对镜头说话,像真人出镜。
## 输出格式(XML,严格按结构输出,不要输出额外解释)
<script>
<copy_display_markdown><![CDATA[直接展示给用户看的成片文案,用 Markdown 写成流畅叙述]]></copy_display_markdown>
<clips>
<clip index="1">
<time_range>0-3秒</time_range>
<speaker>说话人标识(如"店主""顾客""主播")</speaker>
<voiceover>这一镜的角色台词(人物说的话,不是旁白)</voiceover>
<visual>【景别】【镜头运动】【人物动作/表情】【环境/道具】【光线氛围】5要素结构,不少于30字</visual>
<reference_image_index>0</reference_image_index>
</clip>
</clips>
<voiceover_script>把所有 clip 的 voiceover 连成完整台词稿,用 [speaker:xxx] 标记每个说话段落</voiceover_script>
<theme>一句话主题</theme>
<negative>【反套路化要求】
禁止使用"家人们谁懂啊""绝绝子""宝子们""家人们""太绝了""yyds"等烂大街网络词;
禁止固定模板化开头;语言要像真人朋友之间的分享,自然、具体、有信息量。</negative>
</script>
## 写作要求
1. **台词(voiceover)**:像真人面对镜头说话或角色对白,短句、口语化、有停顿有情绪,开头 3 秒给出钩子;不要书面腔,不要机械报参数。严禁第三人称解说。
2. **说话人(speaker)**:每个 clip 必须标注说话人标识,如"店主""顾客""主播""我"等。短剧类必须有至少 2 个不同角色。
3. **画面描述(visual)**:强制 5 要素结构——【景别】【镜头运动】【人物动作/表情】【环境/道具】【光线氛围】,每镜 visual 不少于 30 字,要具体到"闭眼听台词能想象出画面"。
4. **voiceover_script 格式**:用 [speaker:xxx] 标记每个说话段落,如"[speaker:店主]你是不是也觉得...[speaker:顾客]是啊,怎么回事?"
5. copy_display_markdown:直接展示给最终用户的文案,用 Markdown 写成自然、流畅、有感染力的成片文案。
6. 内容必须来自图片观察与用户给出的信息,不编造卖点、不夸大、不使用绝对化用语和虚假承诺。
7. reference_image_index 填本镜参考图片序号(从 0 开始),没有合适参考图填 -1。
8. 分镜数量与时长匹配总时长,节奏紧凑。
9. **口播字数硬约束**(必须严格遵守):按每秒约 2.5~3 个中文字(正常口播语速)计算:
- 5秒视频:voiceover_script 总字数 12~15 字
- 10秒视频:voiceover_script 总字数 25~30 字
- 15秒视频:voiceover_script 总字数 35~45 字
- 20秒视频:voiceover_script 总字数 50~60 字
- 30秒视频:voiceover_script 总字数 75~90 字
- 宁可少写也不要多写,超长会导致 TTS 音频超出视频时长限制
10. **镜头数量硬约束**:5秒1~2镜、10秒3镜、15秒3~4镜、20秒4~5镜、30秒6~8镜
11. **时间轴硬约束**:第一个clip从0秒开始,最后一个clip结束于total_duration秒,相邻clip首尾相接
12. 必须严格按<marketing_purpose><target_audience><persona><viral_structure><language><industry>指定的参数写文案和分镜
13. 镜头间动作衔接要自然,画面描述要具体到能直接拍摄/生成"""
V4_STORYBOARD_USER = """<marketing_purpose>{marketing_purpose}</marketing_purpose>
<industry>{industry}</industry>
<image_analysis>
{image_summary}
</image_analysis>
<user_parameters>
<theme_hint>{theme_hint}</theme_hint>
<duration>{duration}秒</duration>
<aspect_ratio>{aspect_ratio}</aspect_ratio>
<tone>{tone}</tone>
<target_audience>{target_audience}</target_audience>
<persona>{persona_hint}</persona>
<viral_structure>{viral_structure_hint}</viral_structure>
<language>{language_hint}</language>
<extra_requirements>{extra_requirements}</extra_requirements>
</user_parameters>
{video_style_section}
请严格按 XML 结构输出分镜脚本。"""
V4_STORYBOARD_EXAMPLE = """<script>
<copy_display_markdown><![CDATA[# 在御众堂,把松弛的自己一点点找回来
产后妈妈最懂那种力不从心,推开门,暖光和一杯热茶先接住了你……]]></copy_display_markdown>
<clips>
<clip index="1">
<time_range>0-3秒</time_range>
<speaker>店主</speaker>
<voiceover>生完娃,是不是连照镜子的勇气都没了?</voiceover>
<visual>【中近景】【缓推】【妈妈疲惫看向镜子】【暖光店内环境】【柔和暖光】</visual>
<reference_image_index>0</reference_image_index>
</clip>
<clip index="2">
<time_range>3-6秒</time_range>
<speaker>顾客</speaker>
<voiceover>是啊,怎么回事?</voiceover>
<visual>【近景】【固定】【顾客表情惊讶】【店内休息区】【暖色调】</visual>
<reference_image_index>0</reference_image_index>
</clip>
</clips>
<voiceover_script>[speaker:店主]生完娃,是不是连照镜子的勇气都没了?[speaker:顾客]是啊,怎么回事?</voiceover_script>
<theme>产后妈妈走进御众堂重拾状态</theme>
<negative>模糊、畸变、夸大疗效、绝对化用语</negative>
</script>"""
def upgrade() -> None:
conn = op.get_bind()
# 1. 查询 storyboard 当前最大 version
result = conn.execute(
text("SELECT MAX(version) FROM viral_video_prompt_templates WHERE prompt_type = :pt"),
{"pt": "storyboard"},
)
max_version = result.scalar() or 0
new_version = max_version + 1
# 2. 旧版 storyboard 模板 is_active 设为 false
conn.execute(
text("UPDATE viral_video_prompt_templates SET is_active = false WHERE prompt_type = :pt"),
{"pt": "storyboard"},
)
# 3. 防御性插入新版 storyboard 模板
existing = conn.execute(
text("SELECT id FROM viral_video_prompt_templates " "WHERE prompt_type = :pt AND version = :ver"),
{"pt": "storyboard", "ver": new_version},
).fetchone()
if not existing:
conn.execute(
text(
"INSERT INTO viral_video_prompt_templates "
"(name, prompt_type, version, system_prompt, user_prompt_template, example_output, is_active) "
"VALUES (:name, :pt, :ver, :sys, :usr, :ex, :active)"
),
{
"name": "编导分镜v4-多角色对话版",
"pt": "storyboard",
"ver": new_version,
"sys": V4_STORYBOARD_SYSTEM,
"usr": V4_STORYBOARD_USER,
"ex": V4_STORYBOARD_EXAMPLE,
"active": True,
},
)
# 4. 检查 image_analysis 是否有 active 模板,没有则插入保底
ia_active = conn.execute(
text("SELECT COUNT(*) FROM viral_video_prompt_templates WHERE prompt_type = :pt AND is_active = true"),
{"pt": "image_analysis"},
).scalar()
if ia_active == 0:
# 插入保底 image_analysis 模板(简化版)
ia_max = (
conn.execute(
text("SELECT MAX(version) FROM viral_video_prompt_templates WHERE prompt_type = :pt"),
{"pt": "image_analysis"},
).scalar()
or 0
)
conn.execute(
text(
"INSERT INTO viral_video_prompt_templates "
"(name, prompt_type, version, system_prompt, user_prompt_template, example_output, is_active) "
"VALUES (:name, :pt, :ver, :sys, :usr, :ex, :active)"
),
{
"name": "图片分析保底版",
"pt": "image_analysis",
"ver": ia_max + 1,
"sys": "你是一名擅长观察和写作的品牌内容编导。分析图片并输出JSON。",
"usr": "请分析这张图片。图片地址:{image_url}",
"ex": '{"images": [{"type": "store", "name": "门店", "summary_markdown": "描述"}]}',
"active": True,
},
)
# 5. 检查 review 是否有 active 模板
review_active = conn.execute(
text("SELECT COUNT(*) FROM viral_video_prompt_templates WHERE prompt_type = :pt AND is_active = true"),
{"pt": "review"},
).scalar()
if review_active == 0:
rv_max = (
conn.execute(
text("SELECT MAX(version) FROM viral_video_prompt_templates WHERE prompt_type = :pt"),
{"pt": "review"},
).scalar()
or 0
)
conn.execute(
text(
"INSERT INTO viral_video_prompt_templates "
"(name, prompt_type, version, system_prompt, user_prompt_template, example_output, is_active) "
"VALUES (:name, :pt, :ver, :sys, :usr, :ex, :active)"
),
{
"name": "文案审核保底版",
"pt": "review",
"ver": rv_max + 1,
"sys": "你是短视频广告合规审核专家。审核文案输出XML。",
"usr": "请审核:{fusion_text}",
"ex": "<review><passed>true</passed></review>",
"active": True,
},
)
def downgrade() -> None:
# 恢复旧版 storyboard 为 active
conn = op.get_bind()
conn.execute(
text("UPDATE viral_video_prompt_templates SET is_active = true WHERE prompt_type = :pt AND version < :ver"),
{"pt": "storyboard", "ver": 4},
)
# 删除新版
conn.execute(
text("DELETE FROM viral_video_prompt_templates WHERE prompt_type = :pt AND version >= :ver"),
{"pt": "storyboard", "ver": 4},
)
+3 -12
View File
@@ -701,7 +701,7 @@ def retry_viral_video_job(
)
# 差额为 0 则不调整
# 重置状态(从阶段1重新开始,清空上一次产物避免脏数据)
# 重置状态
job.retry_count += 1
job.status = ViralVideoStatus.PENDING
job.error_msg = "" if not is_stale_running else "任务执行超时,已重置重试"
@@ -710,20 +710,11 @@ def retry_viral_video_job(
job.current_stage = ""
job.phase_message = ""
job.heartbeat_at = None
# 重试时清空上次产物(参考 resume_from_image_analyzed 逻辑)
job.intent_result = None
job.copy_result = None
job.storyboard = None
job.generated_copy_text = ""
job.result_video_url = ""
job.pre_trusted_images = None
job.credits_cost = 0.0
repo.update(job)
# 重新入队:从图片分析阶段1重新跑(旧 run_viral_video_pipeline 只跑到 wait_user_confirm 就停,
# 会导致前端永远转圈或文案确认页卡死后 confirm-copy 返回 409)
# 重新入队
try:
celery_app.send_task("worker.run_viral_video_analyze", args=[job.id])
celery_app.send_task("worker.run_viral_video_pipeline", args=[job.id])
logger.info(
"[爆款视频] 重试入队: job_id=%s retry_count=%d stale=%s params_changed=%s",
job.id,
-20
View File
@@ -65,18 +65,6 @@ export function isAnalysisStage(stage: ViralVideoStage | undefined): boolean {
return isImageAnalysisStage(stage) || isCopyStage(stage)
}
/** 从 job 对象取到有效阶段(兼容 progress_stage/current_stage 两种字段名) */
export function getJobStage(job: { current_stage?: ViralVideoStage; progress_stage?: ViralVideoStage } | null | undefined): ViralVideoStage | undefined {
if (!job) return undefined
return job.current_stage || job.progress_stage
}
/** 从 job 对象取到阶段提示文案(兼容 phase_message/progress_message) */
export function getJobPhaseMessage(job: { phase_message?: string; progress_message?: string } | null | undefined): string {
if (!job) return ""
return job.phase_message || job.progress_message || ""
}
/** 单张图片 VLM 识别结果(v8 叙述优先,仅保留最少结构化字段) */
export interface ImageProductAnalysis {
/** store / product / person / scene */
@@ -101,8 +89,6 @@ export interface ImageAnalysisResult {
export interface ShotScript {
/** 时间区间,如 "0-3秒" */
time_range?: string
/** 说话人角色,如 "店主"、"顾客" */
speaker?: string
/** 景别/角度/运镜,如 "近景俯拍45度,缓慢推镜" */
shot_type_angle_movement?: string
/** 场景描述+对白 */
@@ -203,14 +189,8 @@ export interface ViralVideoJob {
video_model?: string
/** 视频时长(秒,5-30,默认15) */
duration?: number
/** 后端真实字段名(snake_case):当前细粒度阶段 */
current_stage?: ViralVideoStage
/** 后端真实字段名:当前阶段中文提示文案 */
phase_message?: string
/** @deprecated 兼容旧前端:使用 current_stage */
progress_stage?: ViralVideoStage
progress_percent?: number
/** @deprecated 兼容旧前端:使用 phase_message */
progress_message?: string
output_url?: string
result_video_url?: string
@@ -1020,11 +1020,6 @@
border-radius: 10px;
padding: 10px 12px;
margin-top: 8px;
display: flex;
flex-direction: column;
flex: 1 1 auto;
min-height: 260px;
overflow: hidden;
}
.vv-copy-head {
display: flex;
@@ -1055,8 +1050,7 @@
flex-direction: column;
align-items: center;
justify-content: center;
flex: 1 1 auto;
min-height: 260px;
height: 360px;
padding: 28px 16px;
gap: 10px;
background: #fff;
@@ -1093,8 +1087,7 @@
.vv-storyboard {
display: flex;
flex-direction: column;
flex: 1 1 auto;
min-height: 260px;
height: 360px;
padding: 10px 12px;
background: #fff;
border: 1px solid #e5e7eb;
@@ -1447,8 +1440,7 @@
/* ── Preview ──────────────── */
.vv-preview {
aspect-ratio: 9 / 16;
max-height: 100%;
max-width: 100%;
max-height: 360px;
background: #fff;
border: 1px dashed #e5e7eb;
border-radius: 10px;
@@ -2155,120 +2147,3 @@
font-size: 12px;
color: #9ca3af;
}
/* ── 三列等高布局(2026-10-10 fix) ──
* vv-page 作为纵向 flex 容器,tabs 固定 + grid 占满剩余高度;
* vv-grid 改用 grid-auto-rows:1fr 强制三列等高;
* 每列的卡片使用 flex column + min-height:0,正文区域 flex:1 并内部滚动。
*/
.vv-page {
display: flex;
flex-direction: column;
/* 减去顶部导航 56px 和自身 padding 16*2=32px,给 Tabs + 三列区留一屏 */
height: calc(100vh - 56px);
min-height: 640px; /* 小窗口保底 */
overflow: hidden;
}
.vv-tabs {
flex-shrink: 0;
}
.vv-grid {
flex: 1 1 auto;
min-height: 0;
align-items: stretch; /* 覆盖原来的 start,强制行内格子撑满行高 */
grid-auto-rows: 1fr; /* 每行所有格子等高 */
overflow: hidden;
}
.vv-col {
display: flex;
flex-direction: column;
gap: 12px;
min-height: 0;
min-width: 0;
}
/* 列内的卡片(.vv-section)默认允许收缩,正文区域接管滚动 */
.vv-col > .vv-section {
display: flex;
flex-direction: column;
min-height: 0;
flex-shrink: 1;
}
/* STEP1/STEP2 唯一卡片:撑满列高 */
.vv-col > .vv-section:only-child {
flex: 1 1 auto;
}
/* STEP3 右侧两卡片:配置固定不缩,预览区吸剩余空间 */
.vv-col > .vv-section-preview {
flex: 1 1 auto;
min-height: 0;
}
.vv-section-preview .vv-section-body.vv-preview-body {
flex: 1 1 auto;
min-height: 0;
display: flex;
align-items: center;
justify-content: center;
padding: 12px;
overflow: hidden;
}
.vv-section-preview .vv-preview {
max-height: 100%;
height: 100%;
aspect-ratio: auto;
width: 100%;
}
/* 列内 .vv-credits / .vv-error / 主按钮 不参与收缩,永远固定在底部可见 */
.vv-col > .vv-credits,
.vv-col > .vv-error,
.vv-col > .vv-btn-primary {
flex-shrink: 0;
}
.vv-col > .vv-credits {
margin-top: 0;
}
/* 可滚动 section 正文 */
.vv-section-body.vv-section-body-scroll {
flex: 1 1 auto;
min-height: 0;
display: flex;
flex-direction: column;
padding: 14px 16px 14px;
overflow: hidden;
}
.vv-scroll-inner {
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
padding-right: 6px;
display: flex;
flex-direction: column;
/* 美化滚动条(紫白主题) */
scrollbar-width: thin;
scrollbar-color: #d8c4ff transparent;
}
.vv-scroll-inner::-webkit-scrollbar {
width: 6px;
}
.vv-scroll-inner::-webkit-scrollbar-thumb {
background: #d8c4ff;
border-radius: 3px;
}
.vv-scroll-inner::-webkit-scrollbar-thumb:hover {
background: #c4a8ff;
}
.vv-scroll-inner::-webkit-scrollbar-track {
background: transparent;
}
/* 主按钮始终顶在 section-body 底部(在 vv-scroll-inner 之外) */
.vv-section-body.vv-section-body-scroll > .vv-btn-primary {
margin-top: 10px;
flex-shrink: 0;
}
/* STEP1 文件列表改为根据可用空间自适应,不再用 max-height:260px 硬编码 */
.vv-file-list {
max-height: none;
}
File diff suppressed because it is too large Load Diff
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useRef } from "react"
import { getViralVideoJob } from "@/api/viral-video"
import { isAnalysisStage, getJobStage, type ViralVideoJob, type ViralVideoStatus } from "@/api/viral-video/types"
import { isAnalysisStage, type ViralVideoJob, type ViralVideoStatus } from "@/api/viral-video/types"
const TERMINAL: ViralVideoStatus[] = ["completed", "failed", "cancelled"]
@@ -44,21 +44,14 @@ export function useViralVideoPolling(
}
if (stoppedRef.current) return
// 视频渲染阶段(Seedance 多段视频生成较慢)拉长轮询间隔
const stage = getJobStage(job)
const inRender = stage === "rendering"
const inRender = job.progress_stage === "rendering"
// 分析阶段走默认间隔即可
const isAnalyzing = isAnalysisStage(stage)
const isAnalyzing = isAnalysisStage(job.progress_stage)
const nextDelay = inRender ? 3000 : isAnalyzing ? 2000 : intervalMs
timerRef.current = setTimeout(() => pollOnce(id), nextDelay)
} catch (_err) {
failCountRef.current += 1
if (stoppedRef.current) return
// P2-5: 连续失败 20 次(约 2 分钟)后熔断,避免后端挂掉时前端无限轮询
if (failCountRef.current >= 20) {
console.warn("[viral-video] 轮询连续失败 20 次,停止轮询,请刷新页面重试")
stop()
return
}
const delay = Math.min(intervalMs * 2 ** Math.min(failCountRef.current, 3), 10000)
timerRef.current = setTimeout(() => pollOnce(id), delay)
}
@@ -17,11 +17,6 @@ export interface ViralVideoWSError {
message: string
}
export interface ViralVideoTerminalEvent {
type: "viral_video:completed" | "viral_video:failed" | "viral_video:copy_generated"
job_id: string
}
export interface UseViralVideoWSOptions {
/** delta 事件回调(script_delta 推送时触发) */
onDelta?: (ev: ViralVideoDeltaEvent) => void
@@ -29,8 +24,6 @@ export interface UseViralVideoWSOptions {
onError?: (err: ViralVideoWSError) => void
/** 连接建立回调 */
onOpen?: () => void
/** P2-6: 收到终态事件(copy_generated/completed/failed)时回调,上层应立即主动拉取一次最新 job,减少终态延迟 */
onTerminal?: (ev: ViralVideoTerminalEvent) => void
}
const WS_SCHEME =
@@ -50,18 +43,16 @@ function buildWsUrl(jobId: string): string {
*/
export function useViralVideoWS(
jobId: string | null | undefined,
{ onDelta, onError, onOpen, onTerminal }: UseViralVideoWSOptions = {},
{ 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)
const onTerminalRef = useRef<UseViralVideoWSOptions["onTerminal"]>()
onDeltaRef.current = onDelta
onErrorRef.current = onError
onOpenRef.current = onOpen
onTerminalRef.current = onTerminal
const close = useCallback(() => {
manualCloseRef.current = true
@@ -104,15 +95,9 @@ export function useViralVideoWS(
const payload = JSON.parse(ev.data)
if (payload?.type === "viral_video:script_delta" && payload.data) {
onDeltaRef.current?.(payload as ViralVideoDeltaEvent)
} else if (
payload?.type === "viral_video:completed" ||
payload?.type === "viral_video:failed" ||
payload?.type === "viral_video:copy_generated"
) {
// P2-6: 收到终态事件时主动通知上层立即拉取,降低终态延迟(不用等下一个轮询周期)
onTerminalRef.current?.(payload as ViralVideoTerminalEvent)
}
// 心跳/其他事件忽略
// copy_generated/completed/failed 等终态事件由 HTTP 轮询统一处理,
// 此处仅消费 delta 做流式预览,不重复推状态。
} catch {
// 心跳/非 JSON 消息忽略
}
+12 -30
View File
@@ -764,9 +764,8 @@ def _script_from_xml(raw: str, job: ViralVideoJob) -> dict | None:
ref_idx = xp.attr_int(ref_raw, -1) if ref_raw not in (None, "") else -1
if not isinstance(ref_idx, int) or ref_idx < 0:
ref_idx = None
speaker = xp.text_of(body, "speaker") or "主播"
if voice:
voice_parts.append(f"[speaker:{speaker}]{voice}")
voice_parts.append(voice)
shot = {
"time_range": a.get("time_range") or xp.text_of(body, "time_range") or f"{i * 3}-{(i + 1) * 3}秒",
"shot_type_angle_movement": visual or "中景平视,固定镜头",
@@ -1923,7 +1922,7 @@ def run_viral_video_pipeline(self: Task, job_id: str) -> dict:
job.mark_running()
_save_job(repo, job, session)
_hb_stop, _ = _start_heartbeat_thread(job_id)
_hb_stop, _hb_thread = _start_heartbeat_thread(job_id)
_set_stage(job, repo, session, ViralVideoStage.IMAGE_ANALYSIS, "正在分析商品特征...")
image_analysis = _step_image_analysis(job)
@@ -1973,16 +1972,8 @@ def run_viral_video_pipeline(self: Task, job_id: str) -> dict:
except Retry:
raise
except Exception as e:
logger.error("[爆款视频][pipeline] 旧一键流水线失败 job_id=%s: %s", job_id, e, exc_info=True)
_err_stage = ""
try:
_r = SQLAlchemyViralVideoJobRepository(session)
_j = _r.get(job_id)
if _j is not None and getattr(_j, "current_stage", None):
_err_stage = _j.current_stage
except Exception:
pass
_mark_failed_and_notify(job_id, session, None, None, str(e), _err_stage)
logger.error("[爆款视频] 流水线异常: %s", e, exc_info=True)
_mark_failed_and_notify(job_id, session, None, None, str(e), "")
return {"ok": False, "job_id": job_id, "error": str(e)}
finally:
if _hb_stop is not None:
@@ -2005,16 +1996,8 @@ def resume_viral_video_pipeline(self: Task, job_id: str) -> dict:
except Retry:
raise
except Exception as e:
logger.error("[爆款视频][resume-pipeline] 恢复流水线失败 job_id=%s: %s", job_id, e, exc_info=True)
_err_stage = ""
try:
_r = SQLAlchemyViralVideoJobRepository(session)
_j = _r.get(job_id)
if _j is not None and getattr(_j, "current_stage", None):
_err_stage = _j.current_stage
except Exception:
pass
_mark_failed_and_notify(job_id, session, None, None, str(e), _err_stage)
logger.error("[爆款视频] 恢复流水线异常: %s", e, exc_info=True)
_mark_failed_and_notify(job_id, session, None, None, str(e), "")
return {"ok": False, "job_id": job_id, "error": str(e)}
finally:
if session:
@@ -2050,14 +2033,13 @@ def run_video_style_analysis(self: Task, job_id: str) -> dict:
def _mark_failed_and_notify(job_id: str, session, repo, job, err_msg: str, stage: str = "") -> None:
"""标记任务失败并通知。若传入的 session 已失效(因前面异常导致 rollback 状态),
会自动 fallback 到新建 SessionLocal 重新标记,确保状态一定落库。
BUG 修复:失败时持久化 stage 到 current_stage,前端才能正确归因错误。"""
会自动 fallback 到新建 SessionLocal 重新标记,确保状态一定落库。"""
try:
# 尝试用传入的 session 标记
marked = False
if job is not None and not job.is_terminal and session is not None:
try:
job.mark_failed(err_msg, stage=stage)
job.mark_failed(err_msg)
_save_job(repo, job, session)
marked = True
except Exception as se:
@@ -2073,7 +2055,7 @@ def _mark_failed_and_notify(job_id: str, session, repo, job, err_msg: str, stage
r = SQLAlchemyViralVideoJobRepository(ssn)
j = r.get(job_id)
if j is not None and not j.is_terminal:
j.mark_failed(err_msg, stage=stage)
j.mark_failed(err_msg)
r.update(j)
ssn.commit()
finally:
@@ -2112,7 +2094,7 @@ def run_viral_video_analyze(self: Task, job_id: str) -> dict:
job.mark_running()
_save_job(repo, job, session)
_hb_stop, _ = _start_heartbeat_thread(job_id)
_hb_stop, _hb_thread = _start_heartbeat_thread(job_id)
_set_stage(job, repo, session, ViralVideoStage.IMAGE_ANALYSIS, "正在分析商品特征...")
image_analysis = _step_image_analysis(job)
@@ -2203,7 +2185,7 @@ def run_viral_video_generate_copy(self: Task, job_id: str) -> dict:
return {"ok": False, "error": f"unexpected status: {job.status}"}
job.touch_heartbeat()
_save_job(repo, job, session)
_hb_stop, _ = _start_heartbeat_thread(job_id)
_hb_stop, _hb_thread = _start_heartbeat_thread(job_id)
# v8:意图理解并入分镜生成,一次 LLM 调用;流式推送 script_delta 给前端
image_analysis = normalize_image_analysis(job.image_analysis)
@@ -2584,7 +2566,7 @@ def run_viral_video_render(self: Task, job_id: str) -> dict:
return {"ok": False, "error": f"unexpected status: {job.status}"}
job.touch_heartbeat()
_save_job(repo, job, session)
_hb_stop, _ = _start_heartbeat_thread(job_id)
_hb_stop, _hb_thread = _start_heartbeat_thread(job_id)
return _run_render_pipeline(job_id, session, repo, job)
except Retry:
raise
+1 -3
View File
@@ -40,9 +40,7 @@ server {
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 3600s;
proxy_read_timeout 300s;
proxy_send_timeout 300s;
proxy_request_buffering off;
}
+1 -3
View File
@@ -39,9 +39,7 @@ server {
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 3600s;
proxy_read_timeout 300s;
proxy_send_timeout 300s;
proxy_request_buffering off;
}
+1 -11
View File
@@ -244,13 +244,6 @@ class ViralVideoJob:
self.heartbeat_at = _now
self.status = ViralVideoStatus.RUNNING
self.updated_at = _now
# 清空上一次渲染留下的错误/产物(参考 resume_from_image_analyzed 对齐)
self.error_msg = ""
self.result_video_url = ""
self.current_stage = ""
self.phase_message = ""
self.completed_at = None
self.credits_cost = 0.0
def resume_from_confirm(self) -> None:
if self.status != ViralVideoStatus.WAIT_USER_CONFIRM:
@@ -267,12 +260,9 @@ class ViralVideoJob:
self.completed_at = datetime.now(timezone.utc)
self.updated_at = datetime.now(timezone.utc)
def mark_failed(self, error_msg: str, stage: str = "") -> None:
def mark_failed(self, error_msg: str) -> None:
self.status = ViralVideoStatus.FAILED
self.error_msg = error_msg
# 失败时持久化失败阶段,避免前端错误归因
if stage:
self.current_stage = stage
self.completed_at = datetime.now(timezone.utc)
self.updated_at = datetime.now(timezone.utc)
+1 -3
View File
@@ -67,12 +67,10 @@ def _classify_dashscope_error(status_code: int, body: str, task_msg: str = "") -
return "invalid_param", f"Wan 3.0 参数错误:{msg_in_body or body[:200]}"
if status_code == 0:
return "network_error", "Wan 3.0 服务连接失败(网络超时),请稍后重试。"
# 任务内失败(异步轮询结果):此时 HTTP 200 只是查询成功,不代表任务成功,提示文案不要带 HTTP 200 以免误导
# 任务内失败
if task_msg and any(kw in task_msg.lower() for kw in ("portrait", "真人", "人脸", "violation", "blocked")):
return "portrait_intercept", "Wan 3.0 视频内容被安全策略拦截,请调整文案或参考图后重试。"
detail = msg_in_body or body[:200]
if task_msg and status_code == 200:
return "task_failed", f"Wan 3.0 视频生成失败:{detail}"
return "unknown", f"Wan 3.0 视频生成失败(HTTP {status_code}):{detail}"
+1 -16
View File
@@ -10,22 +10,7 @@ source "${SCRIPT_DIR}/ci_env.sh"
echo "=== Installing mypy ==="
# pip�容错: 默认�(阿里云)缺文件时fallback到清�/官方�(2026-10-10 librt-0.6.0 metadata 404)
MYPY_SPEC="mypy<1.19"
INSTALL_OK=0
python3 -m pip install -q "$MYPY_SPEC" && INSTALL_OK=1 || true
if [ "$INSTALL_OK" != "1" ]; then
echo "WARN: default pip index failed, retry tsinghua mirror..."
python3 -m pip install -q -i https://pypi.tuna.tsinghua.edu.cn/simple "$MYPY_SPEC" && INSTALL_OK=1 || true
fi
if [ "$INSTALL_OK" != "1" ]; then
echo "WARN: tsinghua mirror failed, retry pypi.org..."
python3 -m pip install -q -i https://pypi.org/simple "$MYPY_SPEC" && INSTALL_OK=1 || true
fi
if [ "$INSTALL_OK" != "1" ]; then
echo "ERROR: pip install mypy failed on all indexes"
exit 1
fi
python3 -m pip install -q mypy
mypy --version
echo ""
echo "=== Running mypy type check (hard gate mode) ==="
+2 -4
View File
@@ -108,10 +108,8 @@ server {
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
proxy_read_timeout 300s;
proxy_send_timeout 300s;
proxy_request_buffering off;
}
+2 -2
View File
@@ -400,7 +400,7 @@ class TestMarkFailedAndNotifySessionFallback:
job.mark_failed.assert_called()
# fallback路径:新session上repo.get(job-1)被调用,且新job被mark_failed并commit
fake_repo.get.assert_called_with("job-1")
fake_job_in_db.mark_failed.assert_called_with("boom", stage="render")
fake_job_in_db.mark_failed.assert_called_with("boom")
fake_repo.update.assert_called_with(fake_job_in_db)
fake_ssn.commit.assert_called()
fake_ssn.close.assert_called()
@@ -419,7 +419,7 @@ class TestMarkFailedAndNotifySessionFallback:
with patch.object(vv, "_emit_progress") as mock_emit:
# 不mock SessionLocal,如果fallback被错误触发会抛AttributeError
vv._mark_failed_and_notify("job-2", session, repo, job, "err", stage="copy")
job.mark_failed.assert_called_with("err", stage="copy")
job.mark_failed.assert_called_with("err")
mock_save.assert_called()
mock_emit.assert_called_once()
+1 -1
View File
@@ -144,7 +144,7 @@ class TestRetryViralVideo:
assert job.status == ViralVideoStatus.PENDING
assert job.retry_count == 2
mock_send.assert_called_once_with("worker.run_viral_video_analyze", args=["job-retry"])
mock_send.assert_called_once_with("worker.run_viral_video_pipeline", args=["job-retry"])
assert resp.id == "job-retry"
def test_retry_without_body_keeps_original_params(self):