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
- _step_image_analysis/_step_intent_parsing 的局部导入补 render_system_prompt(F821 未定义名) - _step_script_generation 的冗余 render_system_prompt 导入删除(F401 未使用) black/isort/ruff 全绿,viral_video 单测 150/150 通过。
2045 lines
86 KiB
Python
2045 lines
86 KiB
Python
"""爆款视频 Celery 编排器 — ViralVideoOrchestrator (v1.6 单次 Seedance 出片版).
|
||
|
||
v1.6 重大简化(Seedance 2.5 单次最长 30 秒,直接出片):
|
||
1. _step_image_analysis 图片 VLM 分析(保留)
|
||
1.5 _step_video_analysis 参考视频风格分析(可选)
|
||
2. _step_intent_parsing 用户文案意图解析
|
||
3. _step_script_generation 编导分镜脚本生成(融合原 copy_fusion+storyboard+review,输出 copy_result 结构 + voiceover_script)
|
||
4. _step_review 合规审核(6 维度,不通过自动重写 1 次)
|
||
5. _step_tts CosyVoice 整段配音(voiceover_script → 单个 mp3 → 上传 OSS 拿公网 URL)
|
||
6. _step_render 单次 Seedance 生成(prompt=完整编导脚本,reference_audios=[TTS URL],reference_images=产品图,generate_audio=true)
|
||
7. _step_upload OSS 上传单个视频文件 + 通知 + 扣点
|
||
|
||
删除/不再使用:
|
||
- 分镜拆分多段生成(storyboard 不再单独驱动分段生成,仅作为 copy_result.shots 存到 DB 给前端/日志参考)
|
||
- ffmpeg concat 拼接(concat_engine 保留但 viral video 主流程不再调用)
|
||
- placeholder 占位视频、分段重试降级
|
||
- BGM 单独混音(Seedance generate_audio=true 原生生成环境音效/BGM)
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import logging
|
||
import os
|
||
import 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()
|