Merge pull request 'fix(viral-video): #2134 generate-copy 提速到 30-40s + 细粒度 phase/phase_message' (#2136) from fix/2134-generate-copy-speed-phase into develop
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
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 (push) Successful in 2s
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 2s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 4s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
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 / Validate - Style (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Validate - Security (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 17s
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
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 / PR Build API Image (pull_request) Successful in 1m24s
CI/CD Pipeline / Build Staging API Image (push) Successful in 49s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 1m23s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
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 / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 37s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 2m9s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m24s
CI/CD Pipeline / CI Gate (pull_request) Successful in 4s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 54s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m14s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 3m35s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m16s
CI/CD Pipeline / Integration Tests (push) Successful in 4m23s
CI/CD Pipeline / Validate - Style (push) Successful in 5m29s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 5m38s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 2m4s
AI Code Review / AI Code Review (pull_request) Successful in 7m4s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m41s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 4m20s
CI/CD Pipeline / Unit Tests (push) Successful in 11m52s
CI/CD Pipeline / Validate - Security (push) Successful in 13m8s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped

This commit was merged in pull request #2136.
This commit is contained in:
2026-10-02 11:41:45 +08:00
14 changed files with 228 additions and 63 deletions
+1
View File
@@ -212,6 +212,7 @@ COSYVOICE_CLONE_MODEL=voice-enrollment
DOUBAO_API_KEY=your-doubao-api-key
DOUBAO_MODEL=doubao-seed-1-6-250615
DOUBAO_FAST_MODEL=doubao-1-5-pro-32k-250115
DOUBAO_BASE_URL=https://ark.cn-beijing.volces.com/api/v3
DOUBAO_TIMEOUT=30
DOUBAO_MAX_RETRIES=2
@@ -0,0 +1,35 @@
"""viral video add phase_message column (#2134)
Revision ID: 090_viral_video_phase_msg
Revises: 089_viral_video_cols
Create Date: 2026-10-02
#2134 阶段细粒度提示:viral_video 表新增 phase_message 列(中文阶段提示文案)。
current_stage 列已在之前版本存在,本迁移只补 phase_message。
幂等 ADD COLUMN IF NOT EXISTS。
"""
import sqlalchemy as sa
from alembic import op
revision = "090_viral_video_phase_msg"
down_revision = "089_viral_video_cols"
branch_labels = None
depends_on = None
def upgrade() -> None:
# SQLite/PostgreSQL 兼容的幂等添加列
conn = op.get_bind()
inspector = sa.inspect(conn)
cols = {c["name"] for c in inspector.get_columns("viral_video_jobs")}
if "phase_message" not in cols:
op.add_column(
"viral_video_jobs",
sa.Column("phase_message", sa.String(length=500), nullable=False, server_default=""),
)
def downgrade() -> None:
op.drop_column("viral_video_jobs", "phase_message")
+2
View File
@@ -128,6 +128,8 @@ def _to_response(job) -> ViralVideoJobResponse:
style_guide=job.style_guide,
style_template_id=job.style_template_id,
status=job.status,
current_stage=getattr(job, "current_stage", "") or "",
phase_message=getattr(job, "phase_message", "") or "",
image_analysis=getattr(job, "image_analysis", None),
storyboard=getattr(job, "storyboard", None),
generated_copy_text=getattr(job, "generated_copy_text", "") or "",
+4
View File
@@ -201,6 +201,10 @@ class ViralVideoJobResponse(BaseModel):
style_guide: dict | None = None
style_template_id: str = ""
status: str
current_stage: str = (
"" # 细粒度阶段 snake_case(analyzing_images/parsing_intent/generating_script/reviewing/tts_synthesizing/rendering_video/uploading)
)
phase_message: str = "" # 中文阶段提示文案(前端轮询/SSE 直接展示)
image_analysis: dict | None = None
# v1.6 编导脚本(推荐前端使用)
copy_result: dict | None = None
+147 -56
View File
@@ -90,6 +90,22 @@ def _save_job(repo, job, session):
session.commit()
def _set_stage(job, repo, session, stage: str, message: str, persist: bool = True) -> None:
"""更新细粒度阶段并持久化到 DB,同时通过 Redis 推送进度事件。
stage 用 ViralVideoStage.value(snake_case,与前端 phase 对齐)。
message 为中文提示文案,前端轮询/SSE 直接展示给用户。
"""
job.current_stage = stage or ""
job.phase_message = message or ""
_emit_progress(job.id, stage, 0.0, message)
if persist and repo is not None and session is not None:
try:
_save_job(repo, job, session)
except Exception as e: # 阶段持久化失败不阻塞主流程
logger.warning("[爆款视频] 阶段持久化失败 stage=%s err=%s", stage, e)
# ── 默认结构 ─────────────────────────────────────────────────────────────
_DEFAULT_HARD_CONSTRAINTS = [
@@ -356,8 +372,11 @@ def _step_intent_parsing(job: ViralVideoJob, image_analysis: dict) -> dict:
"suggested_title": "视频主题标题(5-15字)"
}}"""
_s = get_shared_settings()
_fast = _s.doubao_fast_model
try:
result = call_llm(prompt)
# 用快模型提速(结构化输出任务,不需要推理模型)
result = call_llm(prompt, temperature=0.4, max_tokens=800, model=_fast)
return (
result
if isinstance(result, dict)
@@ -371,27 +390,20 @@ def _step_intent_parsing(job: ViralVideoJob, image_analysis: dict) -> dict:
# ── 编导分镜脚本生成(核心,v1.6 新 prompt) ──────────────────────────────
_SCRIPT_GENERATION_PROMPT = """你是一名资深短视频导演,擅长为 AI 视频生成模型(Seedance 2.5)撰写专业编导分镜脚本。
_SCRIPT_GENERATION_PROMPT = """你是资深短视频导演,为 Seedance 2.5(单次生成最多{duration}秒)写编导分镜脚本。脚本将整体作为 prompt 一次性传给视频模型,必须让模型在连贯镜头流中清楚每段时间拍什么、画面如何、人物说什么。
## 产品信息
## 产品
{products_summary}
## 营销参数
- 视频主题/意图:{intent}
- 主题/意图:{intent}
- 关键信息:{key_messages}
- 调性:{tone}
- 目标客户:{target_customer}
- 用户原始文案/卖点(必须融入口播):{user_copy}
- 视频时长:{duration} 秒(单次生成)
- 画幅比例:{ratio}
- 产品图片数量:{n_images} 张(将作为 reference_images 传给视频模型,第1张通常作为首帧/主产品图)
- 参考风格(可选):{style_hint}
- 爆款结构(用户指定,必须严格遵循):{viral_structure_block}
## 任务
请撰写**一段完整的编导分镜脚本**,包含视频总览、场景光线、逐镜头时间轴、硬性约束、负面提示词,以及自然口语化的口播对白。
这段脚本会**整个拼成一个长 prompt**一次性传给 Seedance 2.5(单次生成最多30秒视频),所以你的描述必须让模型在一个长镜头/连贯镜头流里理解每个时间段该拍什么、画面如何、人物说什么做什么。
- 用户原始卖点(必须融入口播):{user_copy}
- 时长:{duration}秒 / 画幅:{ratio} / 产品图:{n_images}张(第1张通常是主图/首帧)
- 风格参考:{style_hint}
- 爆款结构(必须严格遵循节奏/段落顺序):{viral_structure_block}
## 输出格式(必须输出严格 JSON,不要 Markdown,不要解释,字段一个都不能少)
@@ -432,14 +444,14 @@ _SCRIPT_GENERATION_PROMPT = """你是一名资深短视频导演,擅长为 AI
```
## 关键要求
1. **镜头感**:每镜必须写清景别(特写/近景/中景/全景)、角度(平视/俯拍/仰拍/45度侧拍)、运镜(推/拉/摇/移/跟/固定),不能笼统说"展示产品"。
2. **画面具体**:描述主体是谁(性别/年龄/穿着风格)、在什么场景、做什么动作、光线从哪来、镜头怎么动,让 AI 能画出来。
3. **对白自然**:像真人说话,不要"家人们谁懂啊""宝子们"这种浮夸腔,也不要"今天给大家推荐一款XX真的太好用了"这种硬广推销腔。要像朋友自然分享好物。
4. **参考图片分配**:reference_image_index 填 0-based 索引,产品特写镜头用产品图(索引0通常是主图),人像/场景镜头可留 null。
5. **时长控制**:所有 shots 的 time_range 加起来必须等于 {duration} 秒,单镜 2-8 秒。
6. **硬性约束和负面词必须包含**:不要删减,可根据产品类型追加。
7. **voiceover_script 必须是纯口播文本**:不含任何标记、括号、说明,字数按中文每秒 3-4 字估算({duration}秒约{approx_chars}字)。
8. **严格遵循爆款结构**:如果上方「爆款结构」字段不为「未指定」,必须严格按该结构的节奏/段落顺序编排文案与镜头,开场钩子、痛点、反转、案例、行动号召等节点要与结构对应,不要打乱顺序。
1. 每镜写清景别/角度/运镜(特写/近景/中景+平视/俯拍+推/拉/固定)。
2. 画面具体:主体(性别/年龄/穿着)、场景、动作、光线、镜头运动要可落地。
3. 对白自然口语化,像朋友分享好物;拒绝"家人们""宝子们""太好用了"等浮夸/硬广腔。
4. reference_image_index 填 0-based 索引(产品特写用索引0主图),人像/场景可 null。
5. shots time_range 累计={duration}秒,单镜2-8秒。
6. hard_constraints/negative_prompts 保留默认项可追加,不要删减。
7. voiceover_script 为纯口播文本(无标记/括号/前缀),{duration}秒约{approx_chars}字。
8. 严格按上方「爆款结构」的节奏/段落顺序编排(钩子/痛点/反转/案例/行动号召与结构对齐)。
"""
@@ -713,8 +725,11 @@ def _step_script_generation(job: ViralVideoJob, intent: dict, image_analysis: di
viral_structure_block=viral_structure_block,
)
_s = get_shared_settings()
_fast = _s.doubao_fast_model
try:
result = call_llm(prompt)
# 编导脚本是结构化 JSON 输出,用快模型提速(temperature 稍高保证创意)
result = call_llm(prompt, temperature=0.8, max_tokens=2500, model=_fast)
parsed = _safe_json_loads(result)
return _validate_and_normalize_script(parsed, job)
except Exception as e:
@@ -743,8 +758,11 @@ def _step_review(job: ViralVideoJob, copy_result: dict) -> dict:
- score: int(0-100分)
- details: 各维度评分和说明
- issues: 需要修改的问题列表(如有)"""
_s = get_shared_settings()
_fast = _s.doubao_fast_model
try:
result = call_llm(prompt)
# 合规审核用快模型 + 短输出(结构化判断)
result = call_llm(prompt, temperature=0.1, max_tokens=500, model=_fast)
return result if isinstance(result, dict) else {"passed": True, "score": 80, "details": {}}
except Exception as e:
logger.warning("[爆款视频] 合规审核失败: %s", e)
@@ -937,10 +955,8 @@ def run_viral_video_pipeline(self: Task, job_id: str) -> dict:
return {"ok": False, "error": "job not found"}
job.mark_running()
_save_job(repo, job, session)
_emit_progress(job_id, ViralVideoStage.IMAGE_ANALYSIS, 5.0, "开始图片分析")
_set_stage(job, repo, session, ViralVideoStage.IMAGE_ANALYSIS, "正在分析商品特征...")
_emit_progress(job_id, ViralVideoStage.IMAGE_ANALYSIS, 10.0, "正在分析产品图片...")
image_analysis = _step_image_analysis(job)
job.image_analysis = image_analysis
_save_job(repo, job, session)
@@ -948,16 +964,18 @@ def run_viral_video_pipeline(self: Task, job_id: str) -> dict:
style_guide = None
if job.reference_video_url or job.style_template_id:
_emit_progress(job_id, ViralVideoStage.VIDEO_ANALYSIS, 20.0, "正在分析参考视频风格...")
_set_stage(job, repo, session, ViralVideoStage.VIDEO_ANALYSIS, "正在分析参考视频风格...")
style_guide = _step_video_analysis(job)
job.style_guide = style_guide
_save_job(repo, job, session)
_emit_progress(job_id, ViralVideoStage.VIDEO_ANALYSIS, 25.0, "风格分析完成", {"style_guide": style_guide})
_emit_progress(job_id, ViralVideoStage.INTENT_PARSING, 30.0, "正在解析文案意图...")
_set_stage(job, repo, session, ViralVideoStage.INTENT_PARSING, "正在解析文案意图...")
intent_result = _step_intent_parsing(job, image_analysis)
job.mark_wait_user_confirm(intent_result)
job.current_stage = ViralVideoStage.INTENT_PARSING
job.phase_message = "意图解析完成,等待用户确认"
_save_job(repo, job, session)
_emit_progress(
job_id,
@@ -1070,10 +1088,8 @@ def run_viral_video_analyze(self: Task, job_id: str) -> dict:
return {"ok": False, "error": "job not found"}
job.mark_running()
_save_job(repo, job, session)
_emit_progress(job_id, ViralVideoStage.IMAGE_ANALYSIS, 5.0, "开始图片分析")
_set_stage(job, repo, session, ViralVideoStage.IMAGE_ANALYSIS, "正在分析商品特征...")
_emit_progress(job_id, ViralVideoStage.IMAGE_ANALYSIS, 10.0, "正在分析产品图片...")
image_analysis = _step_image_analysis(job)
job.image_analysis = image_analysis
_save_job(repo, job, session)
@@ -1081,7 +1097,7 @@ def run_viral_video_analyze(self: Task, job_id: str) -> dict:
style_guide = None
if job.reference_video_url or job.style_template_id:
_emit_progress(job_id, ViralVideoStage.VIDEO_ANALYSIS, 70.0, "正在分析参考视频风格...")
_set_stage(job, repo, session, ViralVideoStage.VIDEO_ANALYSIS, "正在分析参考视频风格...")
style_guide = _step_video_analysis(job)
job.style_guide = style_guide
_save_job(repo, job, session)
@@ -1094,6 +1110,8 @@ def run_viral_video_analyze(self: Task, job_id: str) -> dict:
)
job.mark_image_analyzed()
job.current_stage = ViralVideoStage.IMAGE_ANALYSIS
job.phase_message = "图片分析完成,请填写营销参数以生成编导脚本"
_save_job(repo, job, session)
_emit_progress(
job_id,
@@ -1118,7 +1136,14 @@ def run_viral_video_analyze(self: Task, job_id: str) -> dict:
@shared_task(bind=True, max_retries=1, name="worker.run_viral_video_generate_copy")
def run_viral_video_generate_copy(self: Task, job_id: str) -> dict:
"""v1.6 阶段2:意图解析 → 编导分镜脚本生成 → 合规审核,完成后状态=copy_generated。"""
"""v1.6 阶段2(v1.6.1 提速版):意图解析 → 编导分镜脚本生成 → 直接返回,合规审核后置到出片前。
优化点(#2134 问题7):
- 意图解析/编导脚本均使用快模型(doubao_fast_model,非推理模型),max_tokens 收紧
- _SCRIPT_GENERATION_PROMPT 精简冗余描述
- 合规审核改为异步后置:不阻塞前端,在 confirm-copy(阶段3 TTS前)再做最终审核
- 每个阶段通过 _set_stage 持久化 current_stage/phase_message 到 DB(问题8)
"""
session = None
try:
session, repo, job = _get_repo_and_job(job_id)
@@ -1127,40 +1152,43 @@ def run_viral_video_generate_copy(self: Task, job_id: str) -> dict:
if job.status != ViralVideoStatus.RUNNING:
return {"ok": False, "error": f"unexpected status: {job.status}"}
# 阶段:意图解析
_set_stage(job, repo, session, ViralVideoStage.INTENT_PARSING, "正在解析文案意图...")
image_analysis = job.image_analysis or {"products": []}
_emit_progress(job_id, ViralVideoStage.INTENT_PARSING, 20.0, "正在解析文案意图...")
intent_result = _step_intent_parsing(job, image_analysis)
job.intent_result = intent_result
_save_job(repo, job, session)
_emit_progress(job_id, ViralVideoStage.INTENT_PARSING, 35.0, "意图解析完成")
_emit_progress(job_id, ViralVideoStage.SCRIPT_GENERATION, 40.0, "正在生成编导分镜脚本...")
# 阶段:编导脚本生成(核心耗时环节,已用快模型)
_set_stage(job, repo, session, ViralVideoStage.SCRIPT_GENERATION, "正在编排分镜脚本...")
copy_result = _step_script_generation(job, intent_result, image_analysis)
_emit_progress(
job_id,
ViralVideoStage.SCRIPT_GENERATION,
60.0,
"编导脚本生成完成",
85.0,
"分镜脚本生成完成",
{"shots": len(copy_result.get("shots", []))},
)
_emit_progress(job_id, ViralVideoStage.REVIEW, 65.0, "正在进行合规审核...")
review_result = _step_review(job, copy_result)
if not review_result.get("passed", True):
_emit_progress(job_id, ViralVideoStage.REVIEW, 67.0, "审核未通过,正在自动重写...")
copy_result = _step_script_generation(job, intent_result, image_analysis)
_step_review(job, copy_result)
_emit_progress(job_id, ViralVideoStage.REVIEW, 70.0, "合规审核完成")
# 合规审核后置:不再阻塞前端返回;在阶段3(confirm-copy 出片前)_run_render_pipeline 里再做最终审核。
# 这里只做一个快速轻量检查(关键字黑名单),发现明显违规再触发重写;LLM 深度审核放到出片前。
_set_stage(job, repo, session, ViralVideoStage.REVIEW, "正在快速检查脚本合规性...")
voiceover = (copy_result or {}).get("voiceover_script", "") or ""
_quick_compliance_blacklist_check(copy_result)
_emit_progress(job_id, ViralVideoStage.REVIEW, 95.0, "脚本合规初检完成")
# 标记 copy_generated 并持久化
job.mark_copy_generated(copy_result)
job.current_stage = ViralVideoStage.REVIEW
job.phase_message = "分镜脚本已生成,请确认或编辑口播文案"
_save_job(repo, job, session)
voiceover = copy_result.get("voiceover_script", "")
_emit_progress(
job_id,
ViralVideoStage.REVIEW,
100.0,
"编导分镜脚本已生成,请确认或编辑口播文案",
"分镜脚本已生成,请确认或编辑口播文案",
{
"copy_result": copy_result,
"generated_copy_text": voiceover,
@@ -1195,39 +1223,102 @@ def run_viral_video_generate_copy(self: Task, job_id: str) -> dict:
session.close()
def _quick_compliance_blacklist_check(copy_result: dict) -> None:
"""阶段2快速黑名单检查:不调用 LLM,只扫描高风险关键词;命中则在 voiceover 中就地替换。
LLM 深度合规审核(_step_review)在阶段3 confirm-copy 出片前执行。
"""
if not isinstance(copy_result, dict):
return
voiceover = copy_result.get("voiceover_script", "") or ""
# 广告法绝对化用语黑名单(常见速查,远非完整,仅挡住最明显违规)
BLACKLIST = {
"最": "很",
"第一": "领先",
"国家级": "高品质",
"世界级": "高品质",
"顶级": "优质",
"极品": "优质",
"独家": "特色",
"绝无仅有": "少见",
"100%": "大幅",
"百分百": "大幅",
"永久": "长久",
"万能": "多用途",
"特效": "效果好",
"速效": "快速见效",
"根治": "改善",
"包治": "改善",
"药到病除": "缓解不适",
}
changed = False
for k, v in BLACKLIST.items():
if k in voiceover:
voiceover = voiceover.replace(k, v)
changed = True
if changed:
copy_result["voiceover_script"] = voiceover
# 同步 final_copy/suggested_copy(如果存在)
for k in ("final_copy", "suggested_copy"):
if isinstance(copy_result.get(k), str) and copy_result[k]:
for bk, bv in BLACKLIST.items():
copy_result[k] = copy_result[k].replace(bk, bv)
def _run_render_pipeline(job_id: str, session, repo, job) -> dict:
"""v1.6 阶段3 / 旧 resume 共用:TTS → 单次 Seedance → Upload → Completed。"""
"""v1.6.1 阶段3:出片前合规审核(LLM 深度)→ TTS → Seedance → Upload → Completed。
阶段2 generate-copy 已把 LLM 深度审核后置,这里在 TTS 前做最终审核(不通过则自动重写1次)。
所有阶段通过 _set_stage 持久化 current_stage/phase_message。
"""
image_analysis = job.image_analysis or {"products": []}
# 如果没有 copy_result(旧数据/失败重试),现场补生成
# 如果没有 copy_result(旧数据/失败重试),现场补生成(意图+脚本,不走 LLM 审核,出片前会统一做)
copy_result = job.copy_result
if not isinstance(copy_result, dict) or not copy_result:
_emit_progress(job_id, ViralVideoStage.SCRIPT_GENERATION, 40.0, "正在补生成编导脚本...")
_set_stage(job, repo, session, ViralVideoStage.SCRIPT_GENERATION, "正在补生成编导脚本...")
intent = job.intent_result or _step_intent_parsing(job, image_analysis)
copy_result = _step_script_generation(job, intent, image_analysis)
_step_review(job, copy_result)
job.mark_copy_generated(copy_result)
_save_job(repo, job, session)
# 出片前 LLM 深度合规审核(#2134 问题7:审核从阶段2后置到这里,不阻塞前端预览脚本)
_set_stage(job, repo, session, ViralVideoStage.REVIEW, "正在进行出片前合规审核...")
try:
review_result = _step_review(job, copy_result)
if not review_result.get("passed", True):
_emit_progress(job_id, ViralVideoStage.REVIEW, 67.0, "审核未通过,正在自动重写...")
intent = job.intent_result or _step_intent_parsing(job, image_analysis)
copy_result = _step_script_generation(job, intent, image_analysis)
_step_review(job, copy_result) # 二次审核,不通过也继续出片(避免反复循环)
job.copy_result = copy_result
job.generated_copy_text = copy_result.get("voiceover_script", "") or ""
_save_job(repo, job, session)
except Exception as e:
logger.warning("[爆款视频][阶段3] 合规审核异常,继续出片: %s", e)
_emit_progress(job_id, ViralVideoStage.REVIEW, 70.0, "合规审核完成")
voiceover = copy_result.get("voiceover_script", "") or job.effective_copy_text
# Step 5: TTS 整段合成
_emit_progress(job_id, ViralVideoStage.TTS, 72.0, "正在生成AI配音...")
_set_stage(job, repo, session, ViralVideoStage.TTS, "正在合成AI配音...")
tts_path = _step_tts(job, voiceover)
tts_url = _upload_tts_to_oss(job, tts_path)
_emit_progress(job_id, ViralVideoStage.TTS, 78.0, "配音完成", {"has_tts": tts_url is not None})
# Step 6: 单次 Seedance
_emit_progress(job_id, ViralVideoStage.RENDERING, 80.0, "正在调用AI生成视频(约1-3分钟)...")
_set_stage(job, repo, session, ViralVideoStage.RENDERING, "正在生成视频(约1-3分钟)...")
video_path = _step_render(job, copy_result, tts_url)
_emit_progress(job_id, ViralVideoStage.RENDERING, 92.0, "视频生成完成")
# Step 7: Upload
_emit_progress(job_id, ViralVideoStage.UPLOADING, 95.0, "正在上传视频...")
_set_stage(job, repo, session, ViralVideoStage.UPLOADING, "正在上传视频...")
video_url = _step_upload(job, video_path)
job.credits_cost = CREDITS_VIRAL_VIDEO_COST
job.mark_completed(video_url)
job.current_stage = ViralVideoStage.UPLOADING
job.phase_message = "视频生成完成"
_save_job(repo, job, session)
_emit_progress(job_id, ViralVideoStage.UPLOADING, 100.0, "视频生成完成!", {"video_url": video_url})
_emit_progress(
@@ -950,6 +950,8 @@ class ViralVideoJobModel(Base):
video_model = Column(String(100), nullable=False, default="")
# 结果与状态
status = Column(String(30), nullable=False, default="pending", index=True)
current_stage = Column(String(200), nullable=False, default="") # 细粒度阶段 snake_case
phase_message = Column(String(500), nullable=False, default="") # 阶段中文提示文案
intent_result = Column(JSON, nullable=True)
image_analysis = Column(JSON, nullable=True)
storyboard = Column(JSON, nullable=True)
@@ -37,6 +37,8 @@ def _to_domain(model: ViralVideoJobModel) -> ViralVideoJob:
video_ratio=getattr(model, "video_ratio", "9:16") or "9:16",
video_model=getattr(model, "video_model", "") or "",
status=ViralVideoStatus(model.status) if model.status else ViralVideoStatus.PENDING,
current_stage=getattr(model, "current_stage", "") or "",
phase_message=getattr(model, "phase_message", "") or "",
intent_result=dict(model.intent_result) if model.intent_result else None,
image_analysis=dict(model.image_analysis) if getattr(model, "image_analysis", None) else None,
storyboard=list(model.storyboard) if getattr(model, "storyboard", None) else None,
@@ -83,6 +85,8 @@ class SQLAlchemyViralVideoJobRepository:
video_ratio=job.video_ratio,
video_model=job.video_model,
status=job.status,
current_stage=job.current_stage or "",
phase_message=job.phase_message or "",
intent_result=job.intent_result,
image_analysis=job.image_analysis,
storyboard=job.storyboard,
@@ -106,6 +110,8 @@ class SQLAlchemyViralVideoJobRepository:
if model is None:
raise ValueError(f"ViralVideoJob {job.id} not found")
model.status = job.status
model.current_stage = job.current_stage or ""
model.phase_message = job.phase_message or ""
model.intent_result = job.intent_result
model.image_analysis = job.image_analysis
model.storyboard = job.storyboard
+2 -1
View File
@@ -90,7 +90,8 @@ class SharedSettings(BaseSettings):
# ── 豆包大模型(火山引擎方舟) ────────────────────────────────────────
doubao_api_key: str = ""
doubao_model: str = "doubao-seed-1-6-250615"
doubao_model: str = "doubao-seed-1-6-250615" # 推理模型(通用兜底)
doubao_fast_model: str = "doubao-1-5-pro-32k-250115" # 快速结构化输出模型(编导脚本/意图解析/审核)
doubao_base_url: str = "https://ark.cn-beijing.volces.com/api/v3"
doubao_timeout: int = 30
doubao_max_retries: int = 2
+2
View File
@@ -117,6 +117,8 @@ class ViralVideoJob:
# 状态
id: str = field(default_factory=lambda: uuid4().hex)
status: ViralVideoStatus = ViralVideoStatus.PENDING
current_stage: str = "" # 细粒度阶段(ViralVideoStage.value,snake_case)
phase_message: str = "" # 阶段中文提示文案,前端轮询直接展示
result_video_url: str = ""
credits_cost: int = 0
error_msg: str = ""
+3 -1
View File
@@ -41,6 +41,7 @@ class DoubaoClient:
self.max_retries: int = settings.doubao_max_retries
self.vision_model: str = settings.doubao_vision_model
self.vision_lite_model: str = settings.doubao_vision_lite_model
self.fast_model: str = settings.doubao_fast_model
def embed_text(self, text: str, timeout: int | None = None) -> list[float] | None:
"""调用豆包文本 Embedding API,返回浮点向量;失败返回 None。"""
@@ -93,6 +94,7 @@ class DoubaoClient:
messages: list[dict[str, str]],
temperature: float = 0.7,
max_tokens: int = 1024,
model: str | None = None,
) -> Optional[str]:
"""调用 Chat Completion 接口.
@@ -113,7 +115,7 @@ class DoubaoClient:
"Content-Type": "application/json",
}
payload: dict[str, Any] = {
"model": self.model,
"model": model or self.model,
"messages": messages,
"temperature": temperature,
"max_tokens": max_tokens,
+20 -4
View File
@@ -496,16 +496,32 @@ def run_generate_cover(
# ── 通用 LLM / Vision 调用(#2039 ViralVideoOrchestrator 使用,复用现有豆包客户端)──
def call_llm(prompt: str, temperature: float = 0.7) -> object:
"""调用豆包大模型(文本对话),返回解析后的 JSON(dict/list)或原文字符串;失败返回 None。"""
def call_llm(
prompt: str,
temperature: float = 0.7,
max_tokens: int = 2048,
model: str | None = None,
system_prompt: str | None = None,
) -> object:
"""调用豆包大模型(文本对话),返回解析后的 JSON(dict/list)或原文字符串;失败返回 None。
Args:
prompt: 用户侧提示。
temperature: 采样温度。
max_tokens: 输出上限(结构化任务默认 2048,长文案可按需加大)。
model: 覆盖默认模型(如 fast_model 提速用),None 走配置默认推理模型。
system_prompt: 覆盖默认 system prompt。
"""
client = get_doubao_client()
if not client.is_available:
return None
if system_prompt is None:
system_prompt = "你是专业的短视频内容策划助手。需要结构化输出时请严格使用 JSON。"
messages = [
{"role": "system", "content": "你是专业的短视频内容策划助手。需要结构化输出时请严格使用 JSON。"},
{"role": "system", "content": system_prompt},
{"role": "user", "content": prompt},
]
raw = client.chat_completion(messages, temperature=temperature, max_tokens=4096)
raw = client.chat_completion(messages, temperature=temperature, max_tokens=max_tokens, model=model)
if raw is None:
return None
try:
+1 -1
View File
@@ -57,7 +57,7 @@ if [ "$TARGET_ENV" = "staging" ]; then
fi
# 共用 secrets 直接导出(如果存在)
SHARED_SECRETS="OSS_ACCESS_KEY_ID OSS_ACCESS_KEY_SECRET COSYVOICE_API_KEY DASHSCOPE_API_KEY MEDIAKIT_API_KEY DOUBAO_API_KEY DOUBAO_MODEL DOUBAO_BASE_URL DOUBAO_VISION_MODEL DOUBAO_VISION_LITE_MODEL DOUBAO_VISION_USE_LITE WECHAT_APP_ID WECHAT_APP_SECRET TIKHUB_API_KEY APIZERO_API_KEY GPU_WORKER_TOKEN"
SHARED_SECRETS="OSS_ACCESS_KEY_ID OSS_ACCESS_KEY_SECRET COSYVOICE_API_KEY DASHSCOPE_API_KEY MEDIAKIT_API_KEY DOUBAO_API_KEY DOUBAO_MODEL DOUBAO_FAST_MODEL DOUBAO_BASE_URL DOUBAO_VISION_MODEL DOUBAO_VISION_LITE_MODEL DOUBAO_VISION_USE_LITE WECHAT_APP_ID WECHAT_APP_SECRET TIKHUB_API_KEY APIZERO_API_KEY GPU_WORKER_TOKEN"
for var in $SHARED_SECRETS; do
value="${!var:-}"
# 已经在环境中了,无需额外操作
+1
View File
@@ -14,6 +14,7 @@ from packages.shared.ai_client import DoubaoClient
class _FakeSettings:
doubao_api_key = "test-key"
doubao_model = "test-model"
doubao_fast_model = "test-fast-model"
doubao_base_url = "https://ark.cn-beijing.volces.com/api/v3"
doubao_timeout = 10
doubao_max_retries = 0
+2
View File
@@ -61,6 +61,8 @@ def _make_job(job_id: str = "job-1", user_id: str = "u1", status: str = "pending
"video_ratio": "9:16",
"video_model": "",
"credits_cost": 0,
"current_stage": "",
"phase_message": "",
"updated_at": None,
"is_terminal": False,
"effective_copy_text": "",