Files
xiaoxia-saas/apps/worker/worker_app/tasks/viral_video.py
T
xiaoxia 0e78f175fe
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 3s
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 3s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web 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
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m19s
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
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m17s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 3m11s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 4m16s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 4m36s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 4m43s
CI/CD Pipeline / Validate - Style (pull_request) Failing after 5m56s
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 - Security (pull_request) Has been cancelled
CI/CD Pipeline / Unit Tests (pull_request) Has been cancelled
AI Code Review / AI Code Review (pull_request) Has been cancelled
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been cancelled
fix(#2174 P0): 信任链从i2i改为t2i文生图——方舟实测验证通过
#2170/#2172的信任链用Seedream图生图(i2i,传reference_images=用户真人照),
实测产物不被Seedance信任,仍被400 portrait_intercept拦截。

方舟文档+实测确认:信任的是Seedream文生图(t2i)产物——纯prompt描述人物,
不传reference_images,产物是纯模型生成人像,同账号30天内可直接传Seedance。

改动:
- VLM prompt新增portrait_prompt字段(60-100字人物外貌描述)
- preheat_trust_chain签名从portrait_urls改为portrait_descriptions,t2i模式不传reference_images
- video_generation移除现场跑i2i逻辑,仅使用预热好的t2i结果;预热失败走#2166自动降级t2v
- 预热时机从VLM前改为VLM后(需要VLM输出的portrait_prompt),仍与文案阶段并行
- 单测适配:TestTrustChain全部改为pre_trusted_images路径验证

实测验证(充值后):
- Seedream flash t2i ~15s出图,人像prompt中英文混合写实风格
- Seedance 2.5 i2v用t2i产物URL → 成功出5s视频(120s)
- Seedance 2.0-mini i2v用t2i产物URL → 成功出5s视频
- 对照组直传用户真人照片 → 400 portrait_intercept(预期)
2026-10-04 15:31:38 +08:00

1969 lines
86 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
import threading
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
from pathlib import Path
from typing import Any
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 (
STAGE_LABELS,
ViralVideoJob,
ViralVideoStage,
ViralVideoStatus,
)
from packages.shared import get_shared_settings
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()
def _start_trust_chain_preheat(job_id: str, portrait_descriptions: list[str]) -> None:
"""#2172/#2174 后台启动信任链预热(Seedream t2i 文生图人像),不阻塞调用方。
#2174 重要:改为 t2i 文生图模式——用 VLM 分析出的人物外貌描述做 prompt,不传 reference_images,
产物是方舟信任模型输出,Seedance 直接放行不触发肖像审核。
i2i(传用户照片做 reference)产物不被信任,实测仍被 400 portrait_intercept 拦截。
预热成功后把结果写入 job.pre_trusted_images,阶段3 渲染直接使用,省掉串行等待。
预热失败静默(pre_trusted_images 保持 None),阶段3 会走 #2166 自动降级纯 t2v。
"""
# 过滤有效描述:非空且不是"无人像"
_valid = [d for d in (portrait_descriptions or []) if d and isinstance(d, str) and "无人像" not in d and len(d) >= 10]
if not _valid:
logger.info("[trust-chain][preheat] 无有效人物描述(可能是纯商品图),跳过预热 job=%s", job_id)
return
# 判断是否是 doubao provider(DashScope/Wan 不需要信任链)
try:
from packages.domain.points_rules import get_viral_video_model_config
from packages.shared.ai_service import preheat_trust_chain
except ImportError:
return
def _run_preheat():
try:
# 只有 doubao provider 的模型需要信任链
try:
_s = get_shared_settings()
job_sess, job_repo, job_obj = _get_repo_and_job(job_id)
model = getattr(job_obj, "video_model", "") or ""
_mcfg = get_viral_video_model_config(model)
if _mcfg.get("provider", "doubao") != "doubao":
job_sess.close()
return
job_sess.close()
except Exception:
pass
logger.info("[trust-chain][preheat] 后台t2i预热启动 job=%s n=%d", job_id, len(_valid))
result = preheat_trust_chain(_valid, timeout=120)
if result and len(result) >= 1:
sess2, repo2, job2 = _get_repo_and_job(job_id)
try:
job2.pre_trusted_images = result
repo2.update(job2)
sess2.commit()
logger.info(
"[trust-chain][preheat] t2i预热完成并持久化 job=%s n=%d",
job_id,
len(result),
)
finally:
sess2.close()
else:
logger.info("[trust-chain][preheat] 预热失败 job=%s,阶段3现场跑兜底", job_id)
except Exception as e:
logger.warning("[trust-chain][preheat] 预热异常 job=%s err=%s", job_id, e, exc_info=True)
# 用 daemon 线程启动,不阻塞 celery task 返回
import threading
t = threading.Thread(target=_run_preheat, name=f"tc-preheat-{job_id[:8]}", daemon=True)
t.start()
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)
# ── worker 心跳(僵尸任务检测) ─────────────────────────────────────────
# 心跳间隔(秒);超过此时间未更新 heartbeat_at 视为 worker 异常
_HEARTBEAT_INTERVAL_SEC = 25
# 任务整体超时:running 超过此时长且心跳停止,则判定为僵尸并回收
_STALE_RUNNING_TIMEOUT_SEC = 10 * 60 # 10 分钟
# 心跳过期窗口:heartbeat_at 距 now 超过此时长视为失效
_HEARTBEAT_EXPIRE_SEC = 2 * 60 # 2 分钟
def _heartbeat_once(job_id: str) -> None:
"""在独立 session 中更新一次 heartbeat_at(不捕获主流程事务状态)。"""
ssn = None
try:
from datetime import datetime, timezone
ssn = SessionLocal()
ssn.execute(
__import__("sqlalchemy").text(
"UPDATE viral_video_jobs SET heartbeat_at = :now, updated_at = :now "
"WHERE id = :jid AND status = 'running'"
),
{"now": datetime.now(timezone.utc), "jid": job_id},
)
ssn.commit()
except Exception as e:
logger.warning("[爆款视频] 心跳更新失败 job=%s err=%s", job_id, e)
finally:
if ssn is not None:
try:
ssn.close()
except Exception:
pass
def _start_heartbeat_thread(job_id: str) -> tuple[threading.Event, threading.Thread]:
"""启动后台心跳线程,每 _HEARTBEAT_INTERVAL_SEC 秒更新一次 heartbeat_at。
返回 (stop_event, thread);任务结束时调用 stop_event.set() 停止心跳。
"""
stop = threading.Event()
def _loop():
# 立即打一次心跳
_heartbeat_once(job_id)
while not stop.wait(_HEARTBEAT_INTERVAL_SEC):
_heartbeat_once(job_id)
t = threading.Thread(target=_loop, name=f"vv-heartbeat-{job_id[:8]}", daemon=True)
t.start()
return stop, t
def _recover_stale_jobs() -> int:
"""启动/定时扫描:把僵尸任务(running 超时且心跳停止)标记为 failed。
返回本次回收的任务数。可由 celery beat 周期性调用,也可在任务启动前顺带扫一次。
"""
from datetime import datetime, timedelta, timezone
ssn = None
try:
ssn = SessionLocal()
now = datetime.now(timezone.utc)
# 判定条件:status=running 且 (started_at 距今 > 10min) 且 (heartbeat_at < now-2min 或 heartbeat_at IS NULL 且 updated_at < now-2min)
cutoff_beat = now - timedelta(seconds=_HEARTBEAT_EXPIRE_SEC)
cutoff_start = now - timedelta(seconds=_STALE_RUNNING_TIMEOUT_SEC)
sql = __import__("sqlalchemy").text(
"UPDATE viral_video_jobs "
"SET status='failed', error_msg='任务执行超时,请重试', updated_at=:now "
"WHERE status='running' "
" AND started_at IS NOT NULL AND started_at < :cutoff_start "
" AND (heartbeat_at IS NULL OR heartbeat_at < :cutoff_beat) "
" AND (heartbeat_at IS NOT NULL OR updated_at < :cutoff_beat)"
)
result = ssn.execute(sql, {"now": now, "cutoff_start": cutoff_start, "cutoff_beat": cutoff_beat})
ssn.commit()
cnt = result.rowcount or 0
if cnt > 0:
logger.warning("[爆款视频] 回收 %d 个僵尸 running 任务", cnt)
return cnt
except Exception as e:
logger.warning("[爆款视频] 僵尸任务扫描失败: %s", e)
return 0
finally:
if ssn is not None:
try:
ssn.close()
except Exception:
pass
# ── 默认结构 ─────────────────────────────────────────────────────────────
_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_SYSTEM_PROMPT = """你是电商商品视觉分析师,从商品图片中提取关键商品信息。严格规则:
1. 只说图片里真实可见的内容,看不清/没有的填「无法判断」,不要瞎猜。
2. 输出必须是严格 JSON(不要 Markdown 代码块,不要额外解释文字)。
3. 字段说明:
{
"name": "商品全名(品牌+产品名+规格,如『大公鸡头管家 多功能油污净 625ml』,从包装 OCR 读出)",
"brand": "品牌名(从 Logo/包装文字读出,看不清填『无法判断』)",
"category": "商品品类(如『家用清洁/油污清洁剂』『日化/洗衣液』;非产品图填『非产品图』)",
"appearance": "外观特征(50-100字:瓶身形状、颜色、瓶盖、标签颜色、尺寸感)",
"packaging": "包装细节(50-100字:标签分区、图案元素、瓶盖/泵头样式、塑封状态)",
"text_on_package": ["包装上清晰可见的文字列表(品牌、产品名、卖点、规格等,看不清的不列)"],
"key_features": [
"3-5 条图片中能看到的外观/视觉特征(如『红色瓶盖白色瓶身』『鸡头图案 Logo』等)"
],
"scene": "图片场景(如白底棚拍/浴室实拍/桌面静物/手持实拍等)",
"portrait_prompt": "如果图片中有清晰人物面部,用60-100字中文描述该人物外貌(性别、年龄段、发型/发色、肤色、脸型、五官特征、当前穿着、表情姿态),用于AI生图参考;没有人物或看不清面部填「无人像」",
"summary": "100-180字中文导购描述,连贯自然段落,像电商详情页介绍,前端直接展示,必须提到品牌/品名/核心外观特征,不能写『无法判断』"
}"""
_IMAGE_ANALYSIS_USER_PROMPT = """请分析这张商品图片,输出严格 JSON。重点:
1. name/brand/text_on_package 从图片包装 OCR 读取,不编造;
2. appearance/packaging 各写 50-100 字,要具体;
3. portrait_prompt:有人物时详细描述外貌(性别/年龄/发型/肤色/穿着/表情)用于AI人像生成参考,无人像填「无人像」;
4. summary 必须是 100-180 字连贯中文段落,说清商品是什么、长什么样、适合谁用,不要写「无法判断」;
5. 非产品图时 category 填「非产品图」,name 填实际看到的内容;
6. 看不清的字段填「无法判断」。"""
def _vision_fallback(idx: int, reason: str, extra: dict | None = None) -> dict:
d = {
"name": "未识别",
"category": "无法判断",
"appearance": "无法判断",
"packaging": "无法判断",
"text_on_package": [],
"key_features": [],
"scene": "通用",
"portrait_prompt": "无人像",
"summary": "",
"_source": reason,
}
if extra:
d.update(extra)
return d
def _is_vision_result_usable(result: dict) -> bool:
"""判断 VLM 返回是否有效:name/summary 不能为未识别/无法判断/空,summary 要够长。"""
if not isinstance(result, dict):
return False
name = (result.get("name") or "").strip()
if not name or name in ("未识别", "无法判断", "未知"):
return False
summary = (result.get("summary") or "").strip()
if len(summary) < 30 or summary in ("无法判断", "未识别"):
return False
category = (result.get("category") or "").strip()
if category == "非产品图":
return True
feats = result.get("key_features") or []
if not isinstance(feats, list) or len(feats) == 0:
return False
return True
def _analyze_single_image(
idx: int,
img_url: str,
vision_model: str,
timeout: int,
*,
pro_fallback_model: str | None = None,
) -> dict:
"""单张图片 VLM 分析(线程池并行调用)。
- lite 失败/结果不可用 时自动用 pro 模型降级重试 1 次。
- 失败/None/不可用最终返回含默认字段的 dict(不会让用户看到「未识别·无法判断」裸结果)。
"""
from packages.shared.ai_service import call_vision
if not img_url or not isinstance(img_url, str):
return _vision_fallback(idx, "invalid_url")
def _call(model: str, tmo: int):
try:
return call_vision(
image_url=img_url,
prompt=_IMAGE_ANALYSIS_USER_PROMPT,
model=model,
max_tokens=800,
temperature=0.1,
timeout=tmo,
system_prompt=_IMAGE_ANALYSIS_SYSTEM_PROMPT,
)
except Exception as e:
logger.warning("[爆款视频] 图片 #%d call_vision(%s) 异常 err=%s", idx, model, e)
return None
def _normalize(raw, source: str) -> dict:
if raw is None:
return _vision_fallback(idx, f"{source}_none")
if isinstance(raw, str):
logger.warning("[爆款视频] 图片 #%d VLM(%s) 返回非 JSON: %s", idx, source, raw[:200])
return _vision_fallback(idx, f"{source}_text", {"_raw": raw[:500]})
if not isinstance(raw, dict):
return _vision_fallback(idx, f"{source}_badtype")
raw.setdefault("_source", source)
raw.setdefault("name", "未识别")
raw.setdefault("brand", "无法判断")
raw.setdefault("category", "无法判断")
raw.setdefault("appearance", "无法判断")
raw.setdefault("packaging", "无法判断")
raw.setdefault("text_on_package", [])
raw.setdefault("key_features", [])
raw.setdefault("scene", "通用")
raw.setdefault("portrait_prompt", "无人像")
raw.setdefault("summary", "")
if not isinstance(raw.get("text_on_package"), list):
raw["text_on_package"] = []
if not isinstance(raw.get("key_features"), list):
raw["key_features"] = []
return raw
# 第一次:传入模型(通常是 lite)
first_raw = _call(vision_model, timeout)
tag1 = vision_model.split("/")[-1] if "/" in vision_model else vision_model
first_result = _normalize(first_raw, tag1)
if _is_vision_result_usable(first_result):
return first_result
logger.warning(
"[爆款视频] 图片 #%d VLM(%s) 结果不可用 name=%r summary_len=%d,尝试 pro 降级",
idx,
vision_model,
first_result.get("name"),
len(first_result.get("summary") or ""),
)
# 第二次:pro 降级重试
if pro_fallback_model and pro_fallback_model != vision_model:
pro_raw = _call(pro_fallback_model, 25) # #2173: pro 25s 上限(lite 已失败过,快速兜底)
pro_result = _normalize(pro_raw, "pro_fallback")
if _is_vision_result_usable(pro_result):
pro_result["_fallback_used"] = True
return pro_result
logger.warning(
"[爆款视频] 图片 #%d pro 降级仍不可用 name=%r summary_len=%d",
idx,
pro_result.get("name"),
len(pro_result.get("summary") or ""),
)
return pro_result
return first_result
def _step_image_analysis(job: ViralVideoJob) -> dict:
"""步骤 1: 图片 VLM 分析 — 识别产品特征(v1.6 优化:并行 + lite 模型提速)。"""
try:
from packages.shared.ai_service import call_vision # noqa: F401
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": []}
# 选择视觉模型:lite 速度优先(默认),pro 作为降级备用
try:
_s = get_shared_settings()
if _s.doubao_vision_use_lite:
vision_model = _s.doubao_vision_lite_model
pro_model = _s.doubao_vision_model
vision_timeout = 15 # #2173: lite 15s 快速失败转 pro(实测 lite 持续超时时白等137s是P0)
else:
vision_model = _s.doubao_vision_model
pro_model = None # 已经是 pro,不再降级
vision_timeout = 60
except Exception:
vision_model = "doubao-1-5-vision-lite-250315"
pro_model = "doubao-1-5-vision-pro-250328"
vision_timeout = 45
results: list[dict] = [None] * len(job.images) # type: ignore
max_workers = min(4, max(1, len(job.images)))
logger.info(
"[爆款视频] 开始并行图片分析 n=%d model=%s pro_fallback=%s timeout=%d workers=%d",
len(job.images),
vision_model,
pro_model,
vision_timeout,
max_workers,
)
with ThreadPoolExecutor(max_workers=max_workers) as pool:
future_to_idx = {
pool.submit(
_analyze_single_image, idx, url, vision_model, vision_timeout, pro_fallback_model=pro_model
): idx
for idx, url in enumerate(job.images)
}
for fut in as_completed(future_to_idx):
idx = future_to_idx[fut]
try:
results[idx] = fut.result()
except Exception as e:
logger.warning("[爆款视频] 图片 #%d future 异常 err=%s", idx, e, exc_info=True)
results[idx] = _vision_fallback(idx, "future_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字)"
}}"""
_s = get_shared_settings()
_fast = _s.doubao_fast_model
_pro = _s.doubao_model
for _m, _lbl in [(_fast, "fast"), (_pro, "pro-fallback")]:
try:
logger.info("[爆款视频] 意图解析 model=%s label=%s", _m, _lbl)
result = call_llm(prompt, temperature=0.4, max_tokens=800, model=_m, timeout=25)
return (
result
if isinstance(result, dict)
else {"intent": str(result)[:200], "key_messages": [], "tone": "专业", "suggested_title": ""}
)
except Exception as e:
logger.warning("[爆款视频] 意图解析失败 label=%s err=%s", _lbl, e)
continue
return {"intent": "推广产品", "key_messages": ["产品亮点"], "tone": "专业", "suggested_title": ""}
# ── 编导分镜脚本生成(核心,v1.6 新 prompt) ──────────────────────────────
# 人设 IP 类型 → 文案/出镜风格指导(前端下拉 10 个 IP 类型)
_PERSONA_STYLE_GUIDE = {
"通用个人IP": "亲切自然、像朋友分享好物,第一人称口语化,不端着",
"老板型IP": "沉稳大气、有行业格局感,适度使用『我做了XX年』『我一直坚持』等老板视角,语气自信不夸张",
"专家型IP": "专业权威、讲原理和数据支撑,用词严谨,少用网梗,像行业专家做科普",
"顾问型IP": "贴心周到、给建议给方案,多用『建议你』『可以试试』『我帮你梳理』",
"创始人IP": "真诚有温度、讲品牌故事和创业初心,带点情怀和个人观点,不端老板架子",
"创业者IP": "真实接地气、讲踩坑经验和创业心路,带点自嘲和韧劲,像身边的创业者朋友",
"从业者经验派": "内行视角、讲行业内幕/实操经验/踩坑教训,多用『干了X年我发现』『内行都知道』",
"避坑顾问型": "直接点出痛点和雷区,先讲『别买XX』『很多人踩过的坑』再给正确选择,节奏感强",
"知识科普型": "清晰讲原理、讲知识点,条理分明、信息密度高,像做一期小科普",
"测评种草型": "真实测评感、讲使用体验和优缺点对比,带『亲测』『我用了XX天』『实测下来』真实感词汇",
}
def _persona_style_hint(persona_id: str) -> str:
"""根据 persona_id 查文案风格指导;未命中/空值返回通用提示。"""
pid = (persona_id or "").strip()
if pid in _PERSONA_STYLE_GUIDE:
return f"【人设风格:{pid}】{_PERSONA_STYLE_GUIDE[pid]}"
if pid:
# 前端传了自由值,照直提示,不阻塞
return f"【人设风格:{pid}】按该人设的口吻、话术习惯组织口播和出镜动作"
return "【人设风格:未指定】亲切自然、像朋友分享好物"
_SCRIPT_GENERATION_PROMPT = """你是资深短视频导演,为 Seedance 2.5(单次生成最多{duration}秒)写编导分镜脚本。脚本将整体作为 prompt 一次性传给视频模型,必须让模型在连贯镜头流中清楚每段时间拍什么、画面如何、人物说什么。
## 产品
{products_summary}
## 营销参数
- 主题/意图:{intent}
- 关键信息:{key_messages}
- 调性:{tone}
- 目标客户:{target_customer}
- 用户原始卖点(必须融入口播):{user_copy}
- 时长:{duration}秒 / 画幅:{ratio} / 产品图:{n_images}张(第1张通常是主图/首帧)
- 风格参考:{style_hint}
- 爆款结构(必须严格遵循节奏/段落顺序):{viral_structure_block}
- 人设/出镜口吻(必须贯穿全部对白和动作描写):{persona_hint}
## 输出格式(必须输出严格 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. 每镜写清景别/角度/运镜(特写/近景/中景+平视/俯拍+推/拉/固定)。
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. 严格按上方「爆款结构」的节奏/段落顺序编排(钩子/痛点/反转/案例/行动号召与结构对齐)。
9. 输出前自检:口播对白禁止错别字和语病,**严禁使用"很近",正确用词是"最近"**(指"最近一段时间/最近在用",绝不能写成"很近");其他同音字、形近字错误一律修正。
10. 必须使用产品信息中真实的品牌、品名和外观特征,不要编造与产品无关的内容。"""
def _build_products_summary(image_analysis: dict) -> str:
"""把 VLM 返回的商品分析结果拼给文案/分镜生成 prompt 用。
优先用 summary(自然段落);没有时用结构化字段兜底拼一段。"""
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 "产品"
# 优先 VLM 生成的 summary 段(自然语言,给编导模型看效果最好)
summary = (p.get("summary") or "").strip()
if summary and len(summary) >= 30:
lines.append(f"- 图{i + 1} {name}:{summary}")
continue
# 结构化字段兜底
brand = p.get("brand") or ""
cat = p.get("category") or ""
spec = p.get("spec") or ""
appearance = p.get("appearance") or ""
packaging = p.get("packaging") 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") or p.get("target_audience_hint") or ""
# text_on_package 可能是数组(新格式)或字符串(旧格式)
text_list = p.get("text_on_package") or []
if isinstance(text_list, str):
text_on_img = text_list
else:
text_on_img = ";".join([str(x) for x in text_list[:8]]) if text_list else (p.get("text_on_image") or "")
feats = p.get("key_features") or p.get("features") or []
sellings = p.get("selling_points") or []
scenes = p.get("suitable_scenes") 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 spec and spec != "无法判断":
parts.append(f"规格={spec}")
if appearance and appearance != "无法判断":
parts.append(f"外观={appearance}")
if packaging and packaging != "无法判断":
parts.append(f"包装={packaging}")
if colors:
parts.append(f"颜色={','.join(colors)}")
if mat and mat != "无法判断":
parts.append(f"材质={mat}")
if style:
parts.append(f"风格={style}")
if scene and scene not in ("通用",):
parts.append(f"展示场景={scene}")
if scenes:
parts.append(f"适用场景={','.join([str(x) for x in scenes[:4]])}")
if audience and audience not in ("通用", "无法判断"):
parts.append(f"目标人群={audience}")
if text_on_img and text_on_img not in ("无",):
parts.append(f"包装文字={text_on_img[:300]}")
if feats:
parts.append("外观特征=" + ";".join([str(x) for x in feats[:6]]))
if sellings:
parts.append("营销卖点=" + ";".join([str(x) for x in sellings[:5]]))
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 _replace_henjin_everywhere(obj: Any) -> Any:
"""递归遍历 copy_result 里所有字符串值,把'很近'替换成'最近'。
覆盖 overview.theme、scene_and_lighting、voiceover_script、
shots[].scene_and_dialogue/action_details/audio_bgm 等所有字段。
"""
if isinstance(obj, str):
if "很近" in obj:
return obj.replace("很近", "最近")
return obj
if isinstance(obj, list):
return [_replace_henjin_everywhere(x) for x in obj]
if isinstance(obj, dict):
return {k: _replace_henjin_everywhere(v) for k, v in obj.items()}
return obj
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 = "亲切自然"
# 爆款结构:用户在 STEP1/STEP2 选的中文结构名,必须严格注入 prompt 指导 AI 编排
_vs = (job.viral_structure or "").strip()
if _vs:
viral_structure_block = f"【{_vs}】—— 请严格按照这个爆款结构的节奏/段落顺序编排镜头、台词和情绪节点(开场钩子、痛点、反转、案例、行动号召等按结构走),不要打乱顺序"
else:
viral_structure_block = "未指定(自由编排,但仍需有钩子开头+产品展示+行动号召的基本节奏)"
persona_hint = _persona_style_hint(getattr(job, "persona_id", ""))
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,
viral_structure_block=viral_structure_block,
persona_hint=persona_hint,
)
_s = get_shared_settings()
_fast = _s.doubao_fast_model
_pro = getattr(_s, "doubao_model", None) or _fast
def _try_gen(model: str, temp: float, max_tok: int, label: str, tmo: int = 25):
logger.info("[爆款视频] 编导脚本生成 model=%s label=%s timeout=%d", model, label, tmo)
r = call_llm(prompt, temperature=temp, max_tokens=max_tok, model=model, timeout=tmo)
if r is None:
logger.warning("[爆款视频] 编导脚本返回None label=%s", label)
return None
parsed = _safe_json_loads(r)
normalized = _validate_and_normalize_script(parsed, job)
voiceover = (normalized or {}).get("voiceover_script") or ""
voiceover_len = len(voiceover)
shots_cnt = len((normalized or {}).get("shots") or [])
# 判定是否"退化到兜底质量":口播过短(<20字)或镜头数<1;正常的短口播(如15s视频~40字)不视为兜底
# v1.6.1 P1修复:递归替换 copy_result 里所有字符串字段的"很近"→"最近"(覆盖 overview/scene_and_lighting/voiceover/shots.* 全部字段)
_before_dump = json.dumps(normalized, ensure_ascii=False)
if "很近" in _before_dump:
logger.warning("[爆款视频] 编导脚本含错别字'很近',递归替换为'最近' label=%s", label)
normalized = _replace_henjin_everywhere(normalized)
voiceover = (normalized or {}).get("voiceover_script") or ""
fallback_marker = "我最近在用的好物" in voiceover # _fallback_script 的特征串
has_typo_henjin = "很近" in json.dumps(normalized, ensure_ascii=False) # 递归检查仍有"很近"视为不合格
is_fallback = fallback_marker or shots_cnt < 1 or voiceover_len < 20 or has_typo_henjin
logger.info(
"[爆款视频] 编导脚本结果 label=%s voiceover_len=%d shots=%d fallback=%s raw_type=%s",
label,
voiceover_len,
shots_cnt,
is_fallback,
type(r).__name__,
)
if is_fallback:
return None # 触发重试
return normalized
try:
# 第一次:快模型 25s
normalized = _try_gen(_fast, 0.8, 2500, "fast-first", tmo=25)
if normalized is not None:
return normalized
# 第二次:快模型降温度+加大 max_tokens,25s
normalized = _try_gen(_fast, 0.6, 3200, "fast-retry", tmo=25)
if normalized is not None:
return normalized
# 第三次:用主力模型兜底,给 40s
if _pro and _pro != _fast:
normalized = _try_gen(_pro, 0.7, 3500, "pro-fallback", tmo=40)
if normalized is not None:
return normalized
logger.warning("[爆款视频] 编导脚本三次都未生成合格结果,使用兜底脚本")
return _fallback_script(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: 需要修改的问题列表(如有)"""
_s = get_shared_settings()
_fast = _s.doubao_fast_model
_pro = _s.doubao_model
for _m, _lbl in [(_fast, "fast"), (_pro, "pro-fallback")]:
try:
logger.info("[爆款视频] 合规审核 model=%s label=%s", _m, _lbl)
result = call_llm(prompt, temperature=0.1, max_tokens=500, model=_m, timeout=20)
return result if isinstance(result, dict) else {"passed": True, "score": 80, "details": {}}
except Exception as e:
logger.warning("[爆款视频] 合规审核失败 label=%s err=%s", _lbl, e)
continue
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 "").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) -> tuple[str, dict | None]:
"""步骤 6: v1.6 单次 Seedance 生成(不再分段/拼接)。
返回 (本地视频路径, usage dict|None)。失败抛异常。
"""
from packages.domain.points_rules import get_viral_video_model_config
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
resolution = getattr(job, "video_resolution", "720p") or "720p"
# 按模型配置决定是否开启音频生成(#2159 多模型支持)
_mcfg = get_viral_video_model_config(model)
gen_audio = bool(_mcfg.get("supports_audio", True))
# 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(
"[爆款视频] 开始单次视频生成 dur=%ds ratio=%s model=%s provider=%s gen_audio=%s ref_imgs=%d ref_audios=%d ref_videos=%d tmpdir=%s",
dur,
ratio if not first_image else "(follow-image)",
model or "default",
_mcfg.get("provider", "doubao"),
gen_audio,
len(rest_images) + (1 if first_image else 0),
len(ref_audios),
len(ref_videos),
tmpdir,
)
logger.info("[爆款视频] Seedance prompt (前300字): %s", prompt[:300])
# #2172: 使用信任链预热结果(阶段1已在后台并行 Seedream AI 化)
pre_trusted = None
all_portrait_urls = []
if first_image:
all_portrait_urls.append(first_image)
for u in rest_images:
if u not in all_portrait_urls:
all_portrait_urls.append(u)
pti = getattr(job, "pre_trusted_images", None)
if pti and len(pti) == len(all_portrait_urls):
pre_trusted = list(pti)
logger.info("[爆款视频] 使用信任链预热结果 n=%d,跳过现场 Seedream AI 化", len(pre_trusted))
elif all_portrait_urls and _mcfg.get("provider", "doubao") == "doubao":
logger.info(
"[爆款视频] 预热结果不可用(%s/%d张),将现场跑信任链",
"缺失" if not pti else f"{len(pti)}/{len(all_portrait_urls)}",
len(all_portrait_urls),
)
# 第一次调用:带参考图/首帧/音频/参考视频
result = call_video_generation(
prompt=prompt,
image_url=first_image,
duration=dur,
ratio=ratio,
resolution=resolution,
output_dir=str(tmpdir),
model=model,
generate_audio=gen_audio, # 按模型能力:有声模型走原生音画同生;Wan 等需后配 TTS
reference_images=rest_images,
reference_audios=ref_audios,
reference_videos=ref_videos,
pre_trusted_images=pre_trusted,
)
# #2170: 真人/肖像拦截由 ai_client 内部信任链自动处理(Seedream AI 化后再调 Seedance);
# 非拦截类错误直接抛错给上层展示用户友好提示。
def _check_and_reraise(result):
if result and isinstance(result, dict):
return result
from packages.shared.ai_service import get_last_video_error
err = get_last_video_error() or {}
user_msg = err.get("user_message") or ""
detail = err.get("detail") or ""
err_code = err.get("error_code") or "unknown"
status_code = err.get("status_code", 0)
err_provider = err.get("provider") or _mcfg.get("provider", "doubao")
err_msg = user_msg or f"视频生成失败({err_provider} status={status_code} code={err_code})"
logger.error(
"[爆款视频] 视频生成失败: provider=%s model=%s code=%s status=%s user_msg=%s detail=%s",
err_provider,
model or "default",
err_code,
status_code,
user_msg,
(detail or "")[:500],
)
raise RuntimeError(err_msg)
if not result or not isinstance(result, dict):
_check_and_reraise(result)
video_path = result.get("video_path") or ""
usage = result.get("usage")
if not video_path or not Path(video_path).exists() or Path(video_path).stat().st_size == 0:
raise RuntimeError("视频生成失败:返回空文件或路径不存在")
# #2170: 统一走方舟 Seedance(含信任链),usage 里的 provider/model_key 用于积分结算;
# 信任链产生的 Seedream 图成本已在利润率中覆盖,不单独结算。
size = Path(video_path).stat().st_size
logger.info("[爆款视频] 单次生成完成: path=%s size=%d usage=%s", video_path, size, usage)
return str(video_path), (usage if isinstance(usage, dict) else None)
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
def _wait_oss_ready(url: str, timeout_sec: int = 10) -> bool:
"""轮询 OSS 公网 URL,直到 HEAD 返回 200 或超时。
用于缓解 OSS 上传后 1-5s 公网 eventual consistency 导致的 NoSuchKey。
"""
import httpx
deadline = time.monotonic() + timeout_sec
last_status = 0
while time.monotonic() < deadline:
try:
r = httpx.head(url, follow_redirects=True, timeout=3.0)
last_status = r.status_code
if r.status_code == 200 and int(r.headers.get("content-length", "0") or 0) > 0:
return True
except Exception as e:
logger.debug("[爆款视频] OSS head 轮询失败: %s", e)
time.sleep(1.0)
logger.warning("[爆款视频] OSS 成片在 %ds 内未就绪 last_status=%s url=%s", timeout_sec, last_status, url[:120])
return False
# ── 主编排器 ────────────────────────────────────────────────────────────
@shared_task(
bind=True,
max_retries=2,
name="worker.run_viral_video_pipeline",
soft_time_limit=900, # 15min(完整流水线)
time_limit=960,
)
def run_viral_video_pipeline(self: Task, job_id: str) -> dict:
"""旧一键流水线(保留兼容):图片分析→风格分析→意图解析→WAIT_USER_CONFIRM。"""
session = None
_hb_stop = None
try:
_recover_stale_jobs() # 顺带回收僵尸任务
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)
_hb_stop, _hb_thread = _start_heartbeat_thread(job_id)
_set_stage(job, repo, session, ViralVideoStage.IMAGE_ANALYSIS, "正在分析商品特征...")
image_analysis = _step_image_analysis(job)
job.image_analysis = image_analysis
# #2174: VLM完成后立即启动信任链t2i预热(从VLM结果提取人物描述),与文案阶段并行
if job.images:
try:
_products = (image_analysis or {}).get("products", []) or []
_portrait_descs = [
(p.get("portrait_prompt") or "无人像") for p in _products
] if _products else []
# 兼容单图结果格式(非products列表)
if not _portrait_descs and isinstance(image_analysis, dict):
_pp = image_analysis.get("portrait_prompt") or "无人像"
if _pp and _pp != "无人像":
_portrait_descs = [_pp]
_start_trust_chain_preheat(job.id, _portrait_descs)
except Exception as _e:
logger.warning("[爆款视频][阶段1] 启动信任链t2i预热失败: %s", _e)
_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:
_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})
_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,
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 _hb_stop is not None:
_hb_stop.set()
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:
"""标记任务失败并通知。若传入的 session 已失效(因前面异常导致 rollback 状态),
会自动 fallback 到新建 SessionLocal 重新标记,确保状态一定落库。"""
try:
# 尝试用传入的 session 标记
marked = False
if job is not None and not job.is_terminal and session is not None:
try:
job.mark_failed(err_msg)
_save_job(repo, job, session)
marked = True
except Exception as se:
logger.warning("[爆款视频] 用原 session 标记失败失败,fallback 新session: %s", se)
try:
session.rollback()
except Exception:
pass
if not marked:
# fallback:新建独立 session 重新标记(保证状态一定落库)
ssn = SessionLocal()
try:
r = SQLAlchemyViralVideoJobRepository(ssn)
j = r.get(job_id)
if j is not None and not j.is_terminal:
j.mark_failed(err_msg)
r.update(j)
ssn.commit()
finally:
ssn.close()
except Exception as inner:
logger.warning("[爆款视频] 标记失败状态时出错(最终fallback也失败): %s", inner, exc_info=True)
_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",
soft_time_limit=240, # 4min(VLM 并行分析)
time_limit=300,
)
def run_viral_video_analyze(self: Task, job_id: str) -> dict:
"""v1.5+ 阶段1:图片 VLM 分析 + 可选视频风格分析。"""
session = None
_hb_stop = None
try:
_recover_stale_jobs() # 顺带回收僵尸任务
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)
_hb_stop, _hb_thread = _start_heartbeat_thread(job_id)
_set_stage(job, repo, session, ViralVideoStage.IMAGE_ANALYSIS, "正在分析商品特征...")
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:
_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,
90.0,
"风格分析完成",
{"style_analyzed": True, "style_guide": style_guide},
)
job.mark_image_analyzed()
job.current_stage = ViralVideoStage.IMAGE_ANALYSIS
job.phase_message = "图片分析完成,请填写营销参数以生成编导脚本"
_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 _hb_stop is not None:
_hb_stop.set()
if session:
session.close()
@shared_task(
bind=True,
max_retries=1,
name="worker.run_viral_video_generate_copy",
soft_time_limit=360, # #2173: 6min(编导脚本含意图+三级重试+审核,fast超时转pro)
time_limit=420, # #2173: 7min hard limit
)
def run_viral_video_generate_copy(self: Task, job_id: str) -> dict:
"""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)
- v1.6.1: lite VLM 15s 快速失败,pro 25s;三级编导重试
"""
session = None
_hb_stop = None
try:
_recover_stale_jobs() # 顺带回收僵尸任务
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}"}
job.touch_heartbeat()
_save_job(repo, job, session)
_hb_stop, _hb_thread = _start_heartbeat_thread(job_id)
# 阶段:意图解析
_set_stage(job, repo, session, ViralVideoStage.INTENT_PARSING, "正在解析文案意图...")
image_analysis = job.image_analysis or {"products": []}
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, "意图解析完成")
# 阶段:编导脚本生成(核心耗时环节,已用快模型)
_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,
85.0,
"分镜脚本生成完成",
{"shots": len(copy_result.get("shots", []))},
)
# 合规审核后置:不再阻塞前端返回;在阶段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)
_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 _hb_stop is not None:
_hb_stop.set()
if session:
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 _try_refund_viral_video(job: ViralVideoJob) -> None:
"""爆款视频生成失败:若已预扣积分则全额退款。"""
try:
from packages.shared import get_shared_settings
_s = get_shared_settings()
if not _s.points_enabled:
return
prepaid = float(getattr(job, "credits_prepaid", 0) or 0)
if prepaid <= 0:
return
from packages.domain.points_service import PointsService
svc = PointsService()
# 使用独立 session(避免污染外层事务)
ssn = SessionLocal()
try:
svc.refund_viral_video(
job.user_id,
prepaid,
getattr(job, "credits_transaction_id", "") or "",
ssn,
)
job.credits_prepaid = 0.0
finally:
ssn.close()
except Exception:
logger.exception("[爆款视频] 失败退款异常 job_id=%s", job.id)
def _settle_viral_video(job: ViralVideoJob, usage: dict | None) -> None:
"""爆款视频生成成功:按实际 usage 结算,多退少补,写 credits_cost。"""
try:
from packages.shared import get_shared_settings
_s = get_shared_settings()
if not _s.points_enabled:
job.credits_cost = 0.0
job.credits_prepaid = 0.0
return
prepaid = float(getattr(job, "credits_prepaid", 0) or 0)
if prepaid <= 0:
job.credits_cost = 0.0
return
from packages.domain.points_rules import calculate_viral_video_credits, resolve_video_dimensions
from packages.domain.points_service import PointsService
w, h = resolve_video_dimensions(
getattr(job, "video_resolution", "720p") or "720p",
getattr(job, "video_ratio", "9:16") or "9:16",
)
fps = 24
duration = int(getattr(job, "duration", 15) or 15)
est_tokens = duration * w * h * fps // 1024
actual_tokens = None
if isinstance(usage, dict):
at = usage.get("completion_tokens")
if isinstance(at, (int, float)) and at > 0:
actual_tokens = int(at)
diff_pct = None
if actual_tokens and est_tokens > 0:
diff_pct = (actual_tokens - est_tokens) * 100.0 / est_tokens
logger.info(
"[爆款视频] tokens估算vs实际 job_id=%s duration=%s %sx%s model=%s est=%s actual=%s diff=%.1f%%",
job.id,
duration,
w,
h,
getattr(job, "video_model", "seedance-2.5"),
est_tokens,
actual_tokens,
diff_pct if diff_pct is not None else 0.0,
)
actual_credits = calculate_viral_video_credits(
duration,
w,
h,
getattr(job, "video_model", "") or "seedance-2.5",
actual_tokens=actual_tokens,
)
svc = PointsService()
ssn = SessionLocal()
try:
svc.settle_viral_video(
job.user_id,
prepaid,
actual_credits,
getattr(job, "credits_transaction_id", "") or "",
ssn,
)
job.credits_cost = actual_credits
job.credits_prepaid = 0.0
finally:
ssn.close()
except Exception:
logger.exception("[爆款视频] 积分结算异常 job_id=%s", job.id)
# 结算异常不阻塞任务完成:保守按预扣值记 credits_cost
job.credits_cost = float(getattr(job, "credits_prepaid", 0) or 0)
job.credits_prepaid = 0.0
def _run_render_pipeline(job_id: str, session, repo, job) -> dict:
"""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(旧数据/失败重试),现场补生成(意图+脚本,不走 LLM 审核,出片前会统一做)
copy_result = job.copy_result
if not isinstance(copy_result, dict) or not copy_result:
_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)
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 整段合成
_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(失败自动退款)
_set_stage(job, repo, session, ViralVideoStage.RENDERING, "正在生成视频(约1-3分钟)...")
video_path = None
usage = None
try:
video_path, usage = _step_render(job, copy_result, tts_url)
except Exception as e:
logger.error("[爆款视频][阶段3] Seedance 生成失败,触发退款: %s", e, exc_info=True)
# 退款
_try_refund_viral_video(job)
raise
_emit_progress(job_id, ViralVideoStage.RENDERING, 92.0, "视频生成完成")
# Step 7: Upload
_set_stage(job, repo, session, ViralVideoStage.UPLOADING, "正在上传视频...")
video_url = _step_upload(job, video_path)
# P2-2 OSS 一致性:上传后循环 head 确认公网可访问(最多等 10s),
# 避免前端拿到 completed 立即下载时命中 NoSuchKey。
if video_url:
_wait_oss_ready(video_url, timeout_sec=10)
# 积分结算:按实际 tokens 多退少补
_settle_viral_video(job, usage)
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(
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:
_recover_stale_jobs() # 顺带回收僵尸任务
_hb_stop = None
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}"}
job.touch_heartbeat()
_save_job(repo, job, session)
_hb_stop, _hb_thread = _start_heartbeat_thread(job_id)
return _run_render_pipeline(job_id, session, repo, job)
except Retry:
raise
except Exception as e:
logger.error("[爆款视频][阶段3] 异常: %s", e, exc_info=True)
# 兜底:任何阶段3异常都尝试退款(_step_render 内部异常已经退过,但 upload 等后续失败也需退)
try:
if session is not None:
job_safe = None
try:
repo_safe = SQLAlchemyViralVideoJobRepository(session)
job_safe = repo_safe.get(job_id)
except Exception:
pass
if job_safe is not None and float(getattr(job_safe, "credits_prepaid", 0) or 0) > 0:
_try_refund_viral_video(job_safe)
try:
repo_safe.update(job_safe)
except Exception:
pass
except Exception:
logger.exception("[爆款视频][阶段3] 兜底退款异常")
_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 _hb_stop is not None:
_hb_stop.set()
if session:
session.close()