c08cfcd5c1
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 3s
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 4s
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 / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 33s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 3m26s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m37s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 1m5s
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Style (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Security (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been cancelled
CI/CD Pipeline / Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / Integration Tests (pull_request) Has been cancelled
AI Code Review / AI Code Review (pull_request) Has been cancelled
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been cancelled
上线后可通过日志对比 formula 预估 tokens 与 Seedance 返回的 completion_tokens,误差应在 10% 以内(视频编码余量)。
1790 lines
77 KiB
Python
1790 lines
77 KiB
Python
"""爆款视频 Celery 编排器 — ViralVideoOrchestrator (v1.6 单次 Seedance 出片版).
|
||
|
||
v1.6 重大简化(Seedance 2.5 单次最长 30 秒,直接出片):
|
||
1. _step_image_analysis 图片 VLM 分析(保留)
|
||
1.5 _step_video_analysis 参考视频风格分析(可选)
|
||
2. _step_intent_parsing 用户文案意图解析
|
||
3. _step_script_generation 编导分镜脚本生成(融合原 copy_fusion+storyboard+review,输出 copy_result 结构 + voiceover_script)
|
||
4. _step_review 合规审核(6 维度,不通过自动重写 1 次)
|
||
5. _step_tts CosyVoice 整段配音(voiceover_script → 单个 mp3 → 上传 OSS 拿公网 URL)
|
||
6. _step_render 单次 Seedance 生成(prompt=完整编导脚本,reference_audios=[TTS URL],reference_images=产品图,generate_audio=true)
|
||
7. _step_upload OSS 上传单个视频文件 + 通知 + 扣点
|
||
|
||
删除/不再使用:
|
||
- 分镜拆分多段生成(storyboard 不再单独驱动分段生成,仅作为 copy_result.shots 存到 DB 给前端/日志参考)
|
||
- ffmpeg concat 拼接(concat_engine 保留但 viral video 主流程不再调用)
|
||
- placeholder 占位视频、分段重试降级
|
||
- BGM 单独混音(Seedance generate_audio=true 原生生成环境音效/BGM)
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import logging
|
||
import os
|
||
import tempfile
|
||
import threading
|
||
import time
|
||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||
from pathlib import Path
|
||
|
||
from celery import Task, shared_task
|
||
from celery.exceptions import Retry
|
||
from worker_app.celery_app import celery_app # noqa: F401
|
||
from worker_app.db import SessionLocal
|
||
|
||
from packages.adapters.sqlalchemy_impl.viral_video_repository import (
|
||
SQLAlchemyViralVideoJobRepository,
|
||
)
|
||
from packages.domain.viral_video import (
|
||
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 _set_stage(job, repo, session, stage: str, message: str, persist: bool = True) -> None:
|
||
"""更新细粒度阶段并持久化到 DB,同时通过 Redis 推送进度事件。
|
||
|
||
stage 用 ViralVideoStage.value(snake_case,与前端 phase 对齐)。
|
||
message 为中文提示文案,前端轮询/SSE 直接展示给用户。
|
||
"""
|
||
job.current_stage = stage or ""
|
||
job.phase_message = message or ""
|
||
_emit_progress(job.id, stage, 0.0, message)
|
||
if persist and repo is not None and session is not None:
|
||
try:
|
||
_save_job(repo, job, session)
|
||
except Exception as e: # 阶段持久化失败不阻塞主流程
|
||
logger.warning("[爆款视频] 阶段持久化失败 stage=%s err=%s", stage, e)
|
||
|
||
|
||
# ── worker 心跳(僵尸任务检测) ─────────────────────────────────────────
|
||
|
||
# 心跳间隔(秒);超过此时间未更新 heartbeat_at 视为 worker 异常
|
||
_HEARTBEAT_INTERVAL_SEC = 25
|
||
# 任务整体超时:running 超过此时长且心跳停止,则判定为僵尸并回收
|
||
_STALE_RUNNING_TIMEOUT_SEC = 10 * 60 # 10 分钟
|
||
# 心跳过期窗口:heartbeat_at 距 now 超过此时长视为失效
|
||
_HEARTBEAT_EXPIRE_SEC = 2 * 60 # 2 分钟
|
||
|
||
|
||
def _heartbeat_once(job_id: str) -> None:
|
||
"""在独立 session 中更新一次 heartbeat_at(不捕获主流程事务状态)。"""
|
||
ssn = None
|
||
try:
|
||
from datetime import datetime, timezone
|
||
|
||
ssn = SessionLocal()
|
||
ssn.execute(
|
||
__import__("sqlalchemy").text(
|
||
"UPDATE viral_video_jobs SET heartbeat_at = :now, updated_at = :now "
|
||
"WHERE id = :jid AND status = 'running'"
|
||
),
|
||
{"now": datetime.now(timezone.utc), "jid": job_id},
|
||
)
|
||
ssn.commit()
|
||
except Exception as e:
|
||
logger.warning("[爆款视频] 心跳更新失败 job=%s err=%s", job_id, e)
|
||
finally:
|
||
if ssn is not None:
|
||
try:
|
||
ssn.close()
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
def _start_heartbeat_thread(job_id: str) -> tuple[threading.Event, threading.Thread]:
|
||
"""启动后台心跳线程,每 _HEARTBEAT_INTERVAL_SEC 秒更新一次 heartbeat_at。
|
||
返回 (stop_event, thread);任务结束时调用 stop_event.set() 停止心跳。
|
||
"""
|
||
stop = threading.Event()
|
||
|
||
def _loop():
|
||
# 立即打一次心跳
|
||
_heartbeat_once(job_id)
|
||
while not stop.wait(_HEARTBEAT_INTERVAL_SEC):
|
||
_heartbeat_once(job_id)
|
||
|
||
t = threading.Thread(target=_loop, name=f"vv-heartbeat-{job_id[:8]}", daemon=True)
|
||
t.start()
|
||
return stop, t
|
||
|
||
|
||
def _recover_stale_jobs() -> int:
|
||
"""启动/定时扫描:把僵尸任务(running 超时且心跳停止)标记为 failed。
|
||
返回本次回收的任务数。可由 celery beat 周期性调用,也可在任务启动前顺带扫一次。
|
||
"""
|
||
from datetime import datetime, timedelta, timezone
|
||
|
||
ssn = None
|
||
try:
|
||
ssn = SessionLocal()
|
||
now = datetime.now(timezone.utc)
|
||
# 判定条件:status=running 且 (started_at 距今 > 10min) 且 (heartbeat_at < now-2min 或 heartbeat_at IS NULL 且 updated_at < now-2min)
|
||
cutoff_beat = now - timedelta(seconds=_HEARTBEAT_EXPIRE_SEC)
|
||
cutoff_start = now - timedelta(seconds=_STALE_RUNNING_TIMEOUT_SEC)
|
||
sql = __import__("sqlalchemy").text(
|
||
"UPDATE viral_video_jobs "
|
||
"SET status='failed', error_msg='任务执行超时,请重试', updated_at=:now "
|
||
"WHERE status='running' "
|
||
" AND started_at IS NOT NULL AND started_at < :cutoff_start "
|
||
" AND (heartbeat_at IS NULL OR heartbeat_at < :cutoff_beat) "
|
||
" AND (heartbeat_at IS NOT NULL OR updated_at < :cutoff_beat)"
|
||
)
|
||
result = ssn.execute(sql, {"now": now, "cutoff_start": cutoff_start, "cutoff_beat": cutoff_beat})
|
||
ssn.commit()
|
||
cnt = result.rowcount or 0
|
||
if cnt > 0:
|
||
logger.warning("[爆款视频] 回收 %d 个僵尸 running 任务", cnt)
|
||
return cnt
|
||
except Exception as e:
|
||
logger.warning("[爆款视频] 僵尸任务扫描失败: %s", e)
|
||
return 0
|
||
finally:
|
||
if ssn is not None:
|
||
try:
|
||
ssn.close()
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
# ── 默认结构 ─────────────────────────────────────────────────────────────
|
||
|
||
_DEFAULT_HARD_CONSTRAINTS = [
|
||
"无字幕、无水印、无任何自动生成文字、无 logo",
|
||
"同一人物全程保持一致的五官、发型、服装、身材,不得换脸或变形",
|
||
"口播语音必须在指定时长内自然念完,语速自然,口型与语音同步",
|
||
"画面流畅无闪烁、无多余肢体、无扭曲变形、无穿模",
|
||
"色彩自然、曝光正确、电影级质感、高清细节",
|
||
]
|
||
|
||
_DEFAULT_NEGATIVE_PROMPTS = [
|
||
"字幕",
|
||
"自动字幕",
|
||
"水印",
|
||
"logo",
|
||
"图标",
|
||
"错误文字",
|
||
"乱码文字",
|
||
"男女声错配",
|
||
"中途换声",
|
||
"五官崩坏",
|
||
"脸部变形",
|
||
"多余手指",
|
||
"肢体扭曲",
|
||
"闪烁",
|
||
"画面抖动",
|
||
"模糊",
|
||
"低分辨率",
|
||
]
|
||
|
||
|
||
def _empty_copy_result(duration: int = 15, ratio: str = "9:16") -> dict:
|
||
return {
|
||
"overview": {"theme": "好物推荐", "total_duration": duration, "aspect_ratio": ratio},
|
||
"scene_and_lighting": "简洁明亮的室内场景,柔和自然光,产品主体清晰",
|
||
"shots": [],
|
||
"hard_constraints": list(_DEFAULT_HARD_CONSTRAINTS),
|
||
"negative_prompts": list(_DEFAULT_NEGATIVE_PROMPTS),
|
||
"voiceover_script": "",
|
||
"final_copy": "",
|
||
"suggested_copy": "",
|
||
"title": "",
|
||
}
|
||
|
||
|
||
# ── 流水线各步骤 ────────────────────────────────────────────────────────
|
||
|
||
|
||
_IMAGE_ANALYSIS_SYSTEM_PROMPT = """你是电商商品视觉分析师,从商品图片中提取关键商品信息。严格规则:
|
||
1. 只说图片里真实可见的内容,看不清/没有的填「无法判断」,不要瞎猜。
|
||
2. 输出必须是严格 JSON(不要 Markdown 代码块,不要额外解释文字)。
|
||
3. 字段说明:
|
||
{
|
||
"name": "商品全名(品牌+产品名+规格,如『大公鸡头管家 多功能油污净 625ml』,从包装 OCR 读出)",
|
||
"brand": "品牌名(从 Logo/包装文字读出,看不清填『无法判断』)",
|
||
"category": "商品品类(如『家用清洁/油污清洁剂』『日化/洗衣液』;非产品图填『非产品图』)",
|
||
"appearance": "外观特征(50-100字:瓶身形状、颜色、瓶盖、标签颜色、尺寸感)",
|
||
"packaging": "包装细节(50-100字:标签分区、图案元素、瓶盖/泵头样式、塑封状态)",
|
||
"text_on_package": ["包装上清晰可见的文字列表(品牌、产品名、卖点、规格等,看不清的不列)"],
|
||
"key_features": [
|
||
"3-5 条图片中能看到的外观/视觉特征(如『红色瓶盖白色瓶身』『鸡头图案 Logo』等)"
|
||
],
|
||
"scene": "图片场景(如白底棚拍/浴室实拍/桌面静物/手持实拍等)",
|
||
"summary": "100-180字中文导购描述,连贯自然段落,像电商详情页介绍,前端直接展示,必须提到品牌/品名/核心外观特征,不能写『无法判断』"
|
||
}"""
|
||
|
||
_IMAGE_ANALYSIS_USER_PROMPT = """请分析这张商品图片,输出严格 JSON。重点:
|
||
1. name/brand/text_on_package 从图片包装 OCR 读取,不编造;
|
||
2. appearance/packaging 各写 50-100 字,要具体;
|
||
3. summary 必须是 100-180 字连贯中文段落,说清商品是什么、长什么样、适合谁用,不要写「无法判断」;
|
||
4. 非产品图时 category 填「非产品图」,name 填实际看到的内容;
|
||
5. 看不清的字段填「无法判断」。"""
|
||
|
||
|
||
def _vision_fallback(idx: int, reason: str, extra: dict | None = None) -> dict:
|
||
d = {
|
||
"name": "未识别",
|
||
"category": "无法判断",
|
||
"appearance": "无法判断",
|
||
"packaging": "无法判断",
|
||
"text_on_package": [],
|
||
"key_features": [],
|
||
"scene": "通用",
|
||
"summary": "",
|
||
"_source": reason,
|
||
}
|
||
if extra:
|
||
d.update(extra)
|
||
return d
|
||
|
||
|
||
def _is_vision_result_usable(result: dict) -> bool:
|
||
"""判断 VLM 返回是否有效:name/summary 不能为未识别/无法判断/空,summary 要够长。"""
|
||
if not isinstance(result, dict):
|
||
return False
|
||
name = (result.get("name") or "").strip()
|
||
if not name or name in ("未识别", "无法判断", "未知"):
|
||
return False
|
||
summary = (result.get("summary") or "").strip()
|
||
if len(summary) < 30 or summary in ("无法判断", "未识别"):
|
||
return False
|
||
category = (result.get("category") or "").strip()
|
||
if category == "非产品图":
|
||
return True
|
||
feats = result.get("key_features") or []
|
||
if not isinstance(feats, list) or len(feats) == 0:
|
||
return False
|
||
return True
|
||
|
||
|
||
def _analyze_single_image(
|
||
idx: int,
|
||
img_url: str,
|
||
vision_model: str,
|
||
timeout: int,
|
||
*,
|
||
pro_fallback_model: str | None = None,
|
||
) -> dict:
|
||
"""单张图片 VLM 分析(线程池并行调用)。
|
||
- lite 失败/结果不可用 时自动用 pro 模型降级重试 1 次。
|
||
- 失败/None/不可用最终返回含默认字段的 dict(不会让用户看到「未识别·无法判断」裸结果)。
|
||
"""
|
||
from packages.shared.ai_service import call_vision
|
||
|
||
if not img_url or not isinstance(img_url, str):
|
||
return _vision_fallback(idx, "invalid_url")
|
||
|
||
def _call(model: str, tmo: int):
|
||
try:
|
||
return call_vision(
|
||
image_url=img_url,
|
||
prompt=_IMAGE_ANALYSIS_USER_PROMPT,
|
||
model=model,
|
||
max_tokens=800,
|
||
temperature=0.1,
|
||
timeout=tmo,
|
||
system_prompt=_IMAGE_ANALYSIS_SYSTEM_PROMPT,
|
||
)
|
||
except Exception as e:
|
||
logger.warning("[爆款视频] 图片 #%d call_vision(%s) 异常 err=%s", idx, model, e)
|
||
return None
|
||
|
||
def _normalize(raw, source: str) -> dict:
|
||
if raw is None:
|
||
return _vision_fallback(idx, f"{source}_none")
|
||
if isinstance(raw, str):
|
||
logger.warning("[爆款视频] 图片 #%d VLM(%s) 返回非 JSON: %s", idx, source, raw[:200])
|
||
return _vision_fallback(idx, f"{source}_text", {"_raw": raw[:500]})
|
||
if not isinstance(raw, dict):
|
||
return _vision_fallback(idx, f"{source}_badtype")
|
||
raw.setdefault("_source", source)
|
||
raw.setdefault("name", "未识别")
|
||
raw.setdefault("brand", "无法判断")
|
||
raw.setdefault("category", "无法判断")
|
||
raw.setdefault("appearance", "无法判断")
|
||
raw.setdefault("packaging", "无法判断")
|
||
raw.setdefault("text_on_package", [])
|
||
raw.setdefault("key_features", [])
|
||
raw.setdefault("scene", "通用")
|
||
raw.setdefault("summary", "")
|
||
if not isinstance(raw.get("text_on_package"), list):
|
||
raw["text_on_package"] = []
|
||
if not isinstance(raw.get("key_features"), list):
|
||
raw["key_features"] = []
|
||
return raw
|
||
|
||
# 第一次:传入模型(通常是 lite)
|
||
first_raw = _call(vision_model, timeout)
|
||
tag1 = vision_model.split("/")[-1] if "/" in vision_model else vision_model
|
||
first_result = _normalize(first_raw, tag1)
|
||
if _is_vision_result_usable(first_result):
|
||
return first_result
|
||
|
||
logger.warning(
|
||
"[爆款视频] 图片 #%d VLM(%s) 结果不可用 name=%r summary_len=%d,尝试 pro 降级",
|
||
idx,
|
||
vision_model,
|
||
first_result.get("name"),
|
||
len(first_result.get("summary") or ""),
|
||
)
|
||
|
||
# 第二次:pro 降级重试
|
||
if pro_fallback_model and pro_fallback_model != vision_model:
|
||
pro_raw = _call(pro_fallback_model, max(60, timeout))
|
||
pro_result = _normalize(pro_raw, "pro_fallback")
|
||
if _is_vision_result_usable(pro_result):
|
||
pro_result["_fallback_used"] = True
|
||
return pro_result
|
||
logger.warning(
|
||
"[爆款视频] 图片 #%d pro 降级仍不可用 name=%r summary_len=%d",
|
||
idx,
|
||
pro_result.get("name"),
|
||
len(pro_result.get("summary") or ""),
|
||
)
|
||
return pro_result
|
||
|
||
return first_result
|
||
|
||
|
||
def _step_image_analysis(job: ViralVideoJob) -> dict:
|
||
"""步骤 1: 图片 VLM 分析 — 识别产品特征(v1.6 优化:并行 + lite 模型提速)。"""
|
||
try:
|
||
from packages.shared.ai_service import call_vision # noqa: F401
|
||
except ImportError:
|
||
logger.warning("[爆款视频] ai_service.call_vision 不可用,使用占位结果")
|
||
return {"products": [_vision_fallback(0, "fallback_import_error")]}
|
||
|
||
if not job.images:
|
||
logger.warning("[爆款视频] 任务无 images,跳过图片分析")
|
||
return {"products": []}
|
||
|
||
# 选择视觉模型:lite 速度优先(默认),pro 作为降级备用
|
||
try:
|
||
_s = get_shared_settings()
|
||
if _s.doubao_vision_use_lite:
|
||
vision_model = _s.doubao_vision_lite_model
|
||
pro_model = _s.doubao_vision_model
|
||
vision_timeout = 45 # lite 给到 45s 避免首轮就 timeout 降级 pro
|
||
else:
|
||
vision_model = _s.doubao_vision_model
|
||
pro_model = None # 已经是 pro,不再降级
|
||
vision_timeout = 60
|
||
except Exception:
|
||
vision_model = "doubao-1-5-vision-lite-250315"
|
||
pro_model = "doubao-1-5-vision-pro-250328"
|
||
vision_timeout = 45
|
||
|
||
results: list[dict] = [None] * len(job.images) # type: ignore
|
||
max_workers = min(4, max(1, len(job.images)))
|
||
logger.info(
|
||
"[爆款视频] 开始并行图片分析 n=%d model=%s pro_fallback=%s timeout=%d workers=%d",
|
||
len(job.images),
|
||
vision_model,
|
||
pro_model,
|
||
vision_timeout,
|
||
max_workers,
|
||
)
|
||
with ThreadPoolExecutor(max_workers=max_workers) as pool:
|
||
future_to_idx = {
|
||
pool.submit(
|
||
_analyze_single_image, idx, url, vision_model, vision_timeout, pro_fallback_model=pro_model
|
||
): idx
|
||
for idx, url in enumerate(job.images)
|
||
}
|
||
for fut in as_completed(future_to_idx):
|
||
idx = future_to_idx[fut]
|
||
try:
|
||
results[idx] = fut.result()
|
||
except Exception as e:
|
||
logger.warning("[爆款视频] 图片 #%d future 异常 err=%s", idx, e, exc_info=True)
|
||
results[idx] = _vision_fallback(idx, "future_exception", {"_error": str(e)[:200]})
|
||
|
||
return {"products": results}
|
||
|
||
|
||
def _step_video_analysis(job: ViralVideoJob) -> dict | None:
|
||
"""步骤 1.5: 参考视频风格分析(可选)。"""
|
||
if not job.reference_video_url:
|
||
return None
|
||
try:
|
||
from viral_video.video_analyzer import analyze_video_style
|
||
|
||
style_guide = analyze_video_style(job.reference_video_url)
|
||
return style_guide if isinstance(style_guide, dict) else None
|
||
except ImportError as e:
|
||
logger.info("[爆款视频] video_analyzer 模块未就绪(%s),使用占位风格分析", e)
|
||
return {
|
||
"cut_speed": "medium",
|
||
"transition": "cross_dissolve",
|
||
"energy": "medium",
|
||
"color_grade": "neutral",
|
||
"narrative": False,
|
||
"source": "placeholder",
|
||
}
|
||
except Exception as e:
|
||
logger.error("[爆款视频] 视频风格分析失败: %s", e)
|
||
return {"error": str(e), "source": "failed"}
|
||
|
||
|
||
def _step_intent_parsing(job: ViralVideoJob, image_analysis: dict) -> dict:
|
||
"""步骤 2: 用户文案意图解析。"""
|
||
try:
|
||
from packages.shared.ai_service import call_llm
|
||
except ImportError:
|
||
return {"intent": "推广产品", "key_messages": ["产品亮点"], "tone": "专业", "suggested_title": ""}
|
||
|
||
products_summary = ""
|
||
products = (image_analysis or {}).get("products", []) or []
|
||
for p in products:
|
||
if not isinstance(p, dict):
|
||
continue
|
||
feats = p.get("key_features") or p.get("features") or []
|
||
extras = []
|
||
if p.get("brand") and p.get("brand") not in ("未知", "无法判断"):
|
||
extras.append(f"品牌={p['brand']}")
|
||
if p.get("category") and p.get("category") not in ("无法判断", "非产品图"):
|
||
extras.append(f"品类={p['category']}")
|
||
if p.get("colors"):
|
||
extras.append(f"颜色={','.join(p['colors'])}")
|
||
if p.get("visual_style"):
|
||
extras.append(f"风格={p['visual_style']}")
|
||
feat_str = ", ".join([str(x) for x in feats + extras])
|
||
products_summary += f"- {p.get('name', '产品')}: {feat_str}\n"
|
||
|
||
prompt = f"""你是一个营销编导。请分析以下信息,理解用户的营销意图并给出短视频主题建议:
|
||
|
||
用户原始文案:{job.user_copy_text or "(未提供,全由 AI 创作)"}
|
||
行业:{job.industry or "未指定"}
|
||
目标客户:{job.target_customer or "未指定"}
|
||
营销目的:{job.marketing_purpose or "未指定"}
|
||
视频时长:{job.duration}秒
|
||
产品信息:
|
||
{products_summary or "- (无图片分析结果)"}
|
||
|
||
请返回严格 JSON(不要 Markdown,不要解释):
|
||
{{
|
||
"intent": "核心营销意图(一句话)",
|
||
"key_messages": ["要传达的3-5个关键信息"],
|
||
"tone": "文案调性(如亲切/专业/高端/活力/治愈/搞笑)",
|
||
"target_emotion": "希望触发的用户情感",
|
||
"call_to_action": "行动号召短句(口语化,5-10字)",
|
||
"suggested_title": "视频主题标题(5-15字)"
|
||
}}"""
|
||
|
||
_s = get_shared_settings()
|
||
_fast = _s.doubao_fast_model
|
||
try:
|
||
# 用快模型提速(结构化输出任务,不需要推理模型)
|
||
result = call_llm(prompt, temperature=0.4, max_tokens=800, model=_fast)
|
||
return (
|
||
result
|
||
if isinstance(result, dict)
|
||
else {"intent": str(result)[:200], "key_messages": [], "tone": "专业", "suggested_title": ""}
|
||
)
|
||
except Exception as e:
|
||
logger.warning("[爆款视频] 意图解析失败: %s", e)
|
||
return {"intent": "推广产品", "key_messages": ["产品亮点"], "tone": "专业", "suggested_title": ""}
|
||
|
||
|
||
# ── 编导分镜脚本生成(核心,v1.6 新 prompt) ──────────────────────────────
|
||
|
||
|
||
# 人设 IP 类型 → 文案/出镜风格指导(前端下拉 10 个 IP 类型)
|
||
_PERSONA_STYLE_GUIDE = {
|
||
"通用个人IP": "亲切自然、像朋友分享好物,第一人称口语化,不端着",
|
||
"老板型IP": "沉稳大气、有行业格局感,适度使用『我做了XX年』『我一直坚持』等老板视角,语气自信不夸张",
|
||
"专家型IP": "专业权威、讲原理和数据支撑,用词严谨,少用网梗,像行业专家做科普",
|
||
"顾问型IP": "贴心周到、给建议给方案,多用『建议你』『可以试试』『我帮你梳理』",
|
||
"创始人IP": "真诚有温度、讲品牌故事和创业初心,带点情怀和个人观点,不端老板架子",
|
||
"创业者IP": "真实接地气、讲踩坑经验和创业心路,带点自嘲和韧劲,像身边的创业者朋友",
|
||
"从业者经验派": "内行视角、讲行业内幕/实操经验/踩坑教训,多用『干了X年我发现』『内行都知道』",
|
||
"避坑顾问型": "直接点出痛点和雷区,先讲『别买XX』『很多人踩过的坑』再给正确选择,节奏感强",
|
||
"知识科普型": "清晰讲原理、讲知识点,条理分明、信息密度高,像做一期小科普",
|
||
"测评种草型": "真实测评感、讲使用体验和优缺点对比,带『亲测』『我用了XX天』『实测下来』真实感词汇",
|
||
}
|
||
|
||
|
||
def _persona_style_hint(persona_id: str) -> str:
|
||
"""根据 persona_id 查文案风格指导;未命中/空值返回通用提示。"""
|
||
pid = (persona_id or "").strip()
|
||
if pid in _PERSONA_STYLE_GUIDE:
|
||
return f"【人设风格:{pid}】{_PERSONA_STYLE_GUIDE[pid]}"
|
||
if pid:
|
||
# 前端传了自由值,照直提示,不阻塞
|
||
return f"【人设风格:{pid}】按该人设的口吻、话术习惯组织口播和出镜动作"
|
||
return "【人设风格:未指定】亲切自然、像朋友分享好物"
|
||
|
||
|
||
_SCRIPT_GENERATION_PROMPT = """你是资深短视频导演,为 Seedance 2.5(单次生成最多{duration}秒)写编导分镜脚本。脚本将整体作为 prompt 一次性传给视频模型,必须让模型在连贯镜头流中清楚每段时间拍什么、画面如何、人物说什么。
|
||
|
||
## 产品
|
||
{products_summary}
|
||
|
||
## 营销参数
|
||
- 主题/意图:{intent}
|
||
- 关键信息:{key_messages}
|
||
- 调性:{tone}
|
||
- 目标客户:{target_customer}
|
||
- 用户原始卖点(必须融入口播):{user_copy}
|
||
- 时长:{duration}秒 / 画幅:{ratio} / 产品图:{n_images}张(第1张通常是主图/首帧)
|
||
- 风格参考:{style_hint}
|
||
- 爆款结构(必须严格遵循节奏/段落顺序):{viral_structure_block}
|
||
- 人设/出镜口吻(必须贯穿全部对白和动作描写):{persona_hint}
|
||
|
||
## 输出格式(必须输出严格 JSON,不要 Markdown,不要解释,字段一个都不能少)
|
||
|
||
```json
|
||
{{
|
||
"overview": {{
|
||
"theme": "视频主题(一句话概括)",
|
||
"total_duration": {duration},
|
||
"aspect_ratio": "{ratio}"
|
||
}},
|
||
"scene_and_lighting": "整体场景描述+光线设定(100-200字,要具体:在哪拍、什么光线、什么色调、什么氛围)",
|
||
"shots": [
|
||
{{
|
||
"time_range": "0-3秒",
|
||
"shot_type_angle_movement": "景别+角度+运镜(例:近景俯拍45度,缓慢推镜;中景平视,固定镜头;特写平视,快速拉镜)",
|
||
"scene_and_dialogue": "画面场景描述 + 人物口播台词(对白要自然口语化,像朋友聊天,不要硬广推销腔)",
|
||
"action_details": "人物动作、表情、物品操作细节(手怎么动、表情变化、产品怎么展示)",
|
||
"audio_bgm": "环境音+BGM提示(例:轻快流行BGM,环境嘈杂咖啡店背景音)",
|
||
"transition": "硬切/淡入淡出/叠化(最后一镜写『结束』即可)",
|
||
"reference_image_index": 0
|
||
}}
|
||
// ... 按时间顺序列出所有镜头,总时长累计 = {duration} 秒
|
||
],
|
||
"hard_constraints": [
|
||
"无字幕、无水印、无任何自动生成文字、无logo",
|
||
"同一人物全程五官、发型、服装、身材保持一致,不得换脸变形",
|
||
"口播语音在总时长内自然念完,语速自然,口型与语音严格同步",
|
||
"画面流畅无闪烁、无多余肢体、无扭曲变形、无穿模",
|
||
"色彩自然、曝光正确、电影级质感、高清细节"
|
||
],
|
||
"negative_prompts": [
|
||
"字幕","自动字幕","水印","logo","图标","错误文字","乱码文字",
|
||
"男女声错配","中途换声","五官崩坏","脸部变形","多余手指",
|
||
"肢体扭曲","闪烁","画面抖动","模糊","低分辨率"
|
||
],
|
||
"voiceover_script": "完整口播稿(把 shots 里所有对白自然拼接成一段,口语化,不加旁白标注、不加镜头标注、不加'主播:'之类前缀,就是纯念出来的文本,长度适配{duration}秒,约{approx_chars}字)"
|
||
}}
|
||
```
|
||
|
||
## 关键要求
|
||
1. 每镜写清景别/角度/运镜(特写/近景/中景+平视/俯拍+推/拉/固定)。
|
||
2. 画面具体:主体(性别/年龄/穿着)、场景、动作、光线、镜头运动要可落地。
|
||
3. 对白自然口语化,像朋友分享好物;拒绝"家人们""宝子们""太好用了"等浮夸/硬广腔。
|
||
4. reference_image_index 填 0-based 索引(产品特写用索引0主图),人像/场景可 null。
|
||
5. shots time_range 累计={duration}秒,单镜2-8秒。
|
||
6. hard_constraints/negative_prompts 保留默认项可追加,不要删减。
|
||
7. voiceover_script 为纯口播文本(无标记/括号/前缀),{duration}秒约{approx_chars}字。
|
||
8. 严格按上方「爆款结构」的节奏/段落顺序编排(钩子/痛点/反转/案例/行动号召与结构对齐)。
|
||
9. 输出前自检:口播对白禁止错别字和语病,**严禁使用"很近",正确用词是"最近"**(指"最近一段时间/最近在用",绝不能写成"很近");其他同音字、形近字错误一律修正。
|
||
10. 必须使用产品信息中真实的品牌、品名和外观特征,不要编造与产品无关的内容。"""
|
||
|
||
|
||
def _build_products_summary(image_analysis: dict) -> str:
|
||
"""把 VLM 返回的商品分析结果拼给文案/分镜生成 prompt 用。
|
||
优先用 summary(自然段落);没有时用结构化字段兜底拼一段。"""
|
||
products = (image_analysis or {}).get("products", []) or []
|
||
if not products:
|
||
return "- (无图片信息,请自由创作自然生活化场景)"
|
||
lines = []
|
||
for i, p in enumerate(products):
|
||
if not isinstance(p, dict):
|
||
continue
|
||
name = p.get("name") or "产品"
|
||
# 优先 VLM 生成的 summary 段(自然语言,给编导模型看效果最好)
|
||
summary = (p.get("summary") or "").strip()
|
||
if summary and len(summary) >= 30:
|
||
lines.append(f"- 图{i+1} {name}:{summary}")
|
||
continue
|
||
# 结构化字段兜底
|
||
brand = p.get("brand") or ""
|
||
cat = p.get("category") or ""
|
||
spec = p.get("spec") or ""
|
||
appearance = p.get("appearance") or ""
|
||
packaging = p.get("packaging") or ""
|
||
colors = p.get("colors") or []
|
||
mat = p.get("material_or_texture") or ""
|
||
style = p.get("visual_style") or ""
|
||
scene = p.get("scene") or ""
|
||
audience = p.get("target_audience") or p.get("target_audience_hint") or ""
|
||
# text_on_package 可能是数组(新格式)或字符串(旧格式)
|
||
text_list = p.get("text_on_package") or []
|
||
if isinstance(text_list, str):
|
||
text_on_img = text_list
|
||
else:
|
||
text_on_img = ";".join([str(x) for x in text_list[:8]]) if text_list else (p.get("text_on_image") or "")
|
||
feats = p.get("key_features") or p.get("features") or []
|
||
sellings = p.get("selling_points") or []
|
||
scenes = p.get("suitable_scenes") or []
|
||
parts = [f"图{i+1} {name}"]
|
||
if brand and brand not in ("未知", "无法判断"):
|
||
parts.append(f"品牌={brand}")
|
||
if cat and cat not in ("无法判断", "非产品图"):
|
||
parts.append(f"品类={cat}")
|
||
if spec and spec != "无法判断":
|
||
parts.append(f"规格={spec}")
|
||
if appearance and appearance != "无法判断":
|
||
parts.append(f"外观={appearance}")
|
||
if packaging and packaging != "无法判断":
|
||
parts.append(f"包装={packaging}")
|
||
if colors:
|
||
parts.append(f"颜色={','.join(colors)}")
|
||
if mat and mat != "无法判断":
|
||
parts.append(f"材质={mat}")
|
||
if style:
|
||
parts.append(f"风格={style}")
|
||
if scene and scene not in ("通用",):
|
||
parts.append(f"展示场景={scene}")
|
||
if scenes:
|
||
parts.append(f"适用场景={','.join([str(x) for x in scenes[:4]])}")
|
||
if audience and audience not in ("通用", "无法判断"):
|
||
parts.append(f"目标人群={audience}")
|
||
if text_on_img and text_on_img not in ("无",):
|
||
parts.append(f"包装文字={text_on_img[:300]}")
|
||
if feats:
|
||
parts.append("外观特征=" + ";".join([str(x) for x in feats[:6]]))
|
||
if sellings:
|
||
parts.append("营销卖点=" + ";".join([str(x) for x in sellings[:5]]))
|
||
lines.append("- " + ",".join(parts))
|
||
return "\n".join(lines)
|
||
|
||
|
||
def _safe_json_loads(raw: str | dict | list | None):
|
||
if raw is None:
|
||
return None
|
||
if isinstance(raw, (dict, list)):
|
||
return raw
|
||
if not isinstance(raw, str):
|
||
return None
|
||
s = raw.strip()
|
||
if s.startswith("```"):
|
||
s = s.strip("`")
|
||
if s.startswith("json"):
|
||
s = s[4:].lstrip()
|
||
try:
|
||
return json.loads(s)
|
||
except Exception:
|
||
# 尝试截取第一个 { ... } 或 [ ... ]
|
||
try:
|
||
for open_c, close_c in (("{", "}"), ("[", "]")):
|
||
i = s.find(open_c)
|
||
j = s.rfind(close_c)
|
||
if i >= 0 and j > i:
|
||
return json.loads(s[i : j + 1])
|
||
except Exception:
|
||
pass
|
||
return None
|
||
|
||
|
||
def _fallback_script(job: ViralVideoJob) -> dict:
|
||
"""脚本生成失败时的兜底脚本(极简但可用)。"""
|
||
dur = max(5, min(30, int(getattr(job, "duration", 15) or 15)))
|
||
ratio = getattr(job, "video_ratio", None) or "9:16"
|
||
base = _empty_copy_result(dur, ratio)
|
||
voiceover = job.user_copy_text or "你好,给大家分享一款我最近在用的好物,真的很不错,推荐你们也试试。"
|
||
shots = [
|
||
{
|
||
"time_range": f"0-{dur}秒",
|
||
"shot_type_angle_movement": "中景平视,缓慢推镜",
|
||
"scene_and_dialogue": "明亮室内,人物自然出镜,微笑着看向镜头。" + voiceover,
|
||
"action_details": "人物手持产品自然展示,表情亲切,动作流畅",
|
||
"audio_bgm": "轻快流行BGM",
|
||
"transition": "结束",
|
||
"reference_image_index": 0 if job.images else None,
|
||
}
|
||
]
|
||
base["shots"] = shots
|
||
base["voiceover_script"] = voiceover
|
||
base["final_copy"] = voiceover
|
||
base["suggested_copy"] = voiceover
|
||
base["title"] = "好物分享"
|
||
return base
|
||
|
||
|
||
def _validate_and_normalize_script(raw, job: ViralVideoJob) -> dict:
|
||
"""把 LLM 返回的脚本规范化、补默认、校验结构。"""
|
||
dur = max(5, min(30, int(getattr(job, "duration", 15) or 15)))
|
||
ratio = getattr(job, "video_ratio", None) or "9:16"
|
||
base = _empty_copy_result(dur, ratio)
|
||
|
||
if not isinstance(raw, dict):
|
||
logger.warning("[爆款视频] 脚本返回非 dict,使用兜底")
|
||
return _fallback_script(job)
|
||
|
||
# overview
|
||
ov = raw.get("overview")
|
||
if isinstance(ov, dict):
|
||
base["overview"] = {
|
||
"theme": str(ov.get("theme") or "好物分享"),
|
||
"total_duration": int(ov.get("total_duration") or dur),
|
||
"aspect_ratio": str(ov.get("aspect_ratio") or ratio),
|
||
}
|
||
else:
|
||
base["overview"]["theme"] = str(raw.get("title") or "好物分享")
|
||
|
||
base["scene_and_lighting"] = str(raw.get("scene_and_lighting") or base["scene_and_lighting"])
|
||
|
||
# shots
|
||
shots_raw = raw.get("shots")
|
||
shots: list[dict] = []
|
||
if isinstance(shots_raw, list):
|
||
for i, s in enumerate(shots_raw):
|
||
if not isinstance(s, dict):
|
||
continue
|
||
shots.append(
|
||
{
|
||
"time_range": str(s.get("time_range") or f"{i*3}-{(i+1)*3}秒"),
|
||
"shot_type_angle_movement": str(s.get("shot_type_angle_movement") or "中景平视,固定镜头"),
|
||
"scene_and_dialogue": str(s.get("scene_and_dialogue") or ""),
|
||
"action_details": str(s.get("action_details") or ""),
|
||
"audio_bgm": str(s.get("audio_bgm") or "轻快BGM"),
|
||
"transition": str(s.get("transition") or ("硬切" if i < len(shots_raw) - 1 else "结束")),
|
||
"reference_image_index": s.get("reference_image_index"),
|
||
}
|
||
)
|
||
if not shots:
|
||
shots = [
|
||
{
|
||
"time_range": f"0-{dur}秒",
|
||
"shot_type_angle_movement": "中景平视,缓慢推镜",
|
||
"scene_and_dialogue": "明亮室内场景,人物自然出镜。",
|
||
"action_details": "自然展示产品",
|
||
"audio_bgm": "轻快BGM",
|
||
"transition": "结束",
|
||
"reference_image_index": 0 if job.images else None,
|
||
}
|
||
]
|
||
base["shots"] = shots
|
||
|
||
# hard_constraints / negative_prompts
|
||
hc = raw.get("hard_constraints")
|
||
if isinstance(hc, list) and hc:
|
||
merged = list(_DEFAULT_HARD_CONSTRAINTS)
|
||
for x in hc:
|
||
if isinstance(x, str) and x and x not in merged:
|
||
merged.append(x)
|
||
base["hard_constraints"] = merged
|
||
np = raw.get("negative_prompts")
|
||
if isinstance(np, list) and np:
|
||
merged = list(_DEFAULT_NEGATIVE_PROMPTS)
|
||
for x in np:
|
||
if isinstance(x, str) and x and x not in merged:
|
||
merged.append(x)
|
||
base["negative_prompts"] = merged
|
||
|
||
# voiceover_script: 优先从字段取,否则从各镜 scene_and_dialogue 提取(粗暴拼接冒号后部分 / 中文句)
|
||
voiceover = str(raw.get("voiceover_script") or "").strip()
|
||
if not voiceover:
|
||
# 兜底:把所有 scene_and_dialogue 拼接起来,去除镜头描述部分(含"景"、"俯拍"、"平视"等词的前缀)
|
||
import re
|
||
|
||
parts = []
|
||
for s in shots:
|
||
txt = s.get("scene_and_dialogue", "")
|
||
# 去除开头到第一个句号/逗号前的"镜头描述"部分
|
||
# 简单策略:找第一个中文说话片段——按句号切,后半段更像对白
|
||
segs = re.split(r"[。!?]", txt)
|
||
for seg in segs:
|
||
seg = seg.strip(" ,,。.!?!?::")
|
||
if len(seg) >= 4 and not any(
|
||
k in seg for k in ("景别", "俯拍", "仰拍", "平视", "镜头", "特写", "中景", "全景", "近景", "运镜")
|
||
):
|
||
parts.append(seg)
|
||
voiceover = "。".join(parts) if parts else (job.user_copy_text or "你好,给大家分享一款好物。")
|
||
base["voiceover_script"] = voiceover
|
||
base["final_copy"] = voiceover
|
||
base["suggested_copy"] = voiceover
|
||
base["title"] = base["overview"]["theme"]
|
||
return base
|
||
|
||
|
||
def _step_script_generation(job: ViralVideoJob, intent: dict, image_analysis: dict) -> dict:
|
||
"""步骤 3: 编导分镜脚本生成(v1.6 核心,输出 copy_result 结构)。"""
|
||
try:
|
||
from packages.shared.ai_service import call_llm
|
||
except ImportError:
|
||
return _fallback_script(job)
|
||
|
||
products_summary = _build_products_summary(image_analysis)
|
||
style_hint = "无"
|
||
if isinstance(job.style_guide, dict):
|
||
style_hint = (
|
||
f"节奏{job.style_guide.get('cut_speed','')}、转场{job.style_guide.get('transition','')}、"
|
||
f"色调{job.style_guide.get('color_grade','')}、能量{job.style_guide.get('energy','')}"
|
||
)
|
||
|
||
dur = max(5, min(30, int(getattr(job, "duration", 15) or 15)))
|
||
ratio = getattr(job, "video_ratio", None) or "9:16"
|
||
approx_chars = max(20, dur * 4)
|
||
|
||
intent_str = ""
|
||
key_msgs = ""
|
||
tone = ""
|
||
if isinstance(intent, dict):
|
||
intent_str = intent.get("intent") or "推广产品"
|
||
key_msgs = "、".join(intent.get("key_messages") or [])
|
||
tone = intent.get("tone") or "亲切自然"
|
||
else:
|
||
intent_str = "推广产品"
|
||
tone = "亲切自然"
|
||
|
||
# 爆款结构:用户在 STEP1/STEP2 选的中文结构名,必须严格注入 prompt 指导 AI 编排
|
||
_vs = (job.viral_structure or "").strip()
|
||
if _vs:
|
||
viral_structure_block = f"【{_vs}】—— 请严格按照这个爆款结构的节奏/段落顺序编排镜头、台词和情绪节点(开场钩子、痛点、反转、案例、行动号召等按结构走),不要打乱顺序"
|
||
else:
|
||
viral_structure_block = "未指定(自由编排,但仍需有钩子开头+产品展示+行动号召的基本节奏)"
|
||
|
||
persona_hint = _persona_style_hint(getattr(job, "persona_id", ""))
|
||
prompt = _SCRIPT_GENERATION_PROMPT.format(
|
||
products_summary=products_summary,
|
||
intent=intent_str,
|
||
key_messages=key_msgs or "产品亮点",
|
||
tone=tone,
|
||
target_customer=job.target_customer or "通用人群",
|
||
user_copy=job.user_copy_text or "(未提供,自由创作)",
|
||
duration=dur,
|
||
ratio=ratio,
|
||
n_images=len(job.images or []),
|
||
style_hint=style_hint,
|
||
approx_chars=approx_chars,
|
||
viral_structure_block=viral_structure_block,
|
||
persona_hint=persona_hint,
|
||
)
|
||
|
||
_s = get_shared_settings()
|
||
_fast = _s.doubao_fast_model
|
||
_pro = getattr(_s, "doubao_model", None) or _fast
|
||
|
||
def _try_gen(model: str, temp: float, max_tok: int, label: str):
|
||
logger.info("[爆款视频] 编导脚本生成 model=%s label=%s", model, label)
|
||
r = call_llm(prompt, temperature=temp, max_tokens=max_tok, model=model)
|
||
if r is None:
|
||
logger.warning("[爆款视频] 编导脚本返回None label=%s", label)
|
||
return None
|
||
parsed = _safe_json_loads(r)
|
||
normalized = _validate_and_normalize_script(parsed, job)
|
||
voiceover = (normalized or {}).get("voiceover_script") or ""
|
||
voiceover_len = len(voiceover)
|
||
shots_cnt = len((normalized or {}).get("shots") or [])
|
||
# 判定是否"退化到兜底质量":口播过短(<20字)或镜头数<1;正常的短口播(如15s视频~40字)不视为兜底
|
||
# v1.6.1 双保险:先做 hard fix 字符串替换("很近" → "最近"),再做不合格判定
|
||
if "很近" in voiceover:
|
||
logger.warning("[爆款视频] 编导脚本含错别字'很近',hard fix 替换为'最近' label=%s", label)
|
||
voiceover = voiceover.replace("很近", "最近")
|
||
normalized["voiceover_script"] = voiceover
|
||
# 同时在 shots 对白里替换
|
||
for sh in normalized.get("shots") or []:
|
||
if isinstance(sh, dict):
|
||
sd = sh.get("scene_and_dialogue") or ""
|
||
if "很近" in sd:
|
||
sh["scene_and_dialogue"] = sd.replace("很近", "最近")
|
||
fallback_marker = "我最近在用的好物" in voiceover # _fallback_script 的特征串
|
||
has_typo_henjin = "很近" in voiceover # v1.6.1: 错别字"很近"视为不合格,触发重试
|
||
is_fallback = fallback_marker or shots_cnt < 1 or voiceover_len < 20 or has_typo_henjin
|
||
logger.info(
|
||
"[爆款视频] 编导脚本结果 label=%s voiceover_len=%d shots=%d fallback=%s raw_type=%s",
|
||
label,
|
||
voiceover_len,
|
||
shots_cnt,
|
||
is_fallback,
|
||
type(r).__name__,
|
||
)
|
||
if is_fallback:
|
||
return None # 触发重试
|
||
return normalized
|
||
|
||
try:
|
||
# 第一次:快模型
|
||
normalized = _try_gen(_fast, 0.8, 2500, "fast-first")
|
||
if normalized is not None:
|
||
return normalized
|
||
# 第二次:快模型降温度+加大 max_tokens
|
||
normalized = _try_gen(_fast, 0.6, 3200, "fast-retry")
|
||
if normalized is not None:
|
||
return normalized
|
||
# 第三次:用主力模型兜底
|
||
if _pro and _pro != _fast:
|
||
normalized = _try_gen(_pro, 0.7, 3500, "pro-fallback")
|
||
if normalized is not None:
|
||
return normalized
|
||
logger.warning("[爆款视频] 编导脚本三次都未生成合格结果,使用兜底脚本")
|
||
return _fallback_script(job)
|
||
except Exception as e:
|
||
logger.warning("[爆款视频] 编导脚本生成异常: %s,使用兜底脚本", e, exc_info=True)
|
||
return _fallback_script(job)
|
||
|
||
|
||
def _step_review(job: ViralVideoJob, copy_result: dict) -> dict:
|
||
"""步骤 4: 合规审核(简化版:基于脚本的 voiceover_script+shots 文本)。"""
|
||
dimensions = ["广告法合规", "平台规范", "内容真实性", "版权安全", "价值观", "风格一致性"]
|
||
try:
|
||
from packages.shared.ai_service import call_llm
|
||
except ImportError:
|
||
return {"passed": True, "score": 90, "details": {d: "通过" for d in dimensions}}
|
||
|
||
voiceover = (copy_result or {}).get("voiceover_script", "")
|
||
shots_preview = json.dumps((copy_result or {}).get("shots", [])[:3], ensure_ascii=False)
|
||
prompt = f"""请对以下短视频编导脚本进行合规审核,检查6个维度:{", ".join(dimensions)}
|
||
|
||
口播文案:{voiceover}
|
||
前3个镜头:{shots_preview}
|
||
行业:{job.industry}
|
||
|
||
请以JSON格式返回:
|
||
- passed: bool(是否全部通过)
|
||
- score: int(0-100分)
|
||
- details: 各维度评分和说明
|
||
- issues: 需要修改的问题列表(如有)"""
|
||
_s = get_shared_settings()
|
||
_fast = _s.doubao_fast_model
|
||
try:
|
||
# 合规审核用快模型 + 短输出(结构化判断)
|
||
result = call_llm(prompt, temperature=0.1, max_tokens=500, model=_fast)
|
||
return result if isinstance(result, dict) else {"passed": True, "score": 80, "details": {}}
|
||
except Exception as e:
|
||
logger.warning("[爆款视频] 合规审核失败: %s", e)
|
||
return {"passed": True, "score": 75, "details": {d: "默认通过" for d in dimensions}}
|
||
|
||
|
||
def _step_tts(job: ViralVideoJob, voiceover_script: str):
|
||
"""步骤 5: CosyVoice 整段配音 → 返回本地 MP3 Path;失败返回 None。"""
|
||
try:
|
||
from pathlib import Path as _Path
|
||
|
||
from apps.worker.services.tts_service_factory import get_tts_service
|
||
|
||
tts_service = get_tts_service()
|
||
voice_id = (getattr(job, "voice_id", "") or "").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.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"
|
||
|
||
# reference_audios: TTS 音频驱动口型
|
||
ref_audios = [tts_audio_url] if tts_audio_url else []
|
||
# reference_images: 产品图(除首帧外的其他图作为多参考;首帧通过 image_url 传)
|
||
images = list(job.images or [])
|
||
first_image = images[0] if images else None
|
||
rest_images = images[1:30] if len(images) > 1 else []
|
||
# reference_videos: 参考视频(可选)
|
||
ref_videos = [job.reference_video_url] if getattr(job, "reference_video_url", "") else []
|
||
|
||
tmpdir = Path(tempfile.mkdtemp(prefix=f"viral_{job.id}_"))
|
||
logger.info(
|
||
"[爆款视频] 开始单次 Seedance 生成 dur=%ds ratio=%s model=%s ref_imgs=%d ref_audios=%d ref_videos=%d tmpdir=%s",
|
||
dur,
|
||
ratio if not first_image else "(follow-image)",
|
||
model or "default",
|
||
len(rest_images) + (1 if first_image else 0),
|
||
len(ref_audios),
|
||
len(ref_videos),
|
||
tmpdir,
|
||
)
|
||
logger.info("[爆款视频] Seedance prompt (前300字): %s", prompt[:300])
|
||
|
||
result = call_video_generation(
|
||
prompt=prompt,
|
||
image_url=first_image,
|
||
duration=dur,
|
||
ratio=ratio,
|
||
resolution=resolution,
|
||
output_dir=str(tmpdir),
|
||
model=model,
|
||
generate_audio=True, # Seedance 原生生成环境音效/BGM;口型由 reference_audios 的 TTS 驱动
|
||
reference_images=rest_images,
|
||
reference_audios=ref_audios,
|
||
reference_videos=ref_videos,
|
||
)
|
||
if not result or not isinstance(result, dict):
|
||
raise RuntimeError("Seedance 视频生成失败:返回为空")
|
||
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("Seedance 视频生成失败:返回空文件或路径不存在")
|
||
logger.info(
|
||
"[爆款视频] Seedance 单次生成完成: %s size=%d usage=%s", video_path, Path(video_path).stat().st_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
|
||
_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:
|
||
try:
|
||
if session is None:
|
||
session = SessionLocal()
|
||
repo = SQLAlchemyViralVideoJobRepository(session)
|
||
job = repo.get(job_id)
|
||
if job is not None and not job.is_terminal:
|
||
job.mark_failed(err_msg)
|
||
_save_job(repo, job, session)
|
||
except Exception as inner:
|
||
logger.warning("[爆款视频] 标记失败状态时出错: %s", inner)
|
||
_emit_progress(
|
||
job_id,
|
||
stage,
|
||
0,
|
||
f"任务失败: {err_msg}",
|
||
{"error": err_msg},
|
||
event_type="viral_video:failed",
|
||
)
|
||
|
||
|
||
# ── v1.5/v1.6 三步分步流水线 Celery 任务 ─────────────────────────────────
|
||
|
||
|
||
@shared_task(
|
||
bind=True,
|
||
max_retries=1,
|
||
name="worker.run_viral_video_analyze",
|
||
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=180, # 3min(编导脚本生成含三级重试)
|
||
time_limit=240,
|
||
)
|
||
def run_viral_video_generate_copy(self: Task, job_id: str) -> dict:
|
||
"""v1.6 阶段2(v1.6.1 提速版):意图解析 → 编导分镜脚本生成 → 直接返回,合规审核后置到出片前。
|
||
|
||
优化点(#2134 问题7):
|
||
- 意图解析/编导脚本均使用快模型(doubao_fast_model,非推理模型),max_tokens 收紧
|
||
- _SCRIPT_GENERATION_PROMPT 精简冗余描述
|
||
- 合规审核改为异步后置:不阻塞前端,在 confirm-copy(阶段3 TTS前)再做最终审核
|
||
- 每个阶段通过 _set_stage 持久化 current_stage/phase_message 到 DB(问题8)
|
||
- v1.6.1: lite VLM 15s 快速失败,pro 25s;三级编导重试
|
||
"""
|
||
session = None
|
||
_hb_stop = None
|
||
try:
|
||
_recover_stale_jobs() # 顺带回收僵尸任务
|
||
session, repo, job = _get_repo_and_job(job_id)
|
||
if job is None:
|
||
return {"ok": False, "error": "job not found"}
|
||
if job.status != ViralVideoStatus.RUNNING:
|
||
return {"ok": False, "error": f"unexpected status: {job.status}"}
|
||
job.touch_heartbeat()
|
||
_save_job(repo, job, session)
|
||
_hb_stop, _hb_thread = _start_heartbeat_thread(job_id)
|
||
|
||
# 阶段:意图解析
|
||
_set_stage(job, repo, session, ViralVideoStage.INTENT_PARSING, "正在解析文案意图...")
|
||
image_analysis = job.image_analysis or {"products": []}
|
||
intent_result = _step_intent_parsing(job, image_analysis)
|
||
job.intent_result = intent_result
|
||
_save_job(repo, job, session)
|
||
_emit_progress(job_id, ViralVideoStage.INTENT_PARSING, 35.0, "意图解析完成")
|
||
|
||
# 阶段:编导脚本生成(核心耗时环节,已用快模型)
|
||
_set_stage(job, repo, session, ViralVideoStage.SCRIPT_GENERATION, "正在编排分镜脚本...")
|
||
copy_result = _step_script_generation(job, intent_result, image_analysis)
|
||
_emit_progress(
|
||
job_id,
|
||
ViralVideoStage.SCRIPT_GENERATION,
|
||
85.0,
|
||
"分镜脚本生成完成",
|
||
{"shots": len(copy_result.get("shots", []))},
|
||
)
|
||
|
||
# 合规审核后置:不再阻塞前端返回;在阶段3(confirm-copy 出片前)_run_render_pipeline 里再做最终审核。
|
||
# 这里只做一个快速轻量检查(关键字黑名单),发现明显违规再触发重写;LLM 深度审核放到出片前。
|
||
_set_stage(job, repo, session, ViralVideoStage.REVIEW, "正在快速检查脚本合规性...")
|
||
voiceover = (copy_result or {}).get("voiceover_script", "") or ""
|
||
_quick_compliance_blacklist_check(copy_result)
|
||
_emit_progress(job_id, ViralVideoStage.REVIEW, 95.0, "脚本合规初检完成")
|
||
|
||
# 标记 copy_generated 并持久化
|
||
job.mark_copy_generated(copy_result)
|
||
job.current_stage = ViralVideoStage.REVIEW
|
||
job.phase_message = "分镜脚本已生成,请确认或编辑口播文案"
|
||
_save_job(repo, job, session)
|
||
|
||
_emit_progress(
|
||
job_id,
|
||
ViralVideoStage.REVIEW,
|
||
100.0,
|
||
"分镜脚本已生成,请确认或编辑口播文案",
|
||
{
|
||
"copy_result": copy_result,
|
||
"generated_copy_text": voiceover,
|
||
"storyboard": copy_result.get("shots", []),
|
||
"status": "copy_generated",
|
||
},
|
||
event_type="viral_video:copy_generated",
|
||
)
|
||
logger.info(
|
||
"[爆款视频][阶段2] 编导脚本生成完成 job_id=%s voiceover_len=%d shots=%d",
|
||
job_id,
|
||
len(voiceover),
|
||
len(copy_result.get("shots", [])),
|
||
)
|
||
return {
|
||
"ok": True,
|
||
"job_id": job_id,
|
||
"status": "copy_generated",
|
||
"copy_result": copy_result,
|
||
"generated_copy_text": voiceover,
|
||
"storyboard": copy_result.get("shots", []),
|
||
}
|
||
|
||
except Retry:
|
||
raise
|
||
except Exception as e:
|
||
logger.error("[爆款视频][阶段2] 异常: %s", e, exc_info=True)
|
||
_mark_failed_and_notify(job_id, session, None, None, str(e), ViralVideoStage.SCRIPT_GENERATION)
|
||
return {"ok": False, "job_id": job_id, "error": str(e)}
|
||
finally:
|
||
if _hb_stop is not None:
|
||
_hb_stop.set()
|
||
if session:
|
||
session.close()
|
||
|
||
|
||
def _quick_compliance_blacklist_check(copy_result: dict) -> None:
|
||
"""阶段2快速黑名单检查:不调用 LLM,只扫描高风险关键词;命中则在 voiceover 中就地替换。
|
||
|
||
LLM 深度合规审核(_step_review)在阶段3 confirm-copy 出片前执行。
|
||
"""
|
||
if not isinstance(copy_result, dict):
|
||
return
|
||
voiceover = copy_result.get("voiceover_script", "") or ""
|
||
# 广告法绝对化用语黑名单(常见速查,远非完整,仅挡住最明显违规)
|
||
BLACKLIST = {
|
||
"最": "很",
|
||
"第一": "领先",
|
||
"国家级": "高品质",
|
||
"世界级": "高品质",
|
||
"顶级": "优质",
|
||
"极品": "优质",
|
||
"独家": "特色",
|
||
"绝无仅有": "少见",
|
||
"100%": "大幅",
|
||
"百分百": "大幅",
|
||
"永久": "长久",
|
||
"万能": "多用途",
|
||
"特效": "效果好",
|
||
"速效": "快速见效",
|
||
"根治": "改善",
|
||
"包治": "改善",
|
||
"药到病除": "缓解不适",
|
||
}
|
||
changed = False
|
||
for k, v in BLACKLIST.items():
|
||
if k in voiceover:
|
||
voiceover = voiceover.replace(k, v)
|
||
changed = True
|
||
if changed:
|
||
copy_result["voiceover_script"] = voiceover
|
||
# 同步 final_copy/suggested_copy(如果存在)
|
||
for k in ("final_copy", "suggested_copy"):
|
||
if isinstance(copy_result.get(k), str) and copy_result[k]:
|
||
for bk, bv in BLACKLIST.items():
|
||
copy_result[k] = copy_result[k].replace(bk, bv)
|
||
|
||
|
||
def _try_refund_viral_video(job: ViralVideoJob) -> None:
|
||
"""爆款视频生成失败:若已预扣积分则全额退款。"""
|
||
try:
|
||
from packages.shared import get_shared_settings
|
||
|
||
_s = get_shared_settings()
|
||
if not _s.points_enabled:
|
||
return
|
||
prepaid = float(getattr(job, "credits_prepaid", 0) or 0)
|
||
if prepaid <= 0:
|
||
return
|
||
from packages.domain.points_service import PointsService
|
||
|
||
svc = PointsService()
|
||
# 使用独立 session(避免污染外层事务)
|
||
ssn = SessionLocal()
|
||
try:
|
||
svc.refund_viral_video(
|
||
job.user_id,
|
||
prepaid,
|
||
getattr(job, "credits_transaction_id", "") or "",
|
||
ssn,
|
||
)
|
||
job.credits_prepaid = 0.0
|
||
finally:
|
||
ssn.close()
|
||
except Exception:
|
||
logger.exception("[爆款视频] 失败退款异常 job_id=%s", job.id)
|
||
|
||
|
||
def _settle_viral_video(job: ViralVideoJob, usage: dict | None) -> None:
|
||
"""爆款视频生成成功:按实际 usage 结算,多退少补,写 credits_cost。"""
|
||
try:
|
||
from packages.shared import get_shared_settings
|
||
|
||
_s = get_shared_settings()
|
||
if not _s.points_enabled:
|
||
job.credits_cost = 0.0
|
||
job.credits_prepaid = 0.0
|
||
return
|
||
prepaid = float(getattr(job, "credits_prepaid", 0) or 0)
|
||
if prepaid <= 0:
|
||
job.credits_cost = 0.0
|
||
return
|
||
from packages.domain.points_rules import calculate_viral_video_credits, resolve_video_dimensions
|
||
from packages.domain.points_service import PointsService
|
||
|
||
w, h = resolve_video_dimensions(
|
||
getattr(job, "video_resolution", "720p") or "720p",
|
||
getattr(job, "video_ratio", "9:16") or "9:16",
|
||
)
|
||
fps = 24
|
||
duration = int(getattr(job, "duration", 15) or 15)
|
||
est_tokens = duration * w * h * fps // 1024
|
||
actual_tokens = None
|
||
if isinstance(usage, dict):
|
||
at = usage.get("completion_tokens")
|
||
if isinstance(at, (int, float)) and at > 0:
|
||
actual_tokens = int(at)
|
||
diff_pct = None
|
||
if actual_tokens and est_tokens > 0:
|
||
diff_pct = (actual_tokens - est_tokens) * 100.0 / est_tokens
|
||
logger.info(
|
||
"[爆款视频] tokens估算vs实际 job_id=%s duration=%s %sx%s model=%s est=%s actual=%s diff=%.1f%%",
|
||
job.id, duration, w, h,
|
||
getattr(job, "video_model", "seedance-2.5"),
|
||
est_tokens, actual_tokens,
|
||
diff_pct if diff_pct is not None else 0.0,
|
||
)
|
||
actual_credits = calculate_viral_video_credits(
|
||
duration,
|
||
w,
|
||
h,
|
||
getattr(job, "video_model", "") or "seedance-2.5",
|
||
actual_tokens=actual_tokens,
|
||
)
|
||
svc = PointsService()
|
||
ssn = SessionLocal()
|
||
try:
|
||
svc.settle_viral_video(
|
||
job.user_id,
|
||
prepaid,
|
||
actual_credits,
|
||
getattr(job, "credits_transaction_id", "") or "",
|
||
ssn,
|
||
)
|
||
job.credits_cost = actual_credits
|
||
job.credits_prepaid = 0.0
|
||
finally:
|
||
ssn.close()
|
||
except Exception:
|
||
logger.exception("[爆款视频] 积分结算异常 job_id=%s", job.id)
|
||
# 结算异常不阻塞任务完成:保守按预扣值记 credits_cost
|
||
job.credits_cost = float(getattr(job, "credits_prepaid", 0) or 0)
|
||
job.credits_prepaid = 0.0
|
||
|
||
|
||
def _run_render_pipeline(job_id: str, session, repo, job) -> dict:
|
||
"""v1.6.1 阶段3:出片前合规审核(LLM 深度)→ TTS → Seedance → Upload → Completed。
|
||
|
||
阶段2 generate-copy 已把 LLM 深度审核后置,这里在 TTS 前做最终审核(不通过则自动重写1次)。
|
||
所有阶段通过 _set_stage 持久化 current_stage/phase_message。
|
||
"""
|
||
image_analysis = job.image_analysis or {"products": []}
|
||
|
||
# 如果没有 copy_result(旧数据/失败重试),现场补生成(意图+脚本,不走 LLM 审核,出片前会统一做)
|
||
copy_result = job.copy_result
|
||
if not isinstance(copy_result, dict) or not copy_result:
|
||
_set_stage(job, repo, session, ViralVideoStage.SCRIPT_GENERATION, "正在补生成编导脚本...")
|
||
intent = job.intent_result or _step_intent_parsing(job, image_analysis)
|
||
copy_result = _step_script_generation(job, intent, image_analysis)
|
||
job.mark_copy_generated(copy_result)
|
||
_save_job(repo, job, session)
|
||
|
||
# 出片前 LLM 深度合规审核(#2134 问题7:审核从阶段2后置到这里,不阻塞前端预览脚本)
|
||
_set_stage(job, repo, session, ViralVideoStage.REVIEW, "正在进行出片前合规审核...")
|
||
try:
|
||
review_result = _step_review(job, copy_result)
|
||
if not review_result.get("passed", True):
|
||
_emit_progress(job_id, ViralVideoStage.REVIEW, 67.0, "审核未通过,正在自动重写...")
|
||
intent = job.intent_result or _step_intent_parsing(job, image_analysis)
|
||
copy_result = _step_script_generation(job, intent, image_analysis)
|
||
_step_review(job, copy_result) # 二次审核,不通过也继续出片(避免反复循环)
|
||
job.copy_result = copy_result
|
||
job.generated_copy_text = copy_result.get("voiceover_script", "") or ""
|
||
_save_job(repo, job, session)
|
||
except Exception as e:
|
||
logger.warning("[爆款视频][阶段3] 合规审核异常,继续出片: %s", e)
|
||
_emit_progress(job_id, ViralVideoStage.REVIEW, 70.0, "合规审核完成")
|
||
|
||
voiceover = copy_result.get("voiceover_script", "") or job.effective_copy_text
|
||
|
||
# Step 5: TTS 整段合成
|
||
_set_stage(job, repo, session, ViralVideoStage.TTS, "正在合成AI配音...")
|
||
tts_path = _step_tts(job, voiceover)
|
||
tts_url = _upload_tts_to_oss(job, tts_path)
|
||
_emit_progress(job_id, ViralVideoStage.TTS, 78.0, "配音完成", {"has_tts": tts_url is not None})
|
||
|
||
# Step 6: 单次 Seedance(失败自动退款)
|
||
_set_stage(job, repo, session, ViralVideoStage.RENDERING, "正在生成视频(约1-3分钟)...")
|
||
video_path = None
|
||
usage = None
|
||
try:
|
||
video_path, usage = _step_render(job, copy_result, tts_url)
|
||
except Exception as e:
|
||
logger.error("[爆款视频][阶段3] Seedance 生成失败,触发退款: %s", e, exc_info=True)
|
||
# 退款
|
||
_try_refund_viral_video(job)
|
||
raise
|
||
_emit_progress(job_id, ViralVideoStage.RENDERING, 92.0, "视频生成完成")
|
||
|
||
# Step 7: Upload
|
||
_set_stage(job, repo, session, ViralVideoStage.UPLOADING, "正在上传视频...")
|
||
video_url = _step_upload(job, video_path)
|
||
|
||
# P2-2 OSS 一致性:上传后循环 head 确认公网可访问(最多等 10s),
|
||
# 避免前端拿到 completed 立即下载时命中 NoSuchKey。
|
||
if video_url:
|
||
_wait_oss_ready(video_url, timeout_sec=10)
|
||
|
||
# 积分结算:按实际 tokens 多退少补
|
||
_settle_viral_video(job, usage)
|
||
job.mark_completed(video_url)
|
||
job.current_stage = ViralVideoStage.UPLOADING
|
||
job.phase_message = "视频生成完成"
|
||
_save_job(repo, job, session)
|
||
_emit_progress(job_id, ViralVideoStage.UPLOADING, 100.0, "视频生成完成!", {"video_url": video_url})
|
||
_emit_progress(
|
||
job_id,
|
||
ViralVideoStage.UPLOADING,
|
||
100.0,
|
||
"视频生成完成",
|
||
{"video_url": video_url},
|
||
event_type="viral_video:completed",
|
||
)
|
||
logger.info("[爆款视频] 任务完成: job_id=%s video_url=%s", job_id, video_url)
|
||
return {"ok": True, "job_id": job_id, "video_url": video_url}
|
||
|
||
|
||
@shared_task(bind=True, max_retries=2, name="worker.run_viral_video_render")
|
||
def run_viral_video_render(self: Task, job_id: str) -> dict:
|
||
"""v1.6 阶段3:TTS + 单次 Seedance 生成 + 上传。"""
|
||
session = None
|
||
try:
|
||
_recover_stale_jobs() # 顺带回收僵尸任务
|
||
_hb_stop = None
|
||
session, repo, job = _get_repo_and_job(job_id)
|
||
if job is None:
|
||
return {"ok": False, "error": "job not found"}
|
||
if job.status != ViralVideoStatus.RUNNING:
|
||
return {"ok": False, "error": f"unexpected status: {job.status}"}
|
||
job.touch_heartbeat()
|
||
_save_job(repo, job, session)
|
||
_hb_stop, _hb_thread = _start_heartbeat_thread(job_id)
|
||
return _run_render_pipeline(job_id, session, repo, job)
|
||
except Retry:
|
||
raise
|
||
except Exception as e:
|
||
logger.error("[爆款视频][阶段3] 异常: %s", e, exc_info=True)
|
||
# 兜底:任何阶段3异常都尝试退款(_step_render 内部异常已经退过,但 upload 等后续失败也需退)
|
||
try:
|
||
if session is not None:
|
||
job_safe = None
|
||
try:
|
||
repo_safe = SQLAlchemyViralVideoJobRepository(session)
|
||
job_safe = repo_safe.get(job_id)
|
||
except Exception:
|
||
pass
|
||
if job_safe is not None and float(getattr(job_safe, "credits_prepaid", 0) or 0) > 0:
|
||
_try_refund_viral_video(job_safe)
|
||
try:
|
||
repo_safe.update(job_safe)
|
||
except Exception:
|
||
pass
|
||
except Exception:
|
||
logger.exception("[爆款视频][阶段3] 兜底退款异常")
|
||
_mark_failed_and_notify(job_id, session, None, None, str(e), ViralVideoStage.RENDERING)
|
||
return {"ok": False, "job_id": job_id, "error": str(e)}
|
||
finally:
|
||
if _hb_stop is not None:
|
||
_hb_stop.set()
|
||
if session:
|
||
session.close()
|