Merge pull request 'fix(viral-video): P1 VLM模型ID/僵尸任务心跳/文案兜底; P2 ratio/OSS一致性' (#2146) from fix/2141-p1-vlm-timeout-heartbeat-copy into develop
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 1s
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 3s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 4s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) 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 / Validate - Style (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been skipped
CI/CD Pipeline / Validate - Security (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (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 / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 19s
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 / 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
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m0s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m20s
CI/CD Pipeline / Integration Tests (push) Successful in 4m20s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (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 / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 5m2s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 5m12s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 5m17s
CI/CD Pipeline / Build Staging API Image (push) Successful in 5m0s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 3m26s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 5m35s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 2m29s
CI/CD Pipeline / CI Gate (pull_request) Successful in 2s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
AI Code Review / AI Code Review (pull_request) Successful in 7m15s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 7m24s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m31s
CI/CD Pipeline / Validate - Style (push) Successful in 8m54s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m43s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 2m47s
CI/CD Pipeline / Validate - Security (push) Successful in 10m52s
CI/CD Pipeline / Unit Tests (push) Successful in 12m28s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Failing after 25m13s

fix(viral-video): P1 VLM/heartbeat/copy fallback/P2 ratio/OSS (#2146)
This commit was merged in pull request #2146.
This commit is contained in:
2026-10-02 14:36:21 +08:00
11 changed files with 285 additions and 28 deletions
+2 -2
View File
@@ -217,8 +217,8 @@ DOUBAO_BASE_URL=https://ark.cn-beijing.volces.com/api/v3
DOUBAO_TIMEOUT=30
DOUBAO_MAX_RETRIES=2
# 视觉模型:pro 精度高,lite 速度快(viral-video 商品识别默认用 lite 提速)
DOUBAO_VISION_MODEL=doubao-1-5-vision-pro-250915
DOUBAO_VISION_LITE_MODEL=doubao-1-5-vision-lite-250915
DOUBAO_VISION_MODEL=doubao-1-5-vision-pro-250328
DOUBAO_VISION_LITE_MODEL=doubao-1-5-vision-lite-250315
DOUBAO_VISION_USE_LITE=true
# ==================== 积分/会员系统 (#1895) ====================
@@ -0,0 +1,42 @@
"""viral_video_jobs 增加 heartbeat_at 列(worker 心跳,用于僵尸任务超时回收)
Revision ID: 092_viral_video_heartbeat
Revises: 091_viral_video_stage
Create Date: 2026-10-02
"""
import sqlalchemy as sa
from alembic import op
revision = "092_viral_video_heartbeat"
down_revision = "091_viral_video_stage"
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 "heartbeat_at" not in cols:
op.add_column("viral_video_jobs", sa.Column("heartbeat_at", sa.DateTime(), nullable=True))
op.execute(
"UPDATE viral_video_jobs SET heartbeat_at = updated_at " "WHERE status = 'running' AND heartbeat_at IS NULL"
)
try:
op.create_index("ix_viral_video_jobs_heartbeat_at", "viral_video_jobs", ["heartbeat_at"])
except Exception:
pass
def downgrade() -> None:
conn = op.get_bind()
inspector = sa.inspect(conn)
cols = {c["name"] for c in inspector.get_columns("viral_video_jobs")}
if "heartbeat_at" in cols:
try:
op.drop_index("ix_viral_video_jobs_heartbeat_at", table_name="viral_video_jobs")
except Exception:
pass
op.drop_column("viral_video_jobs", "heartbeat_at")
+19 -5
View File
@@ -400,28 +400,42 @@ def retry_viral_video_job(
authenticated_user: AuthenticatedUser = Depends(get_current_user),
session: Session = Depends(get_db_session),
) -> ViralVideoJobResponse:
"""重试失败的爆款视频任务。"""
"""重试失败的爆款视频任务(也支持对僵尸/超时 running 任务强制重置后重试)。"""
from datetime import datetime, timezone
repo = _get_job_repo(session)
job = repo.get(job_id)
if job is None:
raise HTTPException(status_code=404, detail="任务不存在")
if job.user_id != authenticated_user.user.id:
raise HTTPException(status_code=403, detail="无权操作此任务")
if job.status != ViralVideoStatus.FAILED:
raise HTTPException(status_code=409, detail="只有失败的任务可以重试")
# 判定是否为僵尸 running 任务:running 超过 10 分钟且心跳停止超过 2 分钟
now = datetime.now(timezone.utc)
is_stale_running = False
if job.status == ViralVideoStatus.RUNNING and job.started_at is not None:
hb = getattr(job, "heartbeat_at", None) or job.updated_at
if (now - job.started_at).total_seconds() > 10 * 60 and hb is not None and (now - hb).total_seconds() > 2 * 60:
is_stale_running = True
if job.status != ViralVideoStatus.FAILED and not is_stale_running:
raise HTTPException(status_code=409, detail="只有失败或超时的任务可以重试")
# 重置状态
job.retry_count += 1
job.status = ViralVideoStatus.PENDING
job.error_msg = ""
job.error_msg = "" if not is_stale_running else "任务执行超时,已重置重试"
job.started_at = None
job.completed_at = None
job.current_stage = ""
job.phase_message = ""
job.heartbeat_at = None
repo.update(job)
# 重新入队
try:
celery_app.send_task("worker.run_viral_video_pipeline", args=[job.id])
logger.info("[爆款视频] 重试入队: job_id=%s retry_count=%d", job.id, job.retry_count)
logger.info("[爆款视频] 重试入队: job_id=%s retry_count=%d stale=%s", job.id, job.retry_count, is_stale_running)
except Exception as e:
logger.error("[爆款视频] 重试入队失败: %s", e, exc_info=True)
job.mark_failed(f"重试入队失败: {e}")
+192 -8
View File
@@ -23,6 +23,8 @@ import json
import logging
import os
import tempfile
import threading
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
from pathlib import Path
@@ -106,6 +108,96 @@ def _set_stage(job, repo, session, stage: str, message: str, persist: bool = Tru
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 = [
@@ -329,8 +421,8 @@ def _step_image_analysis(job: ViralVideoJob) -> dict:
pro_model = None # 已经是 pro,不再降级
vision_timeout = 60
except Exception:
vision_model = "doubao-1-5-vision-lite-250915"
pro_model = "doubao-1-5-vision-pro-250915"
vision_model = "doubao-1-5-vision-lite-250315"
pro_model = "doubao-1-5-vision-pro-250328"
vision_timeout = 25
results: list[dict] = [None] * len(job.images) # type: ignore
@@ -537,7 +629,8 @@ _SCRIPT_GENERATION_PROMPT = """你是资深短视频导演,为 Seedance 2.5(
6. hard_constraints/negative_prompts 保留默认项可追加,不要删减。
7. voiceover_script 为纯口播文本(无标记/括号/前缀),{duration}秒约{approx_chars}字。
8. 严格按上方「爆款结构」的节奏/段落顺序编排(钩子/痛点/反转/案例/行动号召与结构对齐)。
"""
9. 输出前自检:口播对白禁止错别字和语病(特别注意"很/最"等常见误用),同音字错误一律修正。
10. 必须使用产品信息中真实的品牌、品名和外观特征,不要编造与产品无关的内容。"""
def _build_products_summary(image_analysis: dict) -> str:
@@ -814,13 +907,52 @@ def _step_script_generation(job: ViralVideoJob, intent: dict, image_analysis: di
_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字)不视为兜底
fallback_marker = "我最近在用的好物" in voiceover # _fallback_script 的特征串
is_fallback = fallback_marker or shots_cnt < 1 or voiceover_len < 20
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:
# 编导脚本是结构化 JSON 输出,用快模型提速(temperature 稍高保证创意)
result = call_llm(prompt, temperature=0.8, max_tokens=2500, model=_fast)
parsed = _safe_json_loads(result)
return _validate_and_normalize_script(parsed, job)
# 第一次:快模型
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)
logger.warning("[爆款视频] 编导脚本生成异常: %s,使用兜底脚本", e, exc_info=True)
return _fallback_script(job)
@@ -1029,6 +1161,27 @@ def _step_upload(job: ViralVideoJob, video_path: str) -> str:
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
# ── 主编排器 ────────────────────────────────────────────────────────────
@@ -1037,11 +1190,15 @@ def run_viral_video_pipeline(self: Task, job_id: str) -> dict:
"""旧一键流水线(保留兼容):图片分析→风格分析→意图解析→WAIT_USER_CONFIRM。"""
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"}
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)
@@ -1088,6 +1245,8 @@ def run_viral_video_pipeline(self: Task, job_id: str) -> dict:
_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()
@@ -1170,11 +1329,15 @@ def run_viral_video_analyze(self: Task, job_id: str) -> dict:
"""v1.5+ 阶段1:图片 VLM 分析 + 可选视频风格分析。"""
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"}
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)
@@ -1217,6 +1380,8 @@ def run_viral_video_analyze(self: Task, job_id: str) -> dict:
_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()
@@ -1233,11 +1398,16 @@ def run_viral_video_generate_copy(self: Task, job_id: str) -> dict:
"""
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)
# 阶段:意图解析
_set_stage(job, repo, session, ViralVideoStage.INTENT_PARSING, "正在解析文案意图...")
@@ -1306,6 +1476,8 @@ def run_viral_video_generate_copy(self: Task, job_id: str) -> dict:
_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()
@@ -1402,6 +1574,11 @@ def _run_render_pipeline(job_id: str, session, repo, job) -> dict:
_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)
job.credits_cost = CREDITS_VIRAL_VIDEO_COST
job.mark_completed(video_url)
job.current_stage = ViralVideoStage.UPLOADING
@@ -1425,11 +1602,16 @@ 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
@@ -1438,5 +1620,7 @@ def run_viral_video_render(self: Task, job_id: str) -> dict:
_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()
@@ -952,6 +952,7 @@ class ViralVideoJobModel(Base):
status = Column(String(30), nullable=False, default="pending", index=True)
current_stage = Column(String(200), nullable=False, default="") # 细粒度阶段 snake_case
phase_message = Column(String(500), nullable=False, default="") # 阶段中文提示文案
heartbeat_at = Column(DateTime, nullable=True, index=True) # worker 心跳,用于僵尸任务超时回收
intent_result = Column(JSON, nullable=True)
image_analysis = Column(JSON, nullable=True)
storyboard = Column(JSON, nullable=True)
@@ -39,6 +39,7 @@ def _to_domain(model: ViralVideoJobModel) -> ViralVideoJob:
status=ViralVideoStatus(model.status) if model.status else ViralVideoStatus.PENDING,
current_stage=getattr(model, "current_stage", "") or "",
phase_message=getattr(model, "phase_message", "") or "",
heartbeat_at=getattr(model, "heartbeat_at", None),
intent_result=dict(model.intent_result) if model.intent_result else None,
image_analysis=dict(model.image_analysis) if getattr(model, "image_analysis", None) else None,
storyboard=list(model.storyboard) if getattr(model, "storyboard", None) else None,
@@ -87,6 +88,7 @@ class SQLAlchemyViralVideoJobRepository:
status=job.status,
current_stage=job.current_stage or "",
phase_message=job.phase_message or "",
heartbeat_at=job.heartbeat_at,
intent_result=job.intent_result,
image_analysis=job.image_analysis,
storyboard=job.storyboard,
@@ -112,6 +114,7 @@ class SQLAlchemyViralVideoJobRepository:
model.status = job.status
model.current_stage = job.current_stage or ""
model.phase_message = job.phase_message or ""
model.heartbeat_at = job.heartbeat_at
model.intent_result = job.intent_result
model.image_analysis = job.image_analysis
model.storyboard = job.storyboard
+2 -2
View File
@@ -95,8 +95,8 @@ class SharedSettings(BaseSettings):
doubao_base_url: str = "https://ark.cn-beijing.volces.com/api/v3"
doubao_timeout: int = 30
doubao_max_retries: int = 2
doubao_vision_model: str = "doubao-1-5-vision-pro-250915" # 高精度视觉(备用)
doubao_vision_lite_model: str = "doubao-1-5-vision-lite-250915" # 快速视觉(商品识别默认,速度优先)
doubao_vision_model: str = "doubao-1-5-vision-pro-250328" # 高精度视觉(备用)
doubao_vision_lite_model: str = "doubao-1-5-vision-lite-250315" # 快速视觉(商品识别默认,速度优先)
doubao_vision_use_lite: bool = True # viral-video 图片分析默认用 lite 提速
doubao_embedding_model: str = "doubao-embedding-large-text-240915"
doubao_video_model: str = "doubao-seedance-2-5-260628"
+13 -2
View File
@@ -119,6 +119,7 @@ class ViralVideoJob:
status: ViralVideoStatus = ViralVideoStatus.PENDING
current_stage: str = "" # 细粒度阶段(ViralVideoStage.value,snake_case)
phase_message: str = "" # 阶段中文提示文案,前端轮询直接展示
heartbeat_at: datetime | None = None # worker 心跳时间,用于超时僵尸任务检测
result_video_url: str = ""
credits_cost: int = 0
error_msg: str = ""
@@ -140,9 +141,19 @@ class ViralVideoJob:
):
raise ValueError(f"Cannot transition from {self.status} to running")
self.status = ViralVideoStatus.RUNNING
now = datetime.now(timezone.utc)
if self.started_at is None:
self.started_at = datetime.now(timezone.utc)
self.updated_at = datetime.now(timezone.utc)
self.started_at = now
self.heartbeat_at = now
self.updated_at = now
def touch_heartbeat(self) -> None:
"""更新心跳时间(worker 在长任务中周期性调用,用于超时检测)。"""
now = datetime.now(timezone.utc)
if self.started_at is None:
self.started_at = now
self.heartbeat_at = now
self.updated_at = now
def mark_image_analyzed(self) -> None:
if self.status not in (ViralVideoStatus.PENDING, ViralVideoStatus.RUNNING):
+6 -5
View File
@@ -162,7 +162,7 @@ class DoubaoClient:
"""调用豆包视觉理解 API(OpenAI 兼容多模态格式).
将 images 附加到最后一条 user message 的 content 中,
使用 vision_model(默认 doubao-1-5-vision-pro-250915)。
使用 vision_model(默认 doubao-1-5-vision-pro-250328)。
Args:
messages: 对话消息列表。最后一条 user message 会被注入图片内容。
@@ -268,9 +268,9 @@ class DoubaoClient:
Args:
prompt: 文本提示词(含完整编导脚本:总览+场景光线+逐镜头时间轴+硬约束+负面词)
image_url: 首帧参考图 URL(可选,提供则走图生视频首帧模式,ratio 跟随首帧)
image_url: 首帧参考图 URL(可选,提供则走图生视频首帧模式,ratio 仍按参数传,模型会自动居中裁剪到目标比例)
duration: 视频时长 4~30 秒
ratio: 宽高比 16:9/9:16/1:1/4:3/3:4/21:9/adaptive;image_url 存在时自动忽略
ratio: 宽高比 16:9/9:16/1:1/4:3/3:4/21:9/adaptive;图生视频时模型会自动居中裁剪首帧到目标比例
resolution: 480p/720p/1080p
generate_audio: 是否让模型原生合成音效/BGM(v1.6 默认 True,配合 reference_audios 做口型驱动)
watermark: 是否加水印
@@ -319,8 +319,9 @@ class DoubaoClient:
# v1.6: 参考视频(风格参考)
if reference_videos:
create_payload["reference_videos"] = [{"url": u} for u in reference_videos[:5] if u and isinstance(u, str)]
# Bug #2110 / v1.6: ratio=None 时不传(首帧图生视频跟随原图比例)
if ratio and not image_url:
# 图生视频也必须传 ratio,否则模型默认 adaptive 可能输出 1:1(首帧方形商品图会导致 960x960)
# 官方文档:图生视频会自动居中裁剪首帧到目标比例,支持 16:9/9:16/1:1/4:3/3:4/21:9/adaptive
if ratio:
create_payload["ratio"] = ratio
headers = {
+3 -2
View File
@@ -625,13 +625,14 @@ def call_video_generation(
- reference_audios 传 TTS 音频 URL 数组做口型驱动;
- reference_images 传产品素材 URL 数组做视觉参考;
- 单次最长 30 秒,不分段不拼接;
- image_url 存在时为「首帧图生视频」模式,自动不传 ratio(Bug #2110)。
- 图生视频(image_url 存在)也强制传 ratio,避免商品方图导致默认输出 1:1。
官方文档:图生视频时 ratio 由参数决定,模型会居中裁剪首帧到目标比例。
"""
client = get_doubao_client()
if not client.is_available:
logger.warning("[ai_service] 豆包客户端未配置,跳过视频生成")
return None
effective_ratio = None if image_url else ratio
effective_ratio = ratio or "9:16"
try:
kwargs: dict = dict(
prompt=prompt,
+2 -2
View File
@@ -253,8 +253,8 @@ class TestCallVideoGenerationV16:
)
assert result == str(out)
kwargs = mock_client.video_generation.call_args.kwargs
# 首帧模式不传 ratio(Bug #2110)
assert "ratio" not in kwargs
# 图生视频也必须传 ratio(避免首帧方图导致默认输出 1:1)
assert kwargs.get("ratio") == "9:16", f"ratio 应透传,got {kwargs.get('ratio')!r}"
assert kwargs["image_url"] == "https://img/x.jpg"
assert kwargs["reference_audios"] == ["https://oss/tts.mp3"]
assert kwargs["reference_images"] == ["https://img/r1.jpg"]