Files
xiaoxia-saas/packages/shared/ai_service.py
T
xiaoxia ad686dcd8b
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
fix(viral-video): P1 VLM模型ID/僵尸任务心跳/文案兜底/P2 ratio映射/OSS一致性
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透传行为
2026-10-02 14:05:48 +08:00

656 lines
22 KiB
Python
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""AI 服务层 — 智能推荐 & 封面生成.
提供 AI 推荐片段编排方案和封面生成的核心业务逻辑。
API 层和 Worker 层都从此模块导入,避免 API 直接依赖 Worker 代码。
"""
from __future__ import annotations
import copy
import json
import logging
import random
import time
from typing import Any, Optional
from packages.domain.config_schemas import DEFAULT_EDIT_PLAN_CONFIG
from packages.shared.ai_client import get_doubao_client
logger = logging.getLogger(__name__)
# ── AI 推荐片段方案 ──────────────────────────────────────────────────────────
def _fallback_recommend_clips(
plan_id: str,
template_id: str,
asset_ids: list[str],
editing_mode: str,
target_duration: float,
) -> dict[str, Any]:
"""本地降级推荐方案(原 stub 逻辑).
当豆包 API 不可用或调用失败时使用,基于模板规则生成模拟推荐数据。
"""
# 模拟 AI 分析耗时
time.sleep(0.5)
# 根据素材数量生成推荐片段
clips: list[dict[str, Any]] = []
order = 0
# 开场片段
clips.append(
{
"clip_type": "intro",
"order": order,
"text_content": "精彩看点",
"duration": 3.0,
"transition_effect": "fade",
"asset_id": asset_ids[0] if asset_ids else "",
"start_time": 0.0,
"config": {},
}
)
order += 1
# 为每个素材生成展示片段
per_clip_duration = max(2.0, (target_duration - 6.0) / max(len(asset_ids), 1))
for i, asset_id in enumerate(asset_ids):
clips.append(
{
"clip_type": "showcase",
"order": order,
"text_content": f"展示片段 {i + 1}",
"duration": round(per_clip_duration, 1),
"transition_effect": "cut",
"asset_id": asset_id,
"start_time": 0.0,
"config": {},
}
)
order += 1
# 结尾 CTA
clips.append(
{
"clip_type": "outro",
"order": order,
"text_content": "感谢观看",
"duration": 3.0,
"transition_effect": "fade",
"asset_id": "",
"start_time": 0.0,
"config": {},
}
)
order += 1
# 生成推荐 config
config = copy.deepcopy(DEFAULT_EDIT_PLAN_CONFIG)
config["title"]["text"] = f"精选视频 — {len(asset_ids)} 个片段"
config["title"]["ai_auto"] = True
return {
"clips": clips,
"config": config,
"total_duration": round(sum(c["duration"] for c in clips), 1),
"confidence": round(random.uniform(0.75, 0.95), 2),
}
def _parse_recommend_response(
content: str,
asset_ids: list[str],
target_duration: float,
) -> Optional[dict[str, Any]]:
"""解析豆包返回的推荐方案.
期望返回结构:
{
"clips": [
{"clip_type": "intro/showcase/outro", "order": 0,
"text_content": "...", "duration": 3.0,
"transition_effect": "fade/cut", "asset_id": "...",
"start_time": 0.0, "config": {}}
],
"title": "视频标题",
"confidence": 0.85
}
"""
if not content:
return None
try:
cleaned = content.strip()
if cleaned.startswith("```"):
cleaned = cleaned.strip("`")
if cleaned.lower().startswith("json"):
cleaned = cleaned[4:]
cleaned = cleaned.strip()
data = json.loads(cleaned)
if not isinstance(data, dict):
return None
clips_data = data.get("clips", [])
if not isinstance(clips_data, list) or len(clips_data) == 0:
return None
clips: list[dict[str, Any]] = []
for _, clip in enumerate(clips_data):
if not isinstance(clip, dict):
continue
asset_id = str(clip.get("asset_id", ""))
# 校验 asset_id 是否在输入列表中
if asset_id and asset_id not in asset_ids:
asset_id = ""
clips.append(
{
"clip_type": clip.get("clip_type", "showcase"),
"order": clip.get("order", len(clips)),
"text_content": str(clip.get("text_content", "")),
"duration": max(1.0, min(30.0, float(clip.get("duration", 3.0)))),
"transition_effect": clip.get("transition_effect", "cut"),
"asset_id": asset_id,
"start_time": max(0.0, float(clip.get("start_time", 0.0))),
"config": clip.get("config", {}) or {},
}
)
if not clips:
return None
# 按 order 排序
clips.sort(key=lambda c: c["order"])
# 重新编号 order 保证连续
for i, clip in enumerate(clips):
clip["order"] = i
config = copy.deepcopy(DEFAULT_EDIT_PLAN_CONFIG)
title = data.get("title", "")
if title:
config["title"]["text"] = str(title)
config["title"]["ai_auto"] = True
confidence = float(data.get("confidence", 0.7))
confidence = max(0.0, min(1.0, confidence))
total_duration = round(sum(c["duration"] for c in clips), 1)
return {
"clips": clips,
"config": config,
"total_duration": total_duration,
"confidence": round(confidence, 2),
}
except (json.JSONDecodeError, ValueError, TypeError, KeyError):
return None
def _call_ai_recommend_service(
plan_id: str,
template_id: str,
asset_ids: list[str],
editing_mode: str,
target_duration: float,
asset_analyses: Optional[dict[str, str]] = None,
) -> dict[str, Any]:
"""调用 AI 推荐服务生成片段编排方案.
优先使用豆包大模型生成,失败或未配置时降级为本地规则生成。
当提供 asset_analyses 时,会将每个素材的视频理解结果注入 prompt,
让 LLM 能基于视频实际内容做智能编排。
Args:
plan_id: 剪辑计划 ID
template_id: 模板 ID
asset_ids: 素材 ID 列表
editing_mode: 剪辑模式
target_duration: 目标时长(秒)
asset_analyses: 可选,{asset_id: 视频理解文本} 映射
"""
client = get_doubao_client()
if not client.is_available:
logger.info("豆包API未配置,使用本地降级生成AI推荐方案")
return _fallback_recommend_clips(plan_id, template_id, asset_ids, editing_mode, target_duration)
# 构建素材描述(含视频理解结果)
asset_analyses = asset_analyses or {}
asset_lines = []
for aid in asset_ids[:30]:
analysis = asset_analyses.get(aid, "")
if analysis:
# 截断过长的分析结果,避免 token 爆炸
analysis_truncated = analysis[:300] + ("..." if len(analysis) > 300 else "")
asset_lines.append(f" - 素材ID: {aid}\n 内容描述: {analysis_truncated}")
else:
asset_lines.append(f" - 素材ID: {aid}")
assets_desc = "\n".join(asset_lines)
has_analysis = any(aid in asset_analyses for aid in asset_ids[:30])
# 构建 prompt
system_prompt = "你是一个专业的视频剪辑导演助手。根据提供的素材列表和目标时长,设计一个完整的视频片段编排方案。\n"
if has_analysis:
system_prompt += (
"每个素材附带了 AI 视频理解的内容描述,请根据素材的实际内容来决策编排:\n"
"- 将内容相关的素材放在一起,保持叙事连贯\n"
"- 根据素材内容合理安排片段顺序(如开场用吸引人的画面、高潮部分紧凑切换等)\n"
"- 为每个片段选择最匹配的素材,并在 text_content 中体现素材主题\n"
)
system_prompt += (
"要求:\n"
"1. 片段类型分为三类:intro(开场)、showcase(展示)、outro(结尾)\n"
"2. 每个片段包含:clip_type、order、text_content(字幕/标题文字)、"
"duration(时长秒)、transition_effect(转场效果:fade/cut/dissolve)、"
"asset_id(使用的素材ID)、start_time(素材起始时间秒)\n"
"3. 总时长接近 target_duration,每个素材至少用一次\n"
"4. 转场效果合理分配,不要全用cut\n"
"5. 返回纯JSON,不要其他文字\n"
'返回格式:{"clips": [...], "title": "视频标题", "confidence": 0.85}'
)
user_prompt = (
f"剪辑计划ID: {plan_id}\n"
f"模板ID: {template_id}\n"
f"剪辑模式: {editing_mode}\n"
f"目标时长: {target_duration}秒\n"
f"素材列表(共{len(asset_ids)}个):\n{assets_desc}\n\n"
f"请设计完整的片段编排方案:"
)
messages = [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt},
]
result = client.chat_completion(
messages=messages,
temperature=0.7,
max_tokens=2048,
)
if result:
parsed = _parse_recommend_response(result, asset_ids, target_duration)
if parsed and len(parsed["clips"]) >= 2:
logger.info(
"豆包AI推荐生成成功: plan_id=%s clips=%d duration=%.1f confidence=%.2f has_analysis=%s",
plan_id,
len(parsed["clips"]),
parsed["total_duration"],
parsed["confidence"],
has_analysis,
)
return parsed
logger.warning("豆包AI推荐返回解析失败,降级到本地方案: %s", result[:100])
# 降级
return _fallback_recommend_clips(plan_id, template_id, asset_ids, editing_mode, target_duration)
# ── AI 封面生成 ──────────────────────────────────────────────────────────────
def _transfer_cover_frame_to_storage(frame_url: str, plan_id: str) -> str:
"""下载 MediaKit 帧图并上传到 OSS,返回公开可访问的 URL.
Args:
frame_url: MediaKit 返回的帧图 URL(内部/临时 URL)
plan_id: 剪辑计划 ID(用于生成存储路径)
Returns:
公开可访问的 URL;如果下载/上传失败则返回原始 URL
"""
import tempfile
import uuid
from pathlib import Path
try:
import httpx
# 下载帧图
logger.info("下载 MediaKit 帧图: plan_id=%s url=%s", plan_id, frame_url[:80])
resp = httpx.get(frame_url, timeout=30, follow_redirects=True)
resp.raise_for_status()
if not resp.content:
logger.warning("MediaKit 帧图下载为空,返回原始 URL")
return frame_url
# 写入临时文件
with tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) as tmp:
tmp.write(resp.content)
tmp_path = tmp.name
# 上传到 OSS
from packages.shared.storage import get_shared_storage_service
storage = get_shared_storage_service()
cover_key = f"covers/{plan_id}/mediakit_frame_{uuid.uuid4().hex[:8]}.jpg"
storage.upload_file(
file_or_path=tmp_path,
storage_key=cover_key,
content_type="image/jpeg",
)
# 获取公开 URL
public_url = storage.get_url(cover_key)
logger.info("封面帧图已上传到 OSS: plan_id=%s key=%s url=%s", plan_id, cover_key, public_url[:80])
# 清理临时文件
Path(tmp_path).unlink(missing_ok=True)
return public_url
except Exception as e:
logger.warning("封面帧图转存失败,返回原始 URL: %s", str(e))
return frame_url
def _call_ai_cover_service(
plan_id: str,
asset_ids: list[str],
cover_type: str,
frame_time: float | None = None,
primary_video_url: str | None = None,
) -> dict[str, Any]:
"""调用 AI 封面生成服务.
统一封面管道下,封面已由渲染后视频抽帧生成并持久化到 GenerationTask.cover_url。
此函数仅处理 manual/upload 等需要前端交互的类型,
ai_frame/ai_regenerate 类型应由调用方直接从持久化的封面 URL 读取。
失败时抛出 RuntimeError。
Args:
plan_id: 剪辑计划 ID
asset_ids: 素材 ID 列表
cover_type: 封面类型
frame_time: 手动选帧时间点
primary_video_url: 主视频的可访问 URL
"""
if cover_type == "upload":
return {
"type": "upload",
"image_url": "",
"message": "请上传封面图片",
}
if cover_type == "manual" and frame_time is not None:
svg_placeholder = (
"data:image/svg+xml,"
"<svg xmlns='http://www.w3.org/2000/svg' width='1080' height='1920'>"
"<rect width='1080' height='1920' fill='#1a1a2e'/>"
"<text x='540' y='960' text-anchor='middle' fill='#e0e0e0' font-size='48' font-family='sans-serif'>手动选帧</text>"
"</svg>"
)
return {
"type": "manual",
"image_url": svg_placeholder,
"frame_time": frame_time,
}
# ai_frame / ai_regenerate: 封面应由渲染后视频抽帧管道生成
# 如果调用方传入了持久化的封面 URL,直接使用
logger.warning(
"封面生成回退: plan_id=%s cover_type=%s — 统一管道应已生成封面,请检查 GenerationTask.cover_url",
plan_id,
cover_type,
)
raise RuntimeError(f"封面数据不可用 (plan_id={plan_id})。请重新生成预览视频以触发封面自动提取。")
def run_ai_recommend(
plan_id: str,
template_id: str,
asset_ids: list[str],
editing_mode: str = "one_take",
target_duration: float = 30.0,
asset_analyses: Optional[dict[str, str]] = None,
) -> dict[str, Any]:
"""执行 AI 推荐片段方案
Args:
plan_id: 剪辑计划 ID
template_id: 模板 ID
asset_ids: 素材 ID 列表
editing_mode: 剪辑模式 (one_take / pip / voice_over / voice_pip)
target_duration: 目标时长(秒)
asset_analyses: 可选,{asset_id: 视频理解文本} 映射
Returns:
推荐方案 dict,包含 clips / config / total_duration / confidence
"""
logger.info(
"AI 推荐片段方案: plan_id=%s template_id=%s assets=%d mode=%s duration=%.1f has_analysis=%s",
plan_id,
template_id,
len(asset_ids),
editing_mode,
target_duration,
bool(asset_analyses),
)
result = _call_ai_recommend_service(
plan_id=plan_id,
template_id=template_id,
asset_ids=asset_ids,
editing_mode=editing_mode,
target_duration=target_duration,
asset_analyses=asset_analyses,
)
logger.info(
"AI 推荐完成: plan_id=%s clips=%d duration=%.1f confidence=%.2f",
plan_id,
len(result["clips"]),
result["total_duration"],
result["confidence"],
)
return result
def run_generate_cover(
plan_id: str,
asset_ids: list[str],
cover_type: str = "ai_frame",
frame_time: float | None = None,
primary_video_url: str | None = None,
) -> dict[str, Any]:
"""执行 AI 封面生成
Args:
plan_id: 剪辑计划 ID
asset_ids: 素材 ID 列表(用于确定视频来源)
cover_type: 封面类型 (ai_frame / manual / upload / ai_regenerate)
frame_time: 手动选帧时间点(仅 manual 模式使用)
primary_video_url: 主视频的可访问 URL(用于 MediaKit 抽帧)
Returns:
封面数据 dict,包含 type / image_url / frame_time
"""
logger.info(
"AI 封面生成: plan_id=%s type=%s assets=%d has_video_url=%s",
plan_id,
cover_type,
len(asset_ids),
bool(primary_video_url),
)
result = _call_ai_cover_service(
plan_id=plan_id,
asset_ids=asset_ids,
cover_type=cover_type,
frame_time=frame_time,
primary_video_url=primary_video_url,
)
logger.info(
"AI 封面生成完成: plan_id=%s type=%s url=%s",
plan_id,
result.get("type"),
result.get("image_url", "")[:60],
)
return result
# ── 通用 LLM / Vision 调用(#2039 ViralVideoOrchestrator 使用,复用现有豆包客户端)──
def call_llm(
prompt: str,
temperature: float = 0.7,
max_tokens: int = 2048,
model: str | None = None,
system_prompt: str | None = None,
) -> object:
"""调用豆包大模型(文本对话),返回解析后的 JSON(dict/list)或原文字符串;失败返回 None。
Args:
prompt: 用户侧提示。
temperature: 采样温度。
max_tokens: 输出上限(结构化任务默认 2048,长文案可按需加大)。
model: 覆盖默认模型(如 fast_model 提速用),None 走配置默认推理模型。
system_prompt: 覆盖默认 system prompt。
"""
client = get_doubao_client()
if not client.is_available:
return None
if system_prompt is None:
system_prompt = "你是专业的短视频内容策划助手。需要结构化输出时请严格使用 JSON。"
messages = [
{"role": "system", "content": system_prompt},
{"role": "user", "content": prompt},
]
raw = client.chat_completion(messages, temperature=temperature, max_tokens=max_tokens, model=model)
if raw is None:
return None
try:
return json.loads(raw)
except (json.JSONDecodeError, TypeError):
return raw
def call_vision(
image_url: str,
prompt: str,
*,
model: str | None = None,
max_tokens: int = 1024,
temperature: float = 0.2,
timeout: int = 45,
system_prompt: str | None = None,
) -> object:
"""调用豆包视觉大模型分析图片,返回解析后的 JSON 或原文字符串;失败返回 None。
Args:
image_url: 可公网访问的图片 URL(直接传给豆包视觉模型,无需本地下载)。
prompt: 用户侧文本提示。
model: 覆盖默认视觉模型(如 vision_lite_model 提速用),None 走配置默认。
max_tokens: 输出上限,商品识别用 800~1200 足够,避免长输出拖慢首 token。
temperature: 温度。
timeout: 单次请求超时(秒)。
system_prompt: 覆盖默认 system prompt(viral-video 商品分析会传专门的详细 prompt)。
"""
client = get_doubao_client()
if not client.is_available:
logger.warning("[call_vision] 豆包客户端未配置 (DOUBAO_API_KEY 缺失)")
return None
if not image_url:
logger.warning("[call_vision] 空 image_url,跳过视觉分析")
return None
if system_prompt is None:
system_prompt = (
"你是资深电商视觉分析师。请严格基于用户提供的图片观察回答,"
"图片里没有的信息不要凭空想象或编造;看不清或无法判断时明确说"
"「无法判断」,不要猜测。输出必须是严格 JSON,不要附加 Markdown 或解释文字。"
)
messages = [
{"role": "system", "content": system_prompt},
{"role": "user", "content": prompt},
]
used_model = model or getattr(client, "vision_model", "?")
logger.info(
"[call_vision] 调用豆包视觉模型 model=%s image_url=%s prompt_len=%d max_tokens=%d timeout=%d",
used_model,
image_url[:120],
len(prompt),
max_tokens,
timeout,
)
raw = client.vision_completion(
messages=messages,
images=[image_url],
temperature=temperature,
max_tokens=max_tokens,
timeout=timeout,
model=model,
)
if raw is None:
logger.warning("[call_vision] 视觉模型返回 None (image_url=%s)", image_url[:80])
return None
logger.info("[call_vision] 视觉模型原始返回 (前400字): %s", raw[:400])
# 剥离 ```json ... ``` 包裹
stripped = raw.strip()
if stripped.startswith("```"):
stripped = stripped.strip("`")
if stripped.startswith("json"):
stripped = stripped[4:].lstrip()
try:
return json.loads(stripped)
except (json.JSONDecodeError, TypeError) as e:
logger.warning("[call_vision] JSON 解析失败(%s),返回原始文本: %s", e, raw[:200])
return raw
def call_video_generation(
prompt: str,
*,
image_url: str | None = None,
duration: int = 15,
ratio: str | None = "9:16",
resolution: str = "720p",
output_dir: str | None = None,
model: str | None = None,
generate_audio: bool = True,
reference_images: list[str] | None = None,
reference_audios: list[str] | None = None,
reference_videos: list[str] | None = None,
) -> str | None:
"""调用 Seedance 2.5 生成视频(v1.6 单次出片版),返回本地 MP4 路径;失败返回 None。
v1.6:
- 默认 generate_audio=True,模型原生合成环境音效/BGM;
- reference_audios 传 TTS 音频 URL 数组做口型驱动;
- reference_images 传产品素材 URL 数组做视觉参考;
- 单次最长 30 秒,不分段不拼接;
- 图生视频(image_url 存在)也强制传 ratio,避免商品方图导致默认输出 1:1。
官方文档:图生视频时 ratio 由参数决定,模型会居中裁剪首帧到目标比例。
"""
client = get_doubao_client()
if not client.is_available:
logger.warning("[ai_service] 豆包客户端未配置,跳过视频生成")
return None
effective_ratio = ratio or "9:16"
try:
kwargs: dict = dict(
prompt=prompt,
image_url=image_url,
duration=int(duration),
resolution=resolution,
generate_audio=bool(generate_audio),
watermark=False,
output_dir=output_dir,
model=model,
reference_images=reference_images,
reference_audios=reference_audios,
reference_videos=reference_videos,
)
if effective_ratio:
kwargs["ratio"] = effective_ratio
return client.video_generation(**kwargs)
except Exception as e:
logger.error("[ai_service] call_video_generation 异常: %s", e, exc_info=True)
return None