perf(#2172): 信任链 Seedream i2i 预热与文案生成并行,缩短有参考图场景等待时间
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 1s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 1s
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
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m5s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 3m24s
CI/CD Pipeline / Integration Tests (pull_request) Failing after 3m28s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 4m6s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Failing after 4m19s
AI Code Review / AI Code Review (pull_request) Successful in 4m57s
CI/CD Pipeline / Validate - Style (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Security (pull_request) Has been cancelled
CI/CD Pipeline / Unit Tests (pull_request) Has been cancelled
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been cancelled
Preview Deploy / Deploy Preview Environment (pull_request) Has been cancelled

问题:
- #2170 信任链方案在视频渲染阶段同步串行跑 Seedream AI 化(每张约5-15s)
- 用户提交到出片要多等一个 Seedream i2i 耗时
- 主人反馈「分析图片很慢」「生成文案也很慢」

方案:
- 抽取 ai_client.preheat_trust_chain() 独立方法
- 在阶段1(图片VLM分析)开始时,用 daemon 线程后台并行启动 Seedream i2i 预热
- 预热结果持久化到 job.pre_trusted_images 字段(新增 094 migration)
- 阶段3渲染时,若预热完成直接使用 pre_trusted_images,跳过现场 Seedream i2i
- 预热失败/未完成不影响主流程,自动回退到现场跑信任链兜底
- VLM分析(~5-15s) + 文案生成(~10-20s) + 用户编辑确认(~30s+)期间,Seedream i2i 在后台已完成

改动文件:
- packages/shared/ai_client.py: 抽取 preheat_trust_chain() 方法;video_generation 加 pre_trusted_images 参数支持预热结果
- packages/shared/ai_service.py: 新增 preheat_trust_chain() 入口;call_video_generation 透传 pre_trusted_images
- packages/domain/viral_video.py: ViralVideoJob 加 pre_trusted_images 字段
- packages/adapters/sqlalchemy_impl/models.py: ViralVideoJobModel 加 pre_trusted_images 列
- packages/adapters/sqlalchemy_impl/viral_video_repository.py: _to_domain/save/update 映射新字段
- alembic/versions/094_viral_video_pre_trusted.py: 新增列 migration
- apps/worker/worker_app/tasks/viral_video.py: _start_trust_chain_preheat() 后台预热启动函数;阶段1启动预热;_step_render 使用预热结果

测试:16513 passed(2个musetalk pre-existing失败,无关)
This commit is contained in:
saas-backend
2026-10-04 11:50:18 +08:00
parent f097e16bc1
commit f8bd8bfe61
7 changed files with 198 additions and 34 deletions
@@ -0,0 +1,31 @@
"""viral_video_jobs 增加 pre_trusted_images 列(信任链Seedream预热结果)
Revision ID: 094_viral_video_pre_trusted
Revises: 093_viral_video_pricing_points_float
Create Date: 2026-10-04
"""
import sqlalchemy as sa
from alembic import op
revision = "094_viral_video_pre_trusted"
down_revision = "093_viral_video_pricing_points_float"
branch_labels = None
depends_on = None
def upgrade() -> None:
conn = op.get_bind()
inspector = sa.inspect(conn)
cols = {c["name"] for c in inspector.get_columns("viral_video_jobs")}
if "pre_trusted_images" not in cols:
op.add_column("viral_video_jobs", sa.Column("pre_trusted_images", sa.Text(), nullable=True))
def downgrade() -> None:
conn = op.get_bind()
inspector = sa.inspect(conn)
cols = {c["name"] for c in inspector.get_columns("viral_video_jobs")}
if "pre_trusted_images" in cols:
op.drop_column("viral_video_jobs", "pre_trusted_images")
@@ -92,6 +92,62 @@ def _save_job(repo, job, session):
session.commit()
def _start_trust_chain_preheat(job_id: str, portrait_urls: list[str]) -> None:
"""#2172 后台启动信任链预热(Seedream AI 化人像),不阻塞调用方。
预热成功后把结果写入 job.pre_trusted_images,阶段3 渲染直接使用,省掉串行等待。
预热失败静默(pre_trusted_images 保持 None),阶段3 会现场跑信任链兜底。
"""
if not portrait_urls:
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():
sess = None
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] 后台预热启动 job=%s n=%d", job_id, len(portrait_urls))
result = preheat_trust_chain(portrait_urls, timeout=120)
if result and len(result) == len(portrait_urls):
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] 预热完成并持久化 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 推送进度事件。
@@ -1162,6 +1218,21 @@ def _step_render(job: ViralVideoJob, copy_result: dict, tts_audio_url: str | Non
)
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,
@@ -1175,6 +1246,7 @@ def _step_render(job: ViralVideoJob, copy_result: dict, tts_audio_url: str | Non
reference_images=rest_images,
reference_audios=ref_audios,
reference_videos=ref_videos,
pre_trusted_images=pre_trusted,
)
# #2170: 真人/肖像拦截由 ai_client 内部信任链自动处理(Seedream AI 化后再调 Seedance);
@@ -1274,6 +1346,13 @@ def run_viral_video_pipeline(self: Task, job_id: str) -> dict:
_hb_stop, _hb_thread = _start_heartbeat_thread(job_id)
_set_stage(job, repo, session, ViralVideoStage.IMAGE_ANALYSIS, "正在分析商品特征...")
# #2172: 信任链预热与 VLM 分析并行启动(不阻塞)
if job.images:
try:
_start_trust_chain_preheat(job.id, list(job.images))
except Exception as _e:
logger.warning("[爆款视频][阶段1] 启动信任链预热失败: %s", _e)
image_analysis = _step_image_analysis(job)
job.image_analysis = image_analysis
_save_job(repo, job, session)
@@ -928,6 +928,7 @@ class ViralVideoJobModel(Base):
id = Column(String(36), primary_key=True)
user_id = Column(String(36), nullable=False, index=True)
images = Column(JSON, nullable=False, default=list) # 产品图片 URL 列表
pre_trusted_images = Column(JSON, nullable=True) # #2172 信任链预热结果(Seedream AI 化 URL 列表)
industry = Column(String(100), nullable=False, default="")
target_customer = Column(String(500), nullable=False, default="")
persona_id = Column(String(36), nullable=False, default="")
@@ -18,6 +18,7 @@ def _to_domain(model: ViralVideoJobModel) -> ViralVideoJob:
id=model.id,
user_id=model.user_id,
images=list(model.images or []),
pre_trusted_images=list(getattr(model, "pre_trusted_images", None) or []) if getattr(model, "pre_trusted_images", None) is not None else None,
industry=model.industry or "",
target_customer=model.target_customer or "",
persona_id=model.persona_id or "",
@@ -70,6 +71,7 @@ class SQLAlchemyViralVideoJobRepository:
id=job.id,
user_id=job.user_id,
images=job.images,
pre_trusted_images=job.pre_trusted_images,
industry=job.industry,
target_customer=job.target_customer,
persona_id=job.persona_id,
@@ -126,6 +128,7 @@ class SQLAlchemyViralVideoJobRepository:
model.storyboard = job.storyboard
model.generated_copy_text = job.generated_copy_text or ""
model.copy_result = job.copy_result
model.pre_trusted_images = job.pre_trusted_images
model.result_video_url = job.result_video_url
model.video_resolution = getattr(job, "video_resolution", "720p") or "720p"
model.credits_prepaid = float(getattr(job, "credits_prepaid", 0) or 0)
+1
View File
@@ -87,6 +87,7 @@ class ViralVideoJob:
user_id: str
images: list[str] = field(default_factory=list)
pre_trusted_images: list[str] | None = None # #2172 信任链预热结果(Seedream AI 化后的 URL 列表),与 images 顺序对应
industry: str = ""
target_customer: str = ""
persona_id: str = ""
+64 -33
View File
@@ -413,6 +413,47 @@ class DoubaoClient:
# ── 视频生成(Seedance 2.5,异步任务)────────────────────────────
def preheat_trust_chain(
self,
portrait_urls: list[str],
*,
timeout: int = 120,
) -> list[str] | None:
"""#2172 信任链预热:对一组人像 URL 执行 Seedream AI 化,返回 AI 化后的 URL 列表。
- 全部成功返回 list[str](顺序与输入一致)
- 任何一张失败返回 None(保留上层回退原图直传的路径)
- 供 worker 在视频生成前并行预热使用;video_generation 内部若收到 preheated 结果会直接使用,不再现场跑。
"""
if not portrait_urls or not self.is_available:
return None
seedream_prompt = (
"保持此人五官特征、发型、肤色、面部轮廓、年龄感,生成一张高清写实人像照片,"
"人物外貌特征与参考图完全一致,皮肤自然,光线柔和,高清细节,不要过度美化。"
)
trusted: list[str] = []
_t0 = time.time()
for idx, raw_url in enumerate(portrait_urls):
sd_prompt = seedream_prompt if len(portrait_urls) == 1 else f"{seedream_prompt}(这是参考图{idx + 1})"
sd_result = self.image_generation(
prompt=sd_prompt,
reference_images=[raw_url],
size="2K",
timeout=timeout,
)
if not sd_result:
logger.warning(
"[trust-chain][preheat] Seedream 第 %d/%d 张失败: %s,预热整体失败",
idx + 1, len(portrait_urls), getattr(self, "last_image_error", None),
)
return None
trusted.append(sd_result["url"])
logger.info(
"[trust-chain][preheat] Seedream AI 化预热完成 %d 张,总耗时 %.1fs",
len(trusted), time.time() - _t0,
)
return trusted
def video_generation(
self,
prompt: str,
@@ -428,6 +469,7 @@ class DoubaoClient:
reference_images: list[str] | None = None,
reference_audios: list[str] | None = None,
reference_videos: list[str] | None = None,
pre_trusted_images: list[str] | None = None,
) -> dict | None:
"""调用 Seedance 2.5 生视频(异步任务→轮询→下载)。
@@ -511,66 +553,55 @@ class DoubaoClient:
ref_videos = [u for u in (reference_videos or [])[:3] if u and isinstance(u, str)]
ref_imgs = [u for u in (reference_images or [])[:9] if u and isinstance(u, str)]
# ── #2170 方舟信任链(Trust Chain)────────────────────────────────────
# ── #2170/#2172 方舟信任链(Trust Chain)────────────────────────────────
# 真人照片直接传给 Seedance 会触发 50411 肖像审核拦截。
# 解决:先通过同账号的 Seedream 5.0 Pro 图生图 AI 化(保持五官特征),
# 得到的 AI 产物图属于"模型信任产物",再作为 reference_image 传给 Seedance 即可通过审核。
# #2172: 支持预热结果 pre_trusted_images(worker 在文案阶段并行预热,省掉串行等待);
# 预热结果有效则直接使用,否则现场跑一次 Seedream AI 化。
# 信任链只作用于 doubao provider;DashScope(Wan) 保持原行为。
trust_chain_applied = False
if provider == "doubao":
seedream_prompt = (
"保持此人五官特征、发型、肤色、面部轮廓、年龄感,生成一张高清写实人像照片,"
"人物外貌特征与参考图完全一致,皮肤自然,光线柔和,高清细节,不要过度美化。"
)
raw_portrait_urls: list[str] = []
if image_url:
raw_portrait_urls.append(image_url)
for u in ref_imgs:
if u not in raw_portrait_urls:
raw_portrait_urls.append(u)
if raw_portrait_urls:
trusted_urls: list[str] = []
trusted_urls: list[str] = []
if pre_trusted_images and len(pre_trusted_images) == len(raw_portrait_urls):
# #2172: 使用预热结果
trusted_urls = list(pre_trusted_images)
trust_chain_applied = True
logger.info(
"[trust-chain] 使用预热结果 %d 张,替换为 reference_image 模式",
len(trusted_urls),
)
elif raw_portrait_urls:
# 现场跑信任链
_tc_t0 = time.time()
for idx, raw_url in enumerate(raw_portrait_urls):
sd_prompt = (
seedream_prompt if len(raw_portrait_urls) == 1 else f"{seedream_prompt}(这是参考图{idx + 1})"
)
sd_result = self.image_generation(
prompt=sd_prompt,
reference_images=[raw_url],
size="2K",
timeout=120,
)
if not sd_result:
logger.warning(
"[trust-chain] Seedream 第 %d/%d 张失败: %s,回退直传原图",
idx + 1,
len(raw_portrait_urls),
self.last_image_error,
)
break
trusted_urls.append(sd_result["url"])
trusted_urls = self.preheat_trust_chain(raw_portrait_urls) or []
if trusted_urls and len(trusted_urls) == len(raw_portrait_urls):
trust_chain_applied = True
# 替换:原 image_url 用第一张 AI 图,ref_imgs 用剩余
if image_url and trusted_urls:
image_url = trusted_urls[0]
ref_imgs = trusted_urls[1:] if len(trusted_urls) > 1 else []
else:
ref_imgs = trusted_urls
logger.info(
"[trust-chain] Seedream AI 化完成 %d 张,总耗时 %.1fs,替换为 reference_image 模式",
len(trusted_urls),
time.time() - _tc_t0,
)
else:
# Seedream 部分失败 → 回退原图直传(仍可能被 50411 拦截,但保留降级路径)
logger.warning(
"[trust-chain] Seedream AI 化不完整(%d/%d),总耗时 %.1fs,回退原图直传",
len(trusted_urls),
len(raw_portrait_urls),
time.time() - _tc_t0,
)
if trust_chain_applied and trusted_urls:
# 替换:原 image_url 用第一张 AI 图,ref_imgs 用剩余
if image_url and trusted_urls:
image_url = trusted_urls[0]
ref_imgs = trusted_urls[1:] if len(trusted_urls) > 1 else []
else:
ref_imgs = trusted_urls
# ─────────────────────────────────────────────────────────────────
# 判断任务模式:
+19 -1
View File
@@ -604,6 +604,22 @@ def call_vision(
return raw
def preheat_trust_chain(portrait_urls: list[str], *, timeout: int = 120) -> list[str] | None:
"""#2172 信任链预热:提前把人像图跑 Seedream AI 化,结果可传给 call_video_generation(pre_trusted_images=...)。
成功返回与输入同序的 AI 化 URL 列表;任意一张失败返回 None(调用方回退到现场跑信任链)。
"""
client = get_doubao_client()
if not client.is_available:
return None
try:
return client.preheat_trust_chain(portrait_urls, timeout=timeout)
except Exception as e:
logger.error("[ai_service] preheat_trust_chain 异常: %s", e, exc_info=True)
return None
def call_video_generation(
prompt: str,
*,
@@ -617,8 +633,9 @@ def call_video_generation(
reference_images: list[str] | None = None,
reference_audios: list[str] | None = None,
reference_videos: list[str] | None = None,
pre_trusted_images: list[str] | None = None,
) -> dict | None:
"""调用 Seedance / Wan 视频生成(v1.6.2 多模型版)。
"""调用 Seedance / Wan 视频生成(v1.6.2 多模型版 + #2172 信任链预热)。
成功返回 {"video_path": str, "usage": dict | None}(usage 含 completion_tokens),失败返回 None。
失败时错误详情会写入 client.last_video_error,可通过 get_last_video_error() 读取:
@@ -650,6 +667,7 @@ def call_video_generation(
reference_images=reference_images,
reference_audios=reference_audios,
reference_videos=reference_videos,
pre_trusted_images=pre_trusted_images,
)
if effective_ratio:
kwargs["ratio"] = effective_ratio