fix(viral-video): P1 VLM模型ID/僵尸任务心跳/文案兜底/P2 ratio映射/OSS一致性
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 3s
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 3s
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 API Image (pull_request) Successful in 19s
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 35s
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 3m24s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 4m9s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 4m44s
CI/CD Pipeline / Unit Tests (pull_request) Has been cancelled
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
AI Code Review / AI Code Review (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
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 3s
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 3s
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 API Image (pull_request) Successful in 19s
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 35s
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 3m24s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 4m9s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 4m44s
CI/CD Pipeline / Unit Tests (pull_request) Has been cancelled
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
AI Code Review / AI Code Review (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
P1-1: VLM lite/pro模型ID修正(250915→250315/250328),根因是旧ID不存在导致lite必然超时降级pro - packages/config/base.py: doubao_vision_model/pro/lite 改为火山方舟官方正确ID - packages/shared/ai_client.py docstring同步更新 - apps/worker/worker_app/tasks/viral_video.py 硬编码字符串同步 - .env.example 同步新ID P1-2: worker心跳+僵尸任务超时回收 - packages/domain/viral_video.py: 新增heartbeat_at字段+touch_heartbeat()方法 - packages/adapters/sqlalchemy_impl/models.py: ViralVideoJobModel加heartbeat_at列(index=True) - packages/adapters/sqlalchemy_impl/viral_video_repository.py: to_entity/save/update映射heartbeat_at - alembic/versions/092_viral_video_heartbeat.py: 幂等迁移,加列+index+给已有running任务初始化 - apps/worker/worker_app/tasks/viral_video.py: - 新增_start_heartbeat_thread后台线程,每25s独立session更新heartbeat_at - 新增_recover_stale_jobs(): running>10min且心跳>2min未更新→自动标记failed - pipeline/analyze/generate_copy/render四个celery任务接入心跳启停+启动时回收僵尸 - apps/api/app/api/routes/viral_video.py: retry接口放宽,允许对超时僵尸任务重试 P1-3: 文案生成质量与兜底修复 - 兜底脚本'很近'→'最近'(之前commit已改) - _step_script_generation 三级重试: fast(0.8/2500tok)→fast(0.6/3200tok)→pro(0.7/3500tok) - 新增fallback判定:口播<20字/含兜底特征串/镜头数<1才视为降级,避免正常短脚本误判 - prompt关键要求新增'输出前自检:口播禁止错别字和语病' - 关键路径打info日志(模型/label/口播长度/镜头数/是否兜底),便于后续排查 P2-1: Seedance图生视频ratio不生效导致9:16出1:1 - 根因: 旧逻辑Bug #2110在image_url存在时不传ratio,模型默认adaptive→首帧方图商品图输出1:1 - 官方文档确认图生视频支持ratio参数,模型会自动居中裁剪首帧 - packages/shared/ai_client.py+ai_service.py: 删除image_url判空,始终传ratio - 同步更新docstring注释 P2-2: OSS成片eventual consistency导致completed后NoSuchKey - apps/worker/worker_app/tasks/viral_video.py: 新增_wait_oss_ready(url,timeout=10s) - mark_completed前循环HEAD公网URL,等200+content-length>0或10s超时 测试: viral-video相关97个单测全部通过,仅更新test_passes_reference_params_to_client以反映新ratio透传行为
This commit is contained in:
+2
-2
@@ -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
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
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")
|
||||
@@ -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, timedelta, 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}")
|
||||
|
||||
@@ -25,6 +25,8 @@ import os
|
||||
import tempfile
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from pathlib import Path
|
||||
import threading
|
||||
import time
|
||||
|
||||
from celery import Task, shared_task
|
||||
from celery.exceptions import Retry
|
||||
@@ -106,6 +108,95 @@ 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()
|
||||
model = 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 +420,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 +628,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 +906,48 @@ 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 +1156,26 @@ 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 +1184,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 +1239,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 +1323,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 +1374,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 +1392,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 +1470,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 +1568,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 +1596,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 +1614,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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"]
|
||||
|
||||
Reference in New Issue
Block a user