diff --git a/.env.example b/.env.example index d7a0cfb67..5bee1579d 100755 --- a/.env.example +++ b/.env.example @@ -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) ==================== diff --git a/alembic/versions/092_viral_video_heartbeat.py b/alembic/versions/092_viral_video_heartbeat.py new file mode 100644 index 000000000..6db6c4e6c --- /dev/null +++ b/alembic/versions/092_viral_video_heartbeat.py @@ -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") diff --git a/apps/api/app/api/routes/viral_video.py b/apps/api/app/api/routes/viral_video.py index d3562ff42..564ecada1 100644 --- a/apps/api/app/api/routes/viral_video.py +++ b/apps/api/app/api/routes/viral_video.py @@ -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}") diff --git a/apps/worker/worker_app/tasks/viral_video.py b/apps/worker/worker_app/tasks/viral_video.py index a5d2fb6da..7d002347f 100644 --- a/apps/worker/worker_app/tasks/viral_video.py +++ b/apps/worker/worker_app/tasks/viral_video.py @@ -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() diff --git a/packages/adapters/sqlalchemy_impl/models.py b/packages/adapters/sqlalchemy_impl/models.py index 0a2ca4931..6fc5609a3 100755 --- a/packages/adapters/sqlalchemy_impl/models.py +++ b/packages/adapters/sqlalchemy_impl/models.py @@ -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) diff --git a/packages/adapters/sqlalchemy_impl/viral_video_repository.py b/packages/adapters/sqlalchemy_impl/viral_video_repository.py index 995436569..df96ad0f7 100755 --- a/packages/adapters/sqlalchemy_impl/viral_video_repository.py +++ b/packages/adapters/sqlalchemy_impl/viral_video_repository.py @@ -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 diff --git a/packages/config/base.py b/packages/config/base.py index adf882c75..eb375c8f4 100755 --- a/packages/config/base.py +++ b/packages/config/base.py @@ -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" diff --git a/packages/domain/viral_video.py b/packages/domain/viral_video.py index 2f957e71f..5155c306b 100755 --- a/packages/domain/viral_video.py +++ b/packages/domain/viral_video.py @@ -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): diff --git a/packages/shared/ai_client.py b/packages/shared/ai_client.py index c074c50b8..66d26bb02 100755 --- a/packages/shared/ai_client.py +++ b/packages/shared/ai_client.py @@ -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 = { diff --git a/packages/shared/ai_service.py b/packages/shared/ai_service.py index 8c8e833c7..0352a4ed2 100755 --- a/packages/shared/ai_service.py +++ b/packages/shared/ai_service.py @@ -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, diff --git a/tests/unit/test_viral_video_p0.py b/tests/unit/test_viral_video_p0.py index 6372388a7..acb8233cf 100644 --- a/tests/unit/test_viral_video_p0.py +++ b/tests/unit/test_viral_video_p0.py @@ -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"]