Files
xiaoxia-saas/apps/worker/worker_app/tasks/viral_video.py
T
xiaoxia 649420bd35
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 1s
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (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 / 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 / Deploy Staging (Watchtower auto-deploy) (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 / PR Build API Image (pull_request) Successful in 1m12s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 1m21s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 1m29s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 2m0s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Successful in 2m10s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m18s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m13s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 3m42s
AI Code Review / AI Code Review (pull_request) Successful in 6m41s
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Style (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Security (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been cancelled
CI/CD Pipeline / Unit Tests (pull_request) Has been cancelled
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been cancelled
feat(viral-video): v1.6 单次Seedance出片+编导分镜脚本 #2124
- 输出从营销口播文案改为专业编导分镜脚本(CopyResult v1.6:overview/scene_and_lighting/shots/hard_constraints/negative_prompts/voiceover_script)
- 新 SCRIPT_GENERATION 阶段替代原 copy_fusion+storyboard+review,prompt 指导 LLM 输出严格 JSON 结构
- pipeline 简化为4步:图片分析 → 编导脚本 → TTS整段合成(上传OSS做reference_audios) → 单次Seedance出片 → 上传
- 删除:多段分镜拆分、ffmpeg concat拼接、placeholder占位视频、分段重试降级、BGM单独混音(Seedance generate_audio=true原生合成音效/BGM)
- ai_client/ai_service video_generation 支持 reference_images/reference_audios/reference_videos/generate_audio 参数
- duration 默认15秒,上限30秒;前端时长下拉 5/10/15/20/25/30s
- 首帧图模式不传 ratio(保持 #2110 修复)
- Alembic migration 088 幂等添加 voice_id/voice_source/video_ratio/video_model/copy_result 五列
- _step_image_analysis call_vision() 全路径 None 防护
- 向后兼容:final_copy=voiceover_script、storyboard=shots、老数据 _build_copy_result 降级拼装
- TS types 更新 CopyResult v1.6 结构 + ShotScript/Overview
- 96个 viral 相关单测全通过
2026-10-01 13:55:31 +08:00

1160 lines
50 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""爆款视频 Celery 编排器 — ViralVideoOrchestrator (v1.6 单次 Seedance 出片版).
v1.6 重大简化(Seedance 2.5 单次最长 30 秒,直接出片):
1. _step_image_analysis 图片 VLM 分析(保留)
1.5 _step_video_analysis 参考视频风格分析(可选)
2. _step_intent_parsing 用户文案意图解析
3. _step_script_generation 编导分镜脚本生成(融合原 copy_fusion+storyboard+review,输出 copy_result 结构 + voiceover_script)
4. _step_review 合规审核(6 维度,不通过自动重写 1 次)
5. _step_tts CosyVoice 整段配音(voiceover_script → 单个 mp3 → 上传 OSS 拿公网 URL)
6. _step_render 单次 Seedance 生成(prompt=完整编导脚本,reference_audios=[TTS URL],reference_images=产品图,generate_audio=true)
7. _step_upload OSS 上传单个视频文件 + 通知 + 扣点
删除/不再使用:
- 分镜拆分多段生成(storyboard 不再单独驱动分段生成,仅作为 copy_result.shots 存到 DB 给前端/日志参考)
- ffmpeg concat 拼接(concat_engine 保留但 viral video 主流程不再调用)
- placeholder 占位视频、分段重试降级
- BGM 单独混音(Seedance generate_audio=true 原生生成环境音效/BGM)
"""
from __future__ import annotations
import json
import logging
import os
import tempfile
from pathlib import Path
from celery import Task, shared_task
from celery.exceptions import Retry
from worker_app.celery_app import celery_app # noqa: F401
from worker_app.db import SessionLocal
from packages.adapters.sqlalchemy_impl.viral_video_repository import (
SQLAlchemyViralVideoJobRepository,
)
from packages.domain.viral_video import (
CREDITS_VIRAL_VIDEO_COST,
STAGE_LABELS,
ViralVideoJob,
ViralVideoStage,
ViralVideoStatus,
)
logger = logging.getLogger(__name__)
# ── WS 进度推送 ──────────────────────────────────────────────────────────
def _emit_progress(
job_id: str,
stage: str,
progress: float,
message: str = "",
data: dict | None = None,
event_type: str = "viral_video:progress",
):
try:
import redis as redis_lib
redis_url = os.environ.get("REDIS_URL", "redis://localhost:6379/0")
r = redis_lib.from_url(redis_url)
event = {
"type": event_type,
"job_id": job_id,
"stage": stage,
"progress": progress,
"message": message or STAGE_LABELS.get(stage, stage),
"data": data or {},
}
r.publish(f"viral_video:{job_id}", json.dumps(event, ensure_ascii=False))
except Exception as e:
logger.warning("[爆款视频] WS 进度推送失败: %s", e)
# ── 仓储辅助 ────────────────────────────────────────────────────────────
def _get_repo_and_job(job_id: str):
session = SessionLocal()
repo = SQLAlchemyViralVideoJobRepository(session)
job = repo.get(job_id)
return session, repo, job
def _save_job(repo, job, session):
repo.update(job)
session.commit()
# ── 默认结构 ─────────────────────────────────────────────────────────────
_DEFAULT_HARD_CONSTRAINTS = [
"无字幕、无水印、无任何自动生成文字、无 logo",
"同一人物全程保持一致的五官、发型、服装、身材,不得换脸或变形",
"口播语音必须在指定时长内自然念完,语速自然,口型与语音同步",
"画面流畅无闪烁、无多余肢体、无扭曲变形、无穿模",
"色彩自然、曝光正确、电影级质感、高清细节",
]
_DEFAULT_NEGATIVE_PROMPTS = [
"字幕",
"自动字幕",
"水印",
"logo",
"图标",
"错误文字",
"乱码文字",
"男女声错配",
"中途换声",
"五官崩坏",
"脸部变形",
"多余手指",
"肢体扭曲",
"闪烁",
"画面抖动",
"模糊",
"低分辨率",
]
def _empty_copy_result(duration: int = 15, ratio: str = "9:16") -> dict:
return {
"overview": {"theme": "好物推荐", "total_duration": duration, "aspect_ratio": ratio},
"scene_and_lighting": "简洁明亮的室内场景,柔和自然光,产品主体清晰",
"shots": [],
"hard_constraints": list(_DEFAULT_HARD_CONSTRAINTS),
"negative_prompts": list(_DEFAULT_NEGATIVE_PROMPTS),
"voiceover_script": "",
"final_copy": "",
"suggested_copy": "",
"title": "",
}
# ── 流水线各步骤 ────────────────────────────────────────────────────────
_IMAGE_ANALYSIS_PROMPT = """请仔细观察这张图片,只基于图片中真实可见的内容进行分析,不要凭空想象。
必须输出严格的 JSON(不要 Markdown 代码块,不要额外解释),字段如下:
{
"category": "产品大类,如护肤品/彩妆/食品/数码/服饰/家居等,若无法识别填『无法判断』",
"name": "产品名称(从包装/品牌/logo/文字推断;没有品牌时描述外观如『粉色包装面霜』)",
"brand": "品牌名(看 logo/包装文字;看不清填『未知』)",
"colors": ["主体颜色"],
"material_or_texture": "材质/质地描述(如玻璃瓶装/塑料软管/哑光质感/金属外壳等;无法判断填『无法判断』)",
"key_features": [
"3-5 条**图片中确实能看到**的外观特征/卖点描述(如『按压式泵头』『瓶身有金色装饰线』等),不要编图片里没有的功效"
],
"visual_style": "视觉风格(如简约高端/粉嫩少女/国潮/科技感/生活方式实拍等)",
"scene": "图片中的使用/展示场景(如白底棚拍/浴室场景/户外街拍/桌面静物等;纯白底填『白底产品图』)",
"target_audience_hint": "从视觉推断的目标人群(如年轻女性/男性商务/亲子家庭等;不确定填『通用』)",
"text_on_image": "图片上出现的可读文字(品牌名/Slogan/产品名等,没有则填『无』)"
}
严格要求:
1. 任何字段无法确认时填『无法判断』或『未知』,不要猜。
2. key_features 只能描述图片里肉眼可见的物理外观,不要写『补水保湿』『抗衰老』这类功效词(除非包装上明确印了)。
3. 如果图片完全不是产品图(比如风景/人像/截图),category 填『非产品图』,name 填实际看到的内容。
"""
def _vision_fallback(idx: int, reason: str, extra: dict | None = None) -> dict:
d = {
"name": "未识别",
"category": "无法判断",
"key_features": [],
"scene": "通用",
"_source": reason,
}
if extra:
d.update(extra)
return d
def _step_image_analysis(job: ViralVideoJob) -> dict:
"""步骤 1: 图片 VLM 分析 — 识别产品特征、场景、卖点(加 None 防护)。"""
try:
from packages.shared.ai_service import call_vision
except ImportError:
logger.warning("[爆款视频] ai_service.call_vision 不可用,使用占位结果")
return {"products": [_vision_fallback(0, "fallback_import_error")]}
if not job.images:
logger.warning("[爆款视频] 任务无 images,跳过图片分析")
return {"products": []}
results = []
for idx, img_url in enumerate(job.images):
if not img_url or not isinstance(img_url, str):
logger.warning("[爆款视频] 图片 #%d URL 非法", idx)
results.append(_vision_fallback(idx, "invalid_url"))
continue
logger.info("[爆款视频] 图片分析 #%d img=%s", idx, img_url[:160])
try:
result = call_vision(image_url=img_url, prompt=_IMAGE_ANALYSIS_PROMPT)
if result is None:
logger.warning("[爆款视频] 图片 #%d call_vision 返回 None", idx)
results.append(_vision_fallback(idx, "vision_none"))
elif isinstance(result, str):
# VLM 返回了非 JSON 文本(JSON 解析失败),记录原始文本但不要让 None 传播
logger.warning("[爆款视频] 图片 #%d VLM 返回非 JSON 文本: %s", idx, result[:200])
results.append(_vision_fallback(idx, "vision_text", {"_raw": result[:500]}))
elif isinstance(result, dict):
result.setdefault("_source", "vision")
# 防御:关键字段缺失则补默认
result.setdefault("name", "未识别")
result.setdefault("category", "无法判断")
result.setdefault("key_features", [])
result.setdefault("scene", "通用")
results.append(result)
else:
logger.warning("[爆款视频] 图片 #%d VLM 返回意外类型 %s", idx, type(result))
results.append(_vision_fallback(idx, "vision_unexpected_type"))
except Exception as e:
logger.warning("[爆款视频] 图片分析失败 img=%s err=%s", img_url[:120], e, exc_info=True)
results.append(_vision_fallback(idx, "vision_exception", {"_error": str(e)[:200]}))
return {"products": results}
def _step_video_analysis(job: ViralVideoJob) -> dict | None:
"""步骤 1.5: 参考视频风格分析(可选)。"""
if not job.reference_video_url:
return None
try:
from viral_video.video_analyzer import analyze_video_style
style_guide = analyze_video_style(job.reference_video_url)
return style_guide if isinstance(style_guide, dict) else None
except ImportError as e:
logger.info("[爆款视频] video_analyzer 模块未就绪(%s),使用占位风格分析", e)
return {
"cut_speed": "medium",
"transition": "cross_dissolve",
"energy": "medium",
"color_grade": "neutral",
"narrative": False,
"source": "placeholder",
}
except Exception as e:
logger.error("[爆款视频] 视频风格分析失败: %s", e)
return {"error": str(e), "source": "failed"}
def _step_intent_parsing(job: ViralVideoJob, image_analysis: dict) -> dict:
"""步骤 2: 用户文案意图解析。"""
try:
from packages.shared.ai_service import call_llm
except ImportError:
return {"intent": "推广产品", "key_messages": ["产品亮点"], "tone": "专业", "suggested_title": ""}
products_summary = ""
products = (image_analysis or {}).get("products", []) or []
for p in products:
if not isinstance(p, dict):
continue
feats = p.get("key_features") or p.get("features") or []
extras = []
if p.get("brand") and p.get("brand") not in ("未知", "无法判断"):
extras.append(f"品牌={p['brand']}")
if p.get("category") and p.get("category") not in ("无法判断", "非产品图"):
extras.append(f"品类={p['category']}")
if p.get("colors"):
extras.append(f"颜色={','.join(p['colors'])}")
if p.get("visual_style"):
extras.append(f"风格={p['visual_style']}")
feat_str = ", ".join([str(x) for x in feats + extras])
products_summary += f"- {p.get('name', '产品')}: {feat_str}\n"
prompt = f"""你是一个营销编导。请分析以下信息,理解用户的营销意图并给出短视频主题建议:
用户原始文案:{job.user_copy_text or "(未提供,全由 AI 创作)"}
行业:{job.industry or "未指定"}
目标客户:{job.target_customer or "未指定"}
营销目的:{job.marketing_purpose or "未指定"}
视频时长:{job.duration}秒
产品信息:
{products_summary or "- (无图片分析结果)"}
请返回严格 JSON(不要 Markdown,不要解释):
{{
"intent": "核心营销意图(一句话)",
"key_messages": ["要传达的3-5个关键信息"],
"tone": "文案调性(如亲切/专业/高端/活力/治愈/搞笑)",
"target_emotion": "希望触发的用户情感",
"call_to_action": "行动号召短句(口语化,5-10字)",
"suggested_title": "视频主题标题(5-15字)"
}}"""
try:
result = call_llm(prompt)
return (
result
if isinstance(result, dict)
else {"intent": str(result)[:200], "key_messages": [], "tone": "专业", "suggested_title": ""}
)
except Exception as e:
logger.warning("[爆款视频] 意图解析失败: %s", e)
return {"intent": "推广产品", "key_messages": ["产品亮点"], "tone": "专业", "suggested_title": ""}
# ── 编导分镜脚本生成(核心,v1.6 新 prompt) ──────────────────────────────
_SCRIPT_GENERATION_PROMPT = """你是一名资深短视频导演,擅长为 AI 视频生成模型(Seedance 2.5)撰写专业编导分镜脚本。
## 产品信息
{products_summary}
## 营销参数
- 视频主题/意图:{intent}
- 关键信息:{key_messages}
- 调性:{tone}
- 目标客户:{target_customer}
- 用户原始文案/卖点(必须融入口播):{user_copy}
- 视频时长:{duration} 秒(单次生成)
- 画幅比例:{ratio}
- 产品图片数量:{n_images} 张(将作为 reference_images 传给视频模型,第1张通常作为首帧/主产品图)
- 参考风格(可选):{style_hint}
## 任务
请撰写**一段完整的编导分镜脚本**,包含视频总览、场景光线、逐镜头时间轴、硬性约束、负面提示词,以及自然口语化的口播对白。
这段脚本会**整个拼成一个长 prompt**一次性传给 Seedance 2.5(单次生成最多30秒视频),所以你的描述必须让模型在一个长镜头/连贯镜头流里理解每个时间段该拍什么、画面如何、人物说什么做什么。
## 输出格式(必须输出严格 JSON,不要 Markdown,不要解释,字段一个都不能少)
```json
{{
"overview": {{
"theme": "视频主题(一句话概括)",
"total_duration": {duration},
"aspect_ratio": "{ratio}"
}},
"scene_and_lighting": "整体场景描述+光线设定(100-200字,要具体:在哪拍、什么光线、什么色调、什么氛围)",
"shots": [
{{
"time_range": "0-3秒",
"shot_type_angle_movement": "景别+角度+运镜(例:近景俯拍45度,缓慢推镜;中景平视,固定镜头;特写平视,快速拉镜)",
"scene_and_dialogue": "画面场景描述 + 人物口播台词(对白要自然口语化,像朋友聊天,不要硬广推销腔)",
"action_details": "人物动作、表情、物品操作细节(手怎么动、表情变化、产品怎么展示)",
"audio_bgm": "环境音+BGM提示(例:轻快流行BGM,环境嘈杂咖啡店背景音)",
"transition": "硬切/淡入淡出/叠化(最后一镜写『结束』即可)",
"reference_image_index": 0
}}
// ... 按时间顺序列出所有镜头,总时长累计 = {duration} 秒
],
"hard_constraints": [
"无字幕、无水印、无任何自动生成文字、无logo",
"同一人物全程五官、发型、服装、身材保持一致,不得换脸变形",
"口播语音在总时长内自然念完,语速自然,口型与语音严格同步",
"画面流畅无闪烁、无多余肢体、无扭曲变形、无穿模",
"色彩自然、曝光正确、电影级质感、高清细节"
],
"negative_prompts": [
"字幕","自动字幕","水印","logo","图标","错误文字","乱码文字",
"男女声错配","中途换声","五官崩坏","脸部变形","多余手指",
"肢体扭曲","闪烁","画面抖动","模糊","低分辨率"
],
"voiceover_script": "完整口播稿(把 shots 里所有对白自然拼接成一段,口语化,不加旁白标注、不加镜头标注、不加'主播:'之类前缀,就是纯念出来的文本,长度适配{duration}秒,约{approx_chars}字)"
}}
```
## 关键要求
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}字)。
"""
def _build_products_summary(image_analysis: dict) -> str:
products = (image_analysis or {}).get("products", []) or []
if not products:
return "- (无图片信息,请自由创作自然生活化场景)"
lines = []
for i, p in enumerate(products):
if not isinstance(p, dict):
continue
name = p.get("name") or "产品"
brand = p.get("brand") or ""
cat = p.get("category") or ""
colors = p.get("colors") or []
mat = p.get("material_or_texture") or ""
style = p.get("visual_style") or ""
scene = p.get("scene") or ""
audience = p.get("target_audience_hint") or ""
text_on_img = p.get("text_on_image") or ""
feats = p.get("key_features") or p.get("features") or []
parts = [f"图{i+1} {name}"]
if brand and brand not in ("未知", "无法判断"):
parts.append(f"品牌={brand}")
if cat and cat not in ("无法判断", "非产品图"):
parts.append(f"品类={cat}")
if colors:
parts.append(f"颜色={','.join(colors)}")
if mat and mat not in ("无法判断",):
parts.append(f"材质={mat}")
if style:
parts.append(f"风格={style}")
if scene and scene not in ("通用",):
parts.append(f"场景={scene}")
if audience and audience != "通用":
parts.append(f"目标人群={audience}")
if text_on_img and text_on_img not in ("无",):
parts.append(f"图片文字={text_on_img}")
if feats:
parts.append("外观特征=" + ";".join([str(x) for x in feats[:6]]))
lines.append("- " + ",".join(parts))
return "\n".join(lines)
def _safe_json_loads(raw: str | dict | list | None):
if raw is None:
return None
if isinstance(raw, (dict, list)):
return raw
if not isinstance(raw, str):
return None
s = raw.strip()
if s.startswith("```"):
s = s.strip("`")
if s.startswith("json"):
s = s[4:].lstrip()
try:
return json.loads(s)
except Exception:
# 尝试截取第一个 { ... } 或 [ ... ]
try:
for open_c, close_c in (("{", "}"), ("[", "]")):
i = s.find(open_c)
j = s.rfind(close_c)
if i >= 0 and j > i:
return json.loads(s[i : j + 1])
except Exception:
pass
return None
def _fallback_script(job: ViralVideoJob) -> dict:
"""脚本生成失败时的兜底脚本(极简但可用)。"""
dur = max(5, min(30, int(getattr(job, "duration", 15) or 15)))
ratio = getattr(job, "video_ratio", None) or "9:16"
base = _empty_copy_result(dur, ratio)
voiceover = job.user_copy_text or "你好,给大家分享一款我最近在用的好物,真的很不错,推荐你们也试试。"
shots = [
{
"time_range": f"0-{dur}秒",
"shot_type_angle_movement": "中景平视,缓慢推镜",
"scene_and_dialogue": "明亮室内,人物自然出镜,微笑着看向镜头。" + voiceover,
"action_details": "人物手持产品自然展示,表情亲切,动作流畅",
"audio_bgm": "轻快流行BGM",
"transition": "结束",
"reference_image_index": 0 if job.images else None,
}
]
base["shots"] = shots
base["voiceover_script"] = voiceover
base["final_copy"] = voiceover
base["suggested_copy"] = voiceover
base["title"] = "好物分享"
return base
def _validate_and_normalize_script(raw, job: ViralVideoJob) -> dict:
"""把 LLM 返回的脚本规范化、补默认、校验结构。"""
dur = max(5, min(30, int(getattr(job, "duration", 15) or 15)))
ratio = getattr(job, "video_ratio", None) or "9:16"
base = _empty_copy_result(dur, ratio)
if not isinstance(raw, dict):
logger.warning("[爆款视频] 脚本返回非 dict,使用兜底")
return _fallback_script(job)
# overview
ov = raw.get("overview")
if isinstance(ov, dict):
base["overview"] = {
"theme": str(ov.get("theme") or "好物分享"),
"total_duration": int(ov.get("total_duration") or dur),
"aspect_ratio": str(ov.get("aspect_ratio") or ratio),
}
else:
base["overview"]["theme"] = str(raw.get("title") or "好物分享")
base["scene_and_lighting"] = str(raw.get("scene_and_lighting") or base["scene_and_lighting"])
# shots
shots_raw = raw.get("shots")
shots: list[dict] = []
if isinstance(shots_raw, list):
for i, s in enumerate(shots_raw):
if not isinstance(s, dict):
continue
shots.append(
{
"time_range": str(s.get("time_range") or f"{i*3}-{(i+1)*3}秒"),
"shot_type_angle_movement": str(s.get("shot_type_angle_movement") or "中景平视,固定镜头"),
"scene_and_dialogue": str(s.get("scene_and_dialogue") or ""),
"action_details": str(s.get("action_details") or ""),
"audio_bgm": str(s.get("audio_bgm") or "轻快BGM"),
"transition": str(s.get("transition") or ("硬切" if i < len(shots_raw) - 1 else "结束")),
"reference_image_index": s.get("reference_image_index"),
}
)
if not shots:
shots = [
{
"time_range": f"0-{dur}秒",
"shot_type_angle_movement": "中景平视,缓慢推镜",
"scene_and_dialogue": "明亮室内场景,人物自然出镜。",
"action_details": "自然展示产品",
"audio_bgm": "轻快BGM",
"transition": "结束",
"reference_image_index": 0 if job.images else None,
}
]
base["shots"] = shots
# hard_constraints / negative_prompts
hc = raw.get("hard_constraints")
if isinstance(hc, list) and hc:
merged = list(_DEFAULT_HARD_CONSTRAINTS)
for x in hc:
if isinstance(x, str) and x and x not in merged:
merged.append(x)
base["hard_constraints"] = merged
np = raw.get("negative_prompts")
if isinstance(np, list) and np:
merged = list(_DEFAULT_NEGATIVE_PROMPTS)
for x in np:
if isinstance(x, str) and x and x not in merged:
merged.append(x)
base["negative_prompts"] = merged
# voiceover_script: 优先从字段取,否则从各镜 scene_and_dialogue 提取(粗暴拼接冒号后部分 / 中文句)
voiceover = str(raw.get("voiceover_script") or "").strip()
if not voiceover:
# 兜底:把所有 scene_and_dialogue 拼接起来,去除镜头描述部分(含"景"、"俯拍"、"平视"等词的前缀)
import re
parts = []
for s in shots:
txt = s.get("scene_and_dialogue", "")
# 去除开头到第一个句号/逗号前的"镜头描述"部分
# 简单策略:找第一个中文说话片段——按句号切,后半段更像对白
segs = re.split(r"[。!?]", txt)
for seg in segs:
seg = seg.strip(" ,,。.!?!?::")
if len(seg) >= 4 and not any(
k in seg for k in ("景别", "俯拍", "仰拍", "平视", "镜头", "特写", "中景", "全景", "近景", "运镜")
):
parts.append(seg)
voiceover = "。".join(parts) if parts else (job.user_copy_text or "你好,给大家分享一款好物。")
base["voiceover_script"] = voiceover
base["final_copy"] = voiceover
base["suggested_copy"] = voiceover
base["title"] = base["overview"]["theme"]
return base
def _step_script_generation(job: ViralVideoJob, intent: dict, image_analysis: dict) -> dict:
"""步骤 3: 编导分镜脚本生成(v1.6 核心,输出 copy_result 结构)。"""
try:
from packages.shared.ai_service import call_llm
except ImportError:
return _fallback_script(job)
products_summary = _build_products_summary(image_analysis)
style_hint = "无"
if isinstance(job.style_guide, dict):
style_hint = (
f"节奏{job.style_guide.get('cut_speed','')}、转场{job.style_guide.get('transition','')}、"
f"色调{job.style_guide.get('color_grade','')}、能量{job.style_guide.get('energy','')}"
)
dur = max(5, min(30, int(getattr(job, "duration", 15) or 15)))
ratio = getattr(job, "video_ratio", None) or "9:16"
approx_chars = max(20, dur * 4)
intent_str = ""
key_msgs = ""
tone = ""
if isinstance(intent, dict):
intent_str = intent.get("intent") or "推广产品"
key_msgs = "、".join(intent.get("key_messages") or [])
tone = intent.get("tone") or "亲切自然"
else:
intent_str = "推广产品"
tone = "亲切自然"
prompt = _SCRIPT_GENERATION_PROMPT.format(
products_summary=products_summary,
intent=intent_str,
key_messages=key_msgs or "产品亮点",
tone=tone,
target_customer=job.target_customer or "通用人群",
user_copy=job.user_copy_text or "(未提供,自由创作)",
duration=dur,
ratio=ratio,
n_images=len(job.images or []),
style_hint=style_hint,
approx_chars=approx_chars,
)
try:
result = call_llm(prompt)
parsed = _safe_json_loads(result)
return _validate_and_normalize_script(parsed, job)
except Exception as e:
logger.warning("[爆款视频] 编导脚本生成失败: %s,使用兜底脚本", e, exc_info=True)
return _fallback_script(job)
def _step_review(job: ViralVideoJob, copy_result: dict) -> dict:
"""步骤 4: 合规审核(简化版:基于脚本的 voiceover_script+shots 文本)。"""
dimensions = ["广告法合规", "平台规范", "内容真实性", "版权安全", "价值观", "风格一致性"]
try:
from packages.shared.ai_service import call_llm
except ImportError:
return {"passed": True, "score": 90, "details": {d: "通过" for d in dimensions}}
voiceover = (copy_result or {}).get("voiceover_script", "")
shots_preview = json.dumps((copy_result or {}).get("shots", [])[:3], ensure_ascii=False)
prompt = f"""请对以下短视频编导脚本进行合规审核,检查6个维度:{", ".join(dimensions)}
口播文案:{voiceover}
前3个镜头:{shots_preview}
行业:{job.industry}
请以JSON格式返回:
- passed: bool(是否全部通过)
- score: int(0-100分)
- details: 各维度评分和说明
- issues: 需要修改的问题列表(如有)"""
try:
result = call_llm(prompt)
return result if isinstance(result, dict) else {"passed": True, "score": 80, "details": {}}
except Exception as e:
logger.warning("[爆款视频] 合规审核失败: %s", e)
return {"passed": True, "score": 75, "details": {d: "默认通过" for d in dimensions}}
def _step_tts(job: ViralVideoJob, voiceover_script: str):
"""步骤 5: CosyVoice 整段配音 → 返回本地 MP3 Path;失败返回 None。"""
try:
from pathlib import Path as _Path
from apps.worker.services.tts_service_factory import get_tts_service
tts_service = get_tts_service()
voice_id = (getattr(job, "voice_id", "") or job.persona_id or "").strip()
text = (voiceover_script or "").strip()
if not text:
logger.warning("[爆款视频] voiceover_script 为空,跳过 TTS")
return None
try:
result = tts_service.synthesize(
text=text,
voice_id=voice_id or "longxiaochun_v3",
format="mp3",
)
except TypeError:
try:
result = tts_service.synthesize(text=text, voice_id=voice_id or "longxiaochun_v3")
except TypeError:
result = tts_service.synthesize(text=text)
if result is None:
return None
p = _Path(result) if not isinstance(result, _Path) else result
if p.exists() and p.stat().st_size > 0:
logger.info(
"[爆款视频] TTS 合成完成: voice=%s path=%s size=%d", voice_id or "longxiaochun_v3", p, p.stat().st_size
)
return p
logger.warning("[爆款视频] TTS 返回路径不存在或空文件: %s", p)
return None
except Exception as e:
logger.warning("[爆款视频] TTS 配音失败: %s", e, exc_info=True)
return None
def _upload_tts_to_oss(job: ViralVideoJob, tts_path) -> str | None:
"""把 TTS 本地 mp3 上传到 OSS,返回公网 URL(供 Seedance 做 reference_audios 口型驱动用)。"""
if tts_path is None:
return None
try:
from video_processing.oss_helpers import upload_to_oss
local = Path(tts_path) if not isinstance(tts_path, Path) else tts_path
if not local.exists():
return None
storage_key = f"generated/viral-video/{job.user_id}/{job.id}/tts_voiceover.mp3"
url = upload_to_oss(local, storage_key)
if url:
logger.info("[爆款视频] TTS 音频已上传 OSS: %s", url[:160])
return url
except Exception as e:
logger.warning("[爆款视频] TTS 上传 OSS 失败: %s", e, exc_info=True)
return None
def _assemble_seedance_prompt(copy_result: dict, job: ViralVideoJob) -> str:
"""把编导脚本拼成 Seedance 长 prompt。"""
if not isinstance(copy_result, dict) or not copy_result:
return "产品展示短视频,清晰明亮,自然讲解"
ov = copy_result.get("overview") or {}
theme = ov.get("theme", "")
total_duration = ov.get("total_duration") or getattr(job, "duration", 15)
aspect_ratio = ov.get("aspect_ratio") or getattr(job, "video_ratio", "9:16")
scene_lighting = copy_result.get("scene_and_lighting", "")
shots = copy_result.get("shots") or []
hc = copy_result.get("hard_constraints") or _DEFAULT_HARD_CONSTRAINTS
np = copy_result.get("negative_prompts") or _DEFAULT_NEGATIVE_PROMPTS
lines: list[str] = []
lines.append("【视频总览】")
lines.append(f"- 整体主题:{theme}")
lines.append(f"- 总时长:{total_duration}秒(单次生成,时长必须严格匹配)")
lines.append(f"- 画幅:{aspect_ratio}")
lines.append("")
lines.append("【场景与光线】")
lines.append(scene_lighting)
lines.append("")
lines.append("【逐镜头时间轴】(按时间顺序连贯拍摄,镜头之间自然衔接)")
for i, s in enumerate(shots):
if not isinstance(s, dict):
continue
tr = s.get("time_range", "")
cam = s.get("shot_type_angle_movement", "")
sd = s.get("scene_and_dialogue", "")
act = s.get("action_details", "")
ab = s.get("audio_bgm", "")
t = s.get("transition", "")
ref = s.get("reference_image_index")
lines.append(f"- 镜头{i+1}({tr}):")
lines.append(f" 景别/运镜:{cam}")
lines.append(f" 画面与对白:{sd}")
lines.append(f" 动作细节:{act}")
lines.append(f" 音效/BGM:{ab}")
lines.append(f" 转场:{t}")
if ref is not None and isinstance(ref, int):
lines.append(f" 参考图片:第{ref+1}张产品图")
lines.append("")
lines.append("【硬性约束】")
for c in hc:
lines.append(f"- {c}")
lines.append("")
lines.append("【负面提示词】(必须避免)")
lines.append(",".join([str(x) for x in np if x]))
return "\n".join(lines)
def _step_render(job: ViralVideoJob, copy_result: dict, tts_audio_url: str | None) -> str:
"""步骤 6: v1.6 单次 Seedance 生成(不再分段/拼接)。"""
from packages.shared.ai_service import call_video_generation
prompt = _assemble_seedance_prompt(copy_result, job)
dur = max(5, min(30, int(getattr(job, "duration", 15) or 15)))
ratio = getattr(job, "video_ratio", None) or "9:16"
model = getattr(job, "video_model", "") or None
# reference_audios: TTS 音频驱动口型
ref_audios = [tts_audio_url] if tts_audio_url else []
# reference_images: 产品图(除首帧外的其他图作为多参考;首帧通过 image_url 传)
images = list(job.images or [])
first_image = images[0] if images else None
rest_images = images[1:30] if len(images) > 1 else []
# reference_videos: 参考视频(可选)
ref_videos = [job.reference_video_url] if getattr(job, "reference_video_url", "") else []
tmpdir = Path(tempfile.mkdtemp(prefix=f"viral_{job.id}_"))
logger.info(
"[爆款视频] 开始单次 Seedance 生成 dur=%ds ratio=%s model=%s ref_imgs=%d ref_audios=%d ref_videos=%d tmpdir=%s",
dur,
ratio if not first_image else "(follow-image)",
model or "default",
len(rest_images) + (1 if first_image else 0),
len(ref_audios),
len(ref_videos),
tmpdir,
)
logger.info("[爆款视频] Seedance prompt (前300字): %s", prompt[:300])
video_path = call_video_generation(
prompt=prompt,
image_url=first_image,
duration=dur,
ratio=ratio,
resolution="720p",
output_dir=str(tmpdir),
model=model,
generate_audio=True, # Seedance 原生生成环境音效/BGM;口型由 reference_audios 的 TTS 驱动
reference_images=rest_images,
reference_audios=ref_audios,
reference_videos=ref_videos,
)
if not video_path or not Path(video_path).exists() or Path(video_path).stat().st_size == 0:
raise RuntimeError("Seedance 视频生成失败:返回空文件或路径不存在")
logger.info("[爆款视频] Seedance 单次生成完成: %s size=%d", video_path, Path(video_path).stat().st_size)
return str(video_path)
def _step_upload(job: ViralVideoJob, video_path: str) -> str:
"""步骤 7: OSS 上传。"""
from video_processing.oss_helpers import upload_to_oss
local = Path(video_path)
storage_key = f"generated/viral-video/{job.user_id}/{job.id}/{local.name}"
logger.info("[爆款视频] 开始上传成片: local=%s key=%s size=%d", local, storage_key, local.stat().st_size)
video_url = upload_to_oss(local, storage_key)
if not video_url:
raise RuntimeError(f"OSS 上传失败: storage_key={storage_key}")
return video_url
# ── 主编排器 ────────────────────────────────────────────────────────────
@shared_task(bind=True, max_retries=2, name="worker.run_viral_video_pipeline")
def run_viral_video_pipeline(self: Task, job_id: str) -> dict:
"""旧一键流水线(保留兼容):图片分析→风格分析→意图解析→WAIT_USER_CONFIRM。"""
session = None
try:
session, repo, job = _get_repo_and_job(job_id)
if job is None:
return {"ok": False, "error": "job not found"}
job.mark_running()
_save_job(repo, job, session)
_emit_progress(job_id, ViralVideoStage.IMAGE_ANALYSIS, 5.0, "开始图片分析")
_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)
_emit_progress(job_id, ViralVideoStage.IMAGE_ANALYSIS, 15.0, "图片分析完成", {"result": image_analysis})
style_guide = None
if job.reference_video_url or job.style_template_id:
_emit_progress(job_id, ViralVideoStage.VIDEO_ANALYSIS, 20.0, "正在分析参考视频风格...")
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, "正在解析文案意图...")
intent_result = _step_intent_parsing(job, image_analysis)
job.mark_wait_user_confirm(intent_result)
_save_job(repo, job, session)
_emit_progress(
job_id,
ViralVideoStage.INTENT_PARSING,
35.0,
"意图解析完成,等待用户确认",
{"intent_result": intent_result, "waiting_confirm": True},
)
_emit_progress(
job_id,
ViralVideoStage.INTENT_PARSING,
35.0,
"等待用户确认意图文案",
{"intent_result": intent_result},
event_type="viral_video:wait_user",
)
return {"ok": True, "job_id": job_id, "status": "wait_user_confirm", "intent_result": intent_result}
except Retry:
raise
except Exception as e:
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:
session.close()
@shared_task(bind=True, max_retries=2, name="worker.resume_viral_video_pipeline")
def resume_viral_video_pipeline(self: Task, job_id: str) -> dict:
"""旧 confirm-intent 路径兼容:从 WAIT_USER_CONFIRM 跑完整个渲染。"""
session = None
try:
session, repo, job = _get_repo_and_job(job_id)
if job is None:
return {"ok": False, "error": "job not found"}
if job.status != ViralVideoStatus.RUNNING:
return {"ok": False, "error": f"unexpected status: {job.status}"}
return _run_render_pipeline(job_id, session, repo, job)
except Retry:
raise
except Exception as e:
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:
session.close()
@shared_task(bind=True, max_retries=1, name="worker.run_video_style_analysis")
def run_video_style_analysis(self: Task, job_id: str) -> dict:
"""独立的视频风格分析任务。"""
session = None
try:
session, repo, job = _get_repo_and_job(job_id)
if job is None:
return {"ok": False, "error": "job not found"}
_emit_progress(job_id, ViralVideoStage.VIDEO_ANALYSIS, 10.0, "正在分析参考视频风格...")
style_guide = _step_video_analysis(job)
job.style_guide = style_guide
_save_job(repo, job, session)
_emit_progress(job_id, ViralVideoStage.VIDEO_ANALYSIS, 100.0, "风格分析完成", {"style_guide": style_guide})
return {"ok": True, "job_id": job_id, "style_guide": style_guide}
except Retry:
raise
except Exception as e:
logger.error("[爆款视频] 风格分析失败: %s", e)
return {"ok": False, "job_id": job_id, "error": str(e)}
finally:
if session:
session.close()
# ── 失败处理 ────────────────────────────────────────────────────────────
def _mark_failed_and_notify(job_id: str, session, repo, job, err_msg: str, stage: str = "") -> None:
try:
if session is None:
session = SessionLocal()
repo = SQLAlchemyViralVideoJobRepository(session)
job = repo.get(job_id)
if job is not None and not job.is_terminal:
job.mark_failed(err_msg)
_save_job(repo, job, session)
except Exception as inner:
logger.warning("[爆款视频] 标记失败状态时出错: %s", inner)
_emit_progress(
job_id,
stage,
0,
f"任务失败: {err_msg}",
{"error": err_msg},
event_type="viral_video:failed",
)
# ── v1.5/v1.6 三步分步流水线 Celery 任务 ─────────────────────────────────
@shared_task(bind=True, max_retries=1, name="worker.run_viral_video_analyze")
def run_viral_video_analyze(self: Task, job_id: str) -> dict:
"""v1.5+ 阶段1:图片 VLM 分析 + 可选视频风格分析。"""
session = None
try:
session, repo, job = _get_repo_and_job(job_id)
if job is None:
return {"ok": False, "error": "job not found"}
job.mark_running()
_save_job(repo, job, session)
_emit_progress(job_id, ViralVideoStage.IMAGE_ANALYSIS, 5.0, "开始图片分析")
_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)
_emit_progress(job_id, ViralVideoStage.IMAGE_ANALYSIS, 60.0, "图片分析完成", {"result": image_analysis})
style_guide = None
if job.reference_video_url or job.style_template_id:
_emit_progress(job_id, ViralVideoStage.VIDEO_ANALYSIS, 70.0, "正在分析参考视频风格...")
style_guide = _step_video_analysis(job)
job.style_guide = style_guide
_save_job(repo, job, session)
_emit_progress(
job_id,
ViralVideoStage.VIDEO_ANALYSIS,
90.0,
"风格分析完成",
{"style_analyzed": True, "style_guide": style_guide},
)
job.mark_image_analyzed()
_save_job(repo, job, session)
_emit_progress(
job_id,
ViralVideoStage.IMAGE_ANALYSIS,
100.0,
"图片分析完成,请填写营销参数以生成编导脚本",
{"image_analysis": image_analysis, "status": "image_analyzed"},
event_type="viral_video:image_analyzed",
)
return {"ok": True, "job_id": job_id, "status": "image_analyzed", "image_analysis": image_analysis}
except Retry:
raise
except Exception as e:
logger.error("[爆款视频][阶段1] 异常: %s", e, exc_info=True)
_mark_failed_and_notify(job_id, session, None, None, str(e), ViralVideoStage.IMAGE_ANALYSIS)
return {"ok": False, "job_id": job_id, "error": str(e)}
finally:
if session:
session.close()
@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。"""
session = None
try:
session, repo, job = _get_repo_and_job(job_id)
if job is None:
return {"ok": False, "error": "job not found"}
if job.status != ViralVideoStatus.RUNNING:
return {"ok": False, "error": f"unexpected status: {job.status}"}
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, "正在生成编导分镜脚本...")
copy_result = _step_script_generation(job, intent_result, image_analysis)
_emit_progress(
job_id,
ViralVideoStage.SCRIPT_GENERATION,
60.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, "合规审核完成")
job.mark_copy_generated(copy_result)
_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,
"storyboard": copy_result.get("shots", []),
"status": "copy_generated",
},
event_type="viral_video:copy_generated",
)
logger.info(
"[爆款视频][阶段2] 编导脚本生成完成 job_id=%s voiceover_len=%d shots=%d",
job_id,
len(voiceover),
len(copy_result.get("shots", [])),
)
return {
"ok": True,
"job_id": job_id,
"status": "copy_generated",
"copy_result": copy_result,
"generated_copy_text": voiceover,
"storyboard": copy_result.get("shots", []),
}
except Retry:
raise
except Exception as e:
logger.error("[爆款视频][阶段2] 异常: %s", e, exc_info=True)
_mark_failed_and_notify(job_id, session, None, None, str(e), ViralVideoStage.SCRIPT_GENERATION)
return {"ok": False, "job_id": job_id, "error": str(e)}
finally:
if session:
session.close()
def _run_render_pipeline(job_id: str, session, repo, job) -> dict:
"""v1.6 阶段3 / 旧 resume 共用:TTS → 单次 Seedance → Upload → Completed。"""
image_analysis = job.image_analysis or {"products": []}
# 如果没有 copy_result(旧数据/失败重试),现场补生成
copy_result = job.copy_result
if not isinstance(copy_result, dict) or not copy_result:
_emit_progress(job_id, ViralVideoStage.SCRIPT_GENERATION, 40.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.mark_copy_generated(copy_result)
_save_job(repo, job, session)
voiceover = copy_result.get("voiceover_script", "") or job.effective_copy_text
# Step 5: TTS 整段合成
_emit_progress(job_id, ViralVideoStage.TTS, 72.0, "正在生成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分钟)...")
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, "正在上传视频...")
video_url = _step_upload(job, video_path)
job.credits_cost = CREDITS_VIRAL_VIDEO_COST
job.mark_completed(video_url)
_save_job(repo, job, session)
_emit_progress(job_id, ViralVideoStage.UPLOADING, 100.0, "视频生成完成!", {"video_url": video_url})
_emit_progress(
job_id,
ViralVideoStage.UPLOADING,
100.0,
"视频生成完成",
{"video_url": video_url},
event_type="viral_video:completed",
)
logger.info("[爆款视频] 任务完成: job_id=%s video_url=%s", job_id, video_url)
return {"ok": True, "job_id": job_id, "video_url": video_url}
@shared_task(bind=True, max_retries=2, name="worker.run_viral_video_render")
def run_viral_video_render(self: Task, job_id: str) -> dict:
"""v1.6 阶段3:TTS + 单次 Seedance 生成 + 上传。"""
session = None
try:
session, repo, job = _get_repo_and_job(job_id)
if job is None:
return {"ok": False, "error": "job not found"}
if job.status != ViralVideoStatus.RUNNING:
return {"ok": False, "error": f"unexpected status: {job.status}"}
return _run_render_pipeline(job_id, session, repo, job)
except Retry:
raise
except Exception as e:
logger.error("[爆款视频][阶段3] 异常: %s", e, exc_info=True)
_mark_failed_and_notify(job_id, session, None, None, str(e), ViralVideoStage.RENDERING)
return {"ok": False, "job_id": job_id, "error": str(e)}
finally:
if session:
session.close()