Files
xiaoxia-saas/apps/worker/worker_app/tasks/viral_video.py
T
Xiaoxia Agent 4e0cc6c19d
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 2s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 2s
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 / 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
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m49s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m3s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 3m0s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 4m31s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 5m7s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 5m54s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 6m12s
AI Code Review / AI Code Review (pull_request) Successful in 7m1s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 8m3s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 12m4s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 9s
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 13m5s
fix(viral_video): #2040 修复 style check F401/F821 导入问题
- _step_image_analysis/_step_intent_parsing 的局部导入补 render_system_prompt(F821 未定义名)
- _step_script_generation 的冗余 render_system_prompt 导入删除(F401 未使用)

black/isort/ruff 全绿,viral_video 单测 150/150 通过。
2026-10-04 18:25:04 +08:00

2045 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 re
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": "",
}
# ── 流水线各步骤 ────────────────────────────────────────────────────────
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 分析(#2040:改为从 prompt_loader 读模板 + XML 解析)。
lite 失败/不可用时用 pro 降级重试 1 次。失败/None 最终返回含默认字段的 dict。
"""
try:
from packages.application.viral_video import xml_parser as xp
from packages.application.viral_video.prompt_loader import (
get_template,
render_system_prompt,
render_user_prompt,
)
from packages.shared.ai_service import call_vision
except ImportError as e:
logger.warning("[爆款视频] prompt 模板/解析模块不可用: %s", e)
return _vision_fallback(idx, f"fallback_import_error:{e}")
if not img_url or not isinstance(img_url, str):
return _vision_fallback(idx, "invalid_url")
template = get_template("image_analysis")
system = render_system_prompt(template)
user = render_user_prompt(
template,
image_count=1,
industry="通用",
image_urls=f"第1张:{img_url}",
)
def _call(model: str, tmo: int):
try:
return call_vision(
image_url=img_url,
prompt=user,
model=model,
max_tokens=2048,
temperature=0.3,
timeout=tmo,
system_prompt=system,
)
except Exception as e:
logger.warning("[爆款视频] 图片 #%d call_vision(%s) 异常 err=%s", idx, model, e)
return None
def _xml_to_product(nodes: list, raw_text: str) -> dict:
product_nodes = [n for n in nodes if n["tag"] == "product"]
scene = xp.text_of(raw_text, "scene") or "通用"
mood = xp.text_of(raw_text, "mood") or ""
for p in product_nodes:
a = p["attrs"]
text_on_pkg = a.get("text_on_package", "")
p_body = p.get("text", "") or ""
if not text_on_pkg and p_body:
text_on_pkg = xp.text_of(p_body, "text_on_package") or ""
text_list = [x.strip() for x in re.split(r"[,,;;]", text_on_pkg) if x.strip()] if text_on_pkg else []
features = a.get("features", "")
feat_list = [x.strip() for x in re.split(r"[,,;;]", features) if x.strip()] if features else []
name = a.get("name", "") or "未识别"
brand = a.get("brand", "") or "无法判断"
category = a.get("category", "") or "无法判断"
appearance = a.get("appearance", "") or "无法判断"
packaging = a.get("packaging", "") or "无法判断"
summary = a.get("summary", "") or f"{brand} {name}"
return {
"name": name,
"brand": brand,
"category": category,
"appearance": appearance,
"packaging": packaging,
"text_on_package": text_list,
"key_features": feat_list or [features] if features else ["无法判断"],
"scene": scene,
"mood": mood,
"portrait_prompt": a.get("portrait_prompt", "无人像"),
"summary": summary,
"_source": "xml",
}
return _vision_fallback(idx, "no_product_tag")
def _normalize(raw, source: str) -> dict:
if raw is None:
return _vision_fallback(idx, f"{source}_none")
if not isinstance(raw, str):
return _vision_fallback(idx, f"{source}_badtype")
nodes = xp.parse_tags(raw)
if not nodes:
logger.warning("[爆款视频] 图片 #%d XML 解析失败 source=%s", idx, source)
return _vision_fallback(idx, f"{source}_xml_fail", {"_raw": raw[:500]})
product = _xml_to_product(nodes, raw)
product.setdefault("_source", source)
product["raw"] = raw[:500]
return product
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
if pro_fallback_model and pro_fallback_model != vision_model:
pro_raw = _call(pro_fallback_model, 25)
pro_result = _normalize(pro_raw, "pro_fallback")
if _is_vision_result_usable(pro_result):
pro_result["_fallback_used"] = True
return pro_result
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: 用户文案意图解析(#2040:改为模板 + XML 解析)。"""
try:
from packages.application.viral_video import xml_parser as xp
from packages.application.viral_video.prompt_loader import (
get_template,
render_system_prompt,
render_user_prompt,
)
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"
template = get_template("intent_parsing")
system = render_system_prompt(template)
user = render_user_prompt(
template,
user_copy_text=job.user_copy_text or "(未提供,全由 AI 创作)",
industry=job.industry or "未指定",
image_analysis=products_summary or "- (无图片分析结果)",
)
def _parse(raw: str) -> dict:
summary = xp.text_of(raw, "intent_summary")
msgs = [n["text"] for n in xp.find_all(raw, "message") if n["text"]]
tone = xp.text_of(raw, "emotion_tone") or "亲切自然"
title = xp.text_of(raw, "suggested_title") or xp.text_of(raw, "title")
return {
"intent": summary or "推广产品",
"key_messages": msgs or ["产品亮点"],
"tone": tone,
"suggested_title": title,
}
def _fallback(raw: str) -> dict:
t = (job.user_copy_text or "").strip()
return {
"intent": t[:30] or "推广产品",
"key_messages": [t[:80]] if t else ["产品亮点"],
"tone": "专业",
"suggested_title": "",
}
_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)
raw = call_llm(
[{"role": "system", "content": system}, {"role": "user", "content": user}],
temperature=0.4,
max_tokens=1024,
model=_m,
timeout=25,
)
if not raw:
continue
parsed = _parse(raw)
if parsed["intent"] or parsed["key_messages"]:
return parsed
except Exception as e:
logger.warning("[爆款视频] 意图解析失败 label=%s err=%s", _lbl, e)
return _fallback("")
_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 "【人设风格:未指定】亲切自然、像朋友分享好物"
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 _script_from_xml(raw: str, job: ViralVideoJob) -> dict | None:
"""把 LLM 返回的 XML 分镜规范化为旧 copy_result 结构(供 Seedance 使用)。"""
from packages.application.viral_video import xml_parser as xp
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 raw:
return None
base["overview"]["theme"] = xp.text_of(raw, "overview_theme") or xp.text_of(raw, "title") or "好物分享"
est = xp.attr_int(xp.text_of(raw, "estimated_duration"), 0)
if est:
base["overview"]["total_duration"] = est
sl = xp.text_of(raw, "scene_and_lighting")
if sl:
base["scene_and_lighting"] = sl
clips = xp.find_all(raw, "clip")
shots: list[dict] = []
voice_parts: list[str] = []
for i, c in enumerate(clips):
a = c["attrs"]
body = c.get("text", "") or ""
ref_idx_raw = a.get("reference_image_index", "")
if ref_idx_raw in (None, "", "null", "None"):
body_ref = xp.text_of(body, "reference_image_index") if body else ""
ref_idx = xp.attr_int(body_ref, 0) if body_ref else None
else:
ref_idx = xp.attr_int(ref_idx_raw, 0)
shot = {
"time_range": a.get("time_range") or f"{i * 3}-{(i + 1) * 3}秒",
"shot_type_angle_movement": (xp.text_of(body, "shot_type_angle_movement") if body else "")
or a.get("shot_type_angle_movement", "")
or "中景平视,固定镜头",
"scene_and_dialogue": (xp.text_of(body, "scene_and_dialogue") if body else "") or "",
"action_details": (xp.text_of(body, "action_details") if body else "") or "",
"audio_bgm": (xp.text_of(body, "audio_bgm") if body else "") or a.get("bgm_note", "") or "轻快BGM",
"transition": (xp.text_of(body, "transition") if body else "")
or a.get("transition", "")
or ("硬切" if i < len(clips) - 1 else "结束"),
"reference_image_index": ref_idx,
}
voice = xp.text_of(body, "voice_text") if body else ""
if voice:
voice_parts.append(voice)
if not shot["scene_and_dialogue"]:
shot["scene_and_dialogue"] = voice
shots.append(shot)
if not shots:
return None
base["shots"] = shots
joined = xp.text_of(raw, "voiceover_script") or "。".join(voice_parts)
base["voiceover_script"] = joined
base["final_copy"] = joined
base["suggested_copy"] = joined
base["title"] = base["overview"]["theme"]
return base
def _step_script_generation(job: ViralVideoJob, intent: dict, image_analysis: dict) -> dict:
"""步骤 3: 编导分镜脚本生成(#2040:模板 + XML 解析;输出 copy_result 结构)。"""
try:
from packages.application.viral_video.prompt_loader import (
get_template,
render_user_prompt,
)
from packages.application.viral_video.prompts import (
FUSION_INSTRUCTIONS,
GLOBAL_CONSTRAINTS,
NEGATIVE_RULES,
)
from packages.shared.ai_service import call_llm
except ImportError:
return _fallback_script(job)
products_summary = _build_products_summary(image_analysis)
if isinstance(job.style_guide, dict):
(
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)))
getattr(job, "video_ratio", None) or "9:16"
intent_str = "推广产品"
key_msgs = "产品亮点"
tone = "亲切自然"
if isinstance(intent, dict):
intent_str = intent.get("intent") or intent_str
key_msgs = "、".join(intent.get("key_messages") or []) or key_msgs
tone = intent.get("tone") or tone
_vs = (job.viral_structure or "").strip()
if _vs:
pass
else:
pass
_persona_style_hint(getattr(job, "persona_id", ""))
fusion_level = getattr(job, "fusion_level", "ai_polish") or "ai_polish"
fusion_instruction = FUSION_INSTRUCTIONS.get(fusion_level, FUSION_INSTRUCTIONS["ai_polish"])
# 使用 storyboard 模板,注入融合指令/硬约束/反套路词
template = get_template("storyboard")
system_tpl = template.system_prompt
system_tpl = system_tpl.replace("{fusion_instruction}", fusion_instruction)
system_tpl = system_tpl.replace("{global_constraints}", GLOBAL_CONSTRAINTS)
system_tpl = system_tpl.replace("{negative_rules}", NEGATIVE_RULES)
fusion_brief = (
f"意图:{intent_str}\n关键信息:{key_msgs}\n调性:{tone}\n"
f"用户原文:{job.user_copy_text or '(未提供)'}\n创作模式:{fusion_level}"
)
user = render_user_prompt(
template,
duration=dur,
image_count=len(job.images or []),
fusion_result=fusion_brief,
image_analysis=products_summary,
)
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)
raw = call_llm(
[{"role": "system", "content": system_tpl}, {"role": "user", "content": user}],
temperature=temp,
max_tokens=max_tok,
model=model,
timeout=tmo,
)
if not raw:
return None
normalized = _script_from_xml(raw, job)
if normalized is None:
# 兼容:万一 LLM 仍输出 JSON,走旧规范化
parsed_json = _safe_json_loads(raw)
if isinstance(parsed_json, dict):
normalized = _validate_and_normalize_script(parsed_json, job)
else:
return None
voiceover = (normalized or {}).get("voiceover_script") or ""
shots_cnt = len((normalized or {}).get("shots") or [])
_before_dump = json.dumps(normalized, ensure_ascii=False)
if "很近" in _before_dump:
normalized = _replace_henjin_everywhere(normalized)
voiceover = (normalized or {}).get("voiceover_script") or ""
fallback_marker = "我最近在用的好物" in voiceover
has_typo_henjin = "很近" in json.dumps(normalized, ensure_ascii=False)
is_fallback = fallback_marker or shots_cnt < 1 or len(voiceover) < 20 or has_typo_henjin
logger.info(
"[爆款视频] 编导脚本结果 label=%s voiceover_len=%d shots=%d fallback=%s",
label,
len(voiceover),
shots_cnt,
is_fallback,
)
return None if is_fallback else normalized
_s = get_shared_settings()
_fast = _s.doubao_fast_model
_pro = getattr(_s, "doubao_model", None) or _fast
try:
normalized = _try_gen(_fast, 0.8, 2500, "fast-first", tmo=25)
if normalized is not None:
return normalized
normalized = _try_gen(_fast, 0.6, 3200, "fast-retry", tmo=25)
if normalized is not None:
return normalized
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: 合规审核(#2040:使用 Reviewer + review 模板,6 维度 + 自动重写 1 次)。
返回结构与旧版兼容:{passed, score, details, issues, rewritten_copy?}
"""
try:
from packages.application.viral_video.reviewer import Reviewer
from packages.application.viral_video.schemas import (
CoreMessage,
FusionResult,
IntentResult,
PersonalBrand,
ScriptSegment,
)
except ImportError as e:
logger.warning("[爆款视频] reviewer 模块不可用,跳过审核: %s", e)
return {"passed": True, "score": 80, "details": {}, "issues": []}
voiceover = (copy_result or {}).get("voiceover_script", "") or ""
title = (copy_result or {}).get("title") or (copy_result or {}).get("overview", {}).get("theme", "")
intent_data = job.intent_result or {}
core_msgs = [
CoreMessage(text=str(m), must_keep=True, confidence=0.9) for m in (intent_data.get("key_messages") or [])
]
brands: list[PersonalBrand] = []
brand_text = intent_data.get("brand_text") or intent_data.get("suggested_title") or ""
if brand_text:
brands.append(PersonalBrand(text=str(brand_text), category="brand"))
intent_obj = IntentResult(
intent_summary=intent_data.get("intent", "") or "推广产品",
core_messages=core_msgs,
personal_brands=brands,
)
fusion_obj = FusionResult(
title=title or "",
hook=(voiceover[:30] if voiceover else ""),
cta="",
script_segments=[],
word_count=len(voiceover),
estimated_duration=int(getattr(job, "duration", 15) or 15),
)
for shot in (copy_result or {}).get("shots", []) or []:
if isinstance(shot, dict) and shot.get("scene_and_dialogue"):
fusion_obj.script_segments.append(ScriptSegment(text=shot["scene_and_dialogue"]))
fusion_level = getattr(job, "fusion_level", "ai_polish") or "ai_polish"
try:
reviewer = Reviewer()
review_res = reviewer.review(fusion_obj, intent_obj, fusion_level)
new_copy = copy_result
rewritten_voice = None
if not review_res.passed and review_res.rewrite_suggestions:
try:
rewritten = reviewer.rewrite(fusion_obj, review_res, intent_obj, fusion_level)
if rewritten and (rewritten.title or rewritten.script_segments):
new_voice = (
rewritten.script_segments[0].text
if rewritten.script_segments
else (rewritten.hook or voiceover)
)
new_copy = dict(copy_result)
new_copy["voiceover_script"] = new_voice
new_copy["final_copy"] = new_voice
new_copy["suggested_copy"] = new_voice
if rewritten.title:
new_copy.setdefault("overview", {})["theme"] = rewritten.title
new_copy["title"] = rewritten.title
rewritten_voice = new_voice
review_res = reviewer.review(rewritten, intent_obj, fusion_level)
except Exception as e:
logger.warning("[爆款视频] 自动重写失败: %s", e)
result = {
"passed": review_res.passed,
"score": 90 if review_res.passed else 60,
"details": {i.dimension: i.text for i in review_res.issues},
"issues": [
{"dimension": i.dimension, "severity": i.severity, "location": i.location, "text": i.text}
for i in review_res.issues
],
}
if rewritten_voice is not None:
result["rewritten_copy"] = new_copy
job.copy_result = new_copy
job.generated_copy_text = rewritten_voice
return result
except Exception as e:
logger.warning("[爆款视频] 审核异常,跳过: %s", e, exc_info=True)
return {"passed": True, "score": 75, "details": {}, "issues": []}
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, "审核未通过,正在自动重写...")
# #2040: Reviewer 已在 _step_review 内完成 1 次自动重写
rewritten = review_result.get("rewritten_copy")
if isinstance(rewritten, dict) and rewritten:
copy_result = rewritten
else:
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()