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
- 输出从营销口播文案改为专业编导分镜脚本(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 相关单测全通过
1160 lines
50 KiB
Python
1160 lines
50 KiB
Python
"""爆款视频 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()
|