fix(viral-video): #2134 generate-copy 提速到 30-40s + 细粒度 phase/phase_message #2136
@@ -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")
|
||||
@@ -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 "",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = ""
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:-}"
|
||||
# 已经在环境中了,无需额外操作
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
Reference in New Issue
Block a user