Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3bb9c5dd4e | |||
| c87810a4a6 | |||
| 75ec9db439 | |||
| 0d70074182 | |||
| 071a3707f4 |
@@ -80,6 +80,8 @@ const AiAvatarPage: React.FC = () => {
|
||||
const [finalizeLoading, setFinalizeLoading] = useState(false)
|
||||
|
||||
/* ── 对口型轮询 ── */
|
||||
/** 对口型轮询总时长上限(10分钟):超过后停止轮询并提示去历史记录查看 */
|
||||
const LIPSYNC_POLL_MAX_MS = 10 * 60 * 1000
|
||||
const lipsyncTimerRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
/* ── 渲染进度轮询 ── */
|
||||
const renderTimerRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
@@ -270,7 +272,24 @@ const AiAvatarPage: React.FC = () => {
|
||||
// 如果是预合成模式,后端会同步把状态置为 submitted(甚至可能已返回 running),
|
||||
// 但仍需轮询等 completed
|
||||
if (lipsyncTimerRef.current) clearInterval(lipsyncTimerRef.current)
|
||||
// 轮询间隔 5 秒;单请求超时 5 分钟(见 api/aiAvatar.ts);总轮询上限 10 分钟
|
||||
// 单次请求失败/超时不中断轮询,继续下一轮;超过总上限后停止并提示用户去历史记录查看
|
||||
lipsyncTimerRef.current = setInterval(async () => {
|
||||
// 总时长保护:超过 10 分钟停止轮询
|
||||
if (Date.now() - lipsyncStartAtRef.current > LIPSYNC_POLL_MAX_MS) {
|
||||
if (lipsyncTimerRef.current) {
|
||||
clearInterval(lipsyncTimerRef.current)
|
||||
lipsyncTimerRef.current = null
|
||||
}
|
||||
if (lipsyncTickRef.current) {
|
||||
clearInterval(lipsyncTickRef.current)
|
||||
lipsyncTickRef.current = null
|
||||
}
|
||||
setLipsyncStatus("failed")
|
||||
setLipsyncErrorMessage("渲染时间较长,请稍后在历史记录中查看")
|
||||
message.warning("对口型渲染时间较长,已停止自动刷新,请稍后在历史记录中查看")
|
||||
return
|
||||
}
|
||||
try {
|
||||
const updated = await getLipsyncJob(job.id)
|
||||
state.setLipsyncJob(updated)
|
||||
@@ -296,9 +315,10 @@ const AiAvatarPage: React.FC = () => {
|
||||
setLipsyncErrorMessage(updated.error_message || "对口型生成失败")
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[对口型] 轮询错误:", err)
|
||||
// 单次轮询失败(含 timeout):不中断轮询,打印日志后等下一轮
|
||||
console.warn("[对口型] 轮询请求失败,将继续下一轮:", err)
|
||||
}
|
||||
}, 3000)
|
||||
}, 5000)
|
||||
} catch (err) {
|
||||
console.error("[对口型] 创建失败:", {
|
||||
status: (err as { response?: { status?: number } })?.response?.status,
|
||||
|
||||
@@ -72,7 +72,8 @@ export const previewTts = async (data: {
|
||||
}
|
||||
|
||||
export const getLipsyncJob = async (id: string): Promise<LipsyncJob> => {
|
||||
const response = await apiClient.get<LipsyncJob>(`/lipsync/jobs/${id}`, { timeout: 60000 })
|
||||
// MuseTalk 渲染 8s 视频约 54s + 排队时间,给足 5 分钟超时避免单次轮询 AxiosError 中断
|
||||
const response = await apiClient.get<LipsyncJob>(`/lipsync/jobs/${id}`, { timeout: 300_000 })
|
||||
return response.data
|
||||
}
|
||||
|
||||
@@ -91,7 +92,10 @@ export const submitRender = async (data: {
|
||||
}
|
||||
|
||||
export const getRenderJob = async (jobId: string): Promise<RenderJob> => {
|
||||
const response = await apiClient.get<RenderJob>(`/ai-avatar/render/${jobId}`, { timeout: 60000 })
|
||||
// 渲染链路(对口型+B-roll+标题+合成+上传)耗时较长,给足 5 分钟超时
|
||||
const response = await apiClient.get<RenderJob>(`/ai-avatar/render/${jobId}`, {
|
||||
timeout: 300_000,
|
||||
})
|
||||
return response.data
|
||||
}
|
||||
|
||||
|
||||
@@ -1162,6 +1162,7 @@ def _step_render(job: ViralVideoJob, copy_result: dict, tts_audio_url: str | Non
|
||||
)
|
||||
logger.info("[爆款视频] Seedance prompt (前300字): %s", prompt[:300])
|
||||
|
||||
# 第一次调用:带参考图/首帧/音频/参考视频
|
||||
result = call_video_generation(
|
||||
prompt=prompt,
|
||||
image_url=first_image,
|
||||
@@ -1175,8 +1176,58 @@ def _step_render(job: ViralVideoJob, copy_result: dict, tts_audio_url: str | Non
|
||||
reference_audios=ref_audios,
|
||||
reference_videos=ref_videos,
|
||||
)
|
||||
|
||||
# 肖像/内容审核拦截降级重试:去掉参考图/参考视频走纯文字 t2v 再试一次
|
||||
# (不影响音频——去掉首帧和多参考图通常就能绕过真人照片审核,纯文字描述真人是允许的)
|
||||
def _check_and_reraise(result):
|
||||
if result and isinstance(result, dict):
|
||||
return result
|
||||
from packages.shared.ai_service import get_last_video_error
|
||||
|
||||
err = get_last_video_error() or {}
|
||||
user_msg = err.get("user_message") or ""
|
||||
detail = err.get("detail") or ""
|
||||
err_code = err.get("error_code") or "unknown"
|
||||
status_code = err.get("status_code", 0)
|
||||
provider = _mcfg.get("provider", "doubao")
|
||||
err_msg = user_msg or f"视频生成失败({provider} status={status_code} code={err_code})"
|
||||
logger.error(
|
||||
"[爆款视频] 视频生成失败: provider=%s model=%s code=%s status=%s user_msg=%s detail=%s",
|
||||
provider,
|
||||
model or "default",
|
||||
err_code,
|
||||
status_code,
|
||||
user_msg,
|
||||
(detail or "")[:500],
|
||||
)
|
||||
raise RuntimeError(err_msg)
|
||||
|
||||
if not result or not isinstance(result, dict):
|
||||
raise RuntimeError("Seedance 视频生成失败:返回为空")
|
||||
from packages.shared.ai_service import get_last_video_error
|
||||
|
||||
err = get_last_video_error() or {}
|
||||
err_code = err.get("error_code") or "unknown"
|
||||
is_portrait = err_code == "portrait_intercept" or any(
|
||||
k in (err.get("detail", "") or "").lower()
|
||||
for k in ("real person", "privacy", "portrait", "sensitivecontent", "face", "人脸", "肖像", "真人")
|
||||
)
|
||||
if is_portrait and (first_image or rest_images or ref_videos):
|
||||
logger.warning("[爆款视频] 参考素材触发真人/肖像审核拦截(error_code=%s),降级为纯文字t2v重试一次", err_code)
|
||||
# 去掉所有参考图/参考视频,纯 t2v 泛化生成;音频仍保留(不影响肖像审核)
|
||||
result = call_video_generation(
|
||||
prompt=prompt,
|
||||
image_url=None,
|
||||
duration=dur,
|
||||
ratio=ratio,
|
||||
resolution=resolution,
|
||||
output_dir=str(tmpdir),
|
||||
model=model,
|
||||
generate_audio=gen_audio,
|
||||
reference_images=[],
|
||||
reference_audios=ref_audios,
|
||||
reference_videos=[],
|
||||
)
|
||||
_check_and_reraise(result)
|
||||
video_path = result.get("video_path") or ""
|
||||
usage = result.get("usage")
|
||||
if not video_path or not Path(video_path).exists() or Path(video_path).stat().st_size == 0:
|
||||
|
||||
+260
-23
@@ -22,6 +22,15 @@ import httpx
|
||||
|
||||
from packages.shared.config import get_shared_settings
|
||||
|
||||
# 网络/超时类异常父类集合:覆盖 Timeout/Connect/Network/ReadTimeout/WriteTimeout/PoolTimeout
|
||||
_HTTP_NETWORK_ERRORS = ()
|
||||
try:
|
||||
_HTTP_NETWORK_ERRORS = (httpx.TimeoutException, httpx.NetworkError)
|
||||
except Exception:
|
||||
_HTTP_NETWORK_ERRORS = (Exception,)
|
||||
|
||||
_HTTP_STATUS_ERROR = httpx.HTTPStatusError if hasattr(httpx, "HTTPStatusError") else Exception
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -67,6 +76,93 @@ def _resolve_video_model_id(model: str | None) -> str:
|
||||
return mid
|
||||
|
||||
|
||||
# ── 视频错误分类(给前端/用户展示友好提示)────────────────────────────
|
||||
|
||||
|
||||
def _classify_video_error(status_code: int, body: str, err: Exception | None) -> tuple[str, str]:
|
||||
"""根据 HTTP 状态码和响应 body 判断错误类型。
|
||||
|
||||
返回 (error_code, user_message):
|
||||
- error_code: 机器可读的错误码("portrait_intercept" / "quota_exceeded" / "model_not_found"
|
||||
/ "invalid_param" / "auth_error" / "rate_limit" / "network_error" / "task_failed" / "unknown")
|
||||
- user_message: 给用户看的中文提示
|
||||
"""
|
||||
body_lower = (body or "").lower()
|
||||
code_in_body = ""
|
||||
msg_in_body = ""
|
||||
try:
|
||||
import json as _json
|
||||
|
||||
parsed = _json.loads(body or "{}")
|
||||
if isinstance(parsed, dict):
|
||||
err_obj = parsed.get("error") or {}
|
||||
if isinstance(err_obj, dict):
|
||||
code_in_body = str(err_obj.get("code", "") or "")
|
||||
msg_in_body = str(err_obj.get("message", "") or err_obj.get("msg", "") or "")
|
||||
else:
|
||||
msg_in_body = str(parsed.get("message", "") or "")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 真人肖像/内容安全拦截
|
||||
if (
|
||||
status_code == 400
|
||||
and any(
|
||||
kw in body_lower
|
||||
for kw in ("portrait", "real_face", "human_face", "真人", "肖像", "人脸", "privacy", "real person", "face")
|
||||
)
|
||||
) or (
|
||||
"content" in body_lower
|
||||
and ("risk" in body_lower or "block" in body_lower or "reject" in body_lower)
|
||||
and status_code == 400
|
||||
):
|
||||
return (
|
||||
"portrait_intercept",
|
||||
"参考素材包含真人照片被安全策略拦截,AI视频模型暂不支持上传真人照片作为参考图,请移除真人图片后重试。",
|
||||
)
|
||||
|
||||
# 配额/计费问题
|
||||
if status_code in (402, 429) or any(
|
||||
kw in body_lower for kw in ("quota", "billing", "insufficient", "欠费", "余额", "限流", "rate limit")
|
||||
):
|
||||
if "rate" in body_lower or status_code == 429:
|
||||
return "rate_limit", "视频生成服务当前繁忙(限流),请稍等1-2分钟后重试。"
|
||||
return "quota_exceeded", "视频生成服务配额不足,请联系管理员充值或稍后重试。"
|
||||
|
||||
# 模型/Endpoint 不存在
|
||||
if status_code == 404 or any(
|
||||
kw in body_lower for kw in ("model not found", "endpoint not found", "不存在", "not found", "model_not_exist")
|
||||
):
|
||||
return "model_not_found", f"视频模型未开通或模型ID无效({code_in_body or ''}),请联系管理员。"
|
||||
|
||||
# 鉴权失败
|
||||
if status_code in (401, 403):
|
||||
return "auth_error", "视频生成服务鉴权失败(API Key无效或过期),请联系管理员。"
|
||||
|
||||
# 任务本身失败(轮询阶段拿到 status=failed)
|
||||
if err and "task failed" in str(err).lower():
|
||||
detail = msg_in_body or str(err)[:200]
|
||||
# 失败原因里再细分真人拦截
|
||||
if any(kw in detail.lower() for kw in ("portrait", "真人", "肖像", "人脸", "content_risk")):
|
||||
return (
|
||||
"portrait_intercept",
|
||||
"视频内容被安全策略拦截(疑似包含真人肖像),请更换参考图或调整文案后重试。",
|
||||
)
|
||||
return "task_failed", f"视频生成失败:{detail}"
|
||||
|
||||
# 参数错误
|
||||
if status_code == 400:
|
||||
return "invalid_param", f"视频生成参数错误:{msg_in_body or body[:200]}"
|
||||
|
||||
# 网络/连接问题
|
||||
if status_code == 0:
|
||||
return "network_error", "视频生成服务连接失败(网络超时),请稍后重试。"
|
||||
|
||||
# 默认
|
||||
detail = msg_in_body or (str(err) if err else "") or body[:200]
|
||||
return "unknown", f"视频生成失败(HTTP {status_code}):{detail}"
|
||||
|
||||
|
||||
class DoubaoClient:
|
||||
"""豆包大模型 API 客户端.
|
||||
|
||||
@@ -84,6 +180,8 @@ class DoubaoClient:
|
||||
self.vision_model: str = settings.doubao_vision_model
|
||||
self.vision_lite_model: str = settings.doubao_vision_lite_model
|
||||
self.fast_model: str = settings.doubao_fast_model
|
||||
# 最近一次视频生成的详细错误(error_code + user_message + raw detail),供上层读取后展示给用户
|
||||
self.last_video_error: dict = {}
|
||||
|
||||
def embed_text(self, text: str, timeout: int | None = None) -> list[float] | None:
|
||||
"""调用豆包文本 Embedding API,返回浮点向量;失败返回 None。"""
|
||||
@@ -307,7 +405,8 @@ class DoubaoClient:
|
||||
"""调用 Seedance 2.5 生视频(异步任务→轮询→下载)。
|
||||
|
||||
成功返回 {"video_path": str, "usage": dict | None},失败返回 None。
|
||||
usage 是 Seedance 返回的计费信息(含 completion_tokens)。
|
||||
失败时把详细错误信息(HTTP状态码、响应 body、分类后的用户提示)写入 self.last_video_error,
|
||||
上层可通过 get_last_video_error() 读取并展示给用户,不再笼统显示"返回为空"。
|
||||
|
||||
【v1.6.1 修复】严格按官方 content 数组协议构造请求:
|
||||
- 所有参考(图/音/视)必须放进 content 数组并带 role 字段,不能放顶层 reference_audios/reference_videos(非官方字段,会被忽略或导致异常)。
|
||||
@@ -315,9 +414,24 @@ class DoubaoClient:
|
||||
判定:传了参考音频/视频或 ≥1 张多参考图时,走 omni_reference(首张图 role=reference_image);纯首帧无参考时走 first_frame(ratio 强制 adaptive)。
|
||||
- 创建任务若因 ratio 报错(HTTP 400),自动回退到 ratio=adaptive 重试一次。
|
||||
"""
|
||||
# 每次调用前清空上次错误
|
||||
self.last_video_error = {}
|
||||
|
||||
if not self.is_available:
|
||||
self.last_video_error = {
|
||||
"error_code": "auth_error",
|
||||
"user_message": "视频生成服务未配置(API Key 缺失),请联系管理员。",
|
||||
"status_code": 0,
|
||||
"detail": "DoubaoClient not available (api_key empty)",
|
||||
}
|
||||
return None
|
||||
if not prompt or not prompt.strip():
|
||||
self.last_video_error = {
|
||||
"error_code": "invalid_param",
|
||||
"user_message": "视频生成提示词不能为空。",
|
||||
"status_code": 0,
|
||||
"detail": "empty prompt",
|
||||
}
|
||||
return None
|
||||
|
||||
settings = get_shared_settings()
|
||||
@@ -331,10 +445,20 @@ class DoubaoClient:
|
||||
|
||||
ds = get_dashscope_client()
|
||||
if ds is None:
|
||||
logger.error("DashScope client 不可用(未配置 DASHSCOPE_API_KEY),video_model=%s", model)
|
||||
err_msg = "DashScope client 不可用(未配置 DASHSCOPE_API_KEY)"
|
||||
logger.error("%s, video_model=%s", err_msg, model)
|
||||
self.last_video_error = {
|
||||
"error_code": "auth_error",
|
||||
"user_message": "Wan 3.0 视频模型未配置 API Key,请联系管理员。",
|
||||
"status_code": 0,
|
||||
"detail": err_msg,
|
||||
}
|
||||
return None
|
||||
try:
|
||||
return ds.video_generation(
|
||||
# DashScope 客户端也设置 last_video_error 语义(如果它支持)
|
||||
if hasattr(ds, "last_video_error"):
|
||||
ds.last_video_error = {}
|
||||
result = ds.video_generation(
|
||||
prompt=prompt,
|
||||
image_url=image_url,
|
||||
duration=duration,
|
||||
@@ -343,8 +467,17 @@ class DoubaoClient:
|
||||
output_dir=output_dir,
|
||||
model=video_model,
|
||||
)
|
||||
if not result and hasattr(ds, "last_video_error") and ds.last_video_error:
|
||||
self.last_video_error = dict(ds.last_video_error)
|
||||
return result
|
||||
except Exception as de:
|
||||
logger.error("DashScope video_generation 异常: %s", de, exc_info=True)
|
||||
self.last_video_error = {
|
||||
"error_code": "unknown",
|
||||
"user_message": f"Wan 3.0 视频生成异常:{de!s}"[:200],
|
||||
"status_code": 0,
|
||||
"detail": str(de),
|
||||
}
|
||||
return None
|
||||
|
||||
ref_audios = [u for u in (reference_audios or [])[:10] if u and isinstance(u, str)]
|
||||
@@ -370,7 +503,7 @@ class DoubaoClient:
|
||||
}
|
||||
)
|
||||
else:
|
||||
# 纯首帧:不带 role,服务端识别为 first_frame(或显式 role=first_frame)
|
||||
# 纯首帧:显式 role=first_frame
|
||||
content.append(
|
||||
{
|
||||
"type": "image_url",
|
||||
@@ -411,22 +544,38 @@ class DoubaoClient:
|
||||
len(ref_audios),
|
||||
len(ref_videos),
|
||||
)
|
||||
# 打印完整 payload 便于排查(截断 prompt)
|
||||
debug_payload = dict(create_payload)
|
||||
if "content" in debug_payload:
|
||||
dbg_content = []
|
||||
for item in debug_payload["content"]:
|
||||
item_copy = dict(item)
|
||||
if item_copy.get("type") == "text" and isinstance(item_copy.get("text"), str):
|
||||
item_copy["text"] = item_copy["text"][:200] + ("..." if len(item_copy["text"]) > 200 else "")
|
||||
dbg_content.append(item_copy)
|
||||
debug_payload["content"] = dbg_content
|
||||
logger.info("Seedance 创建任务 payload: %s", json_safe_dumps(debug_payload))
|
||||
|
||||
def _do_create(payload: dict) -> tuple[str | None, Exception | None, int, str]:
|
||||
"""返回 (task_id, last_err, status_code, body_text)。"""
|
||||
last_err: Exception | None = None
|
||||
last_sc = 0
|
||||
last_body = ""
|
||||
for attempt in range(self.max_retries + 1):
|
||||
try:
|
||||
resp = httpx.post(create_url, headers=headers, json=payload, timeout=self.timeout)
|
||||
sc = int(getattr(resp, "status_code", 0) or 0)
|
||||
body = (getattr(resp, "text", "") or "")[:1500]
|
||||
body = (getattr(resp, "text", "") or "")[:2000]
|
||||
last_sc = sc
|
||||
last_body = body
|
||||
if sc >= 400:
|
||||
logger.error("Seedance 创建任务 HTTP %d: body=%s", sc, body)
|
||||
try:
|
||||
resp.raise_for_status()
|
||||
except Exception as ee:
|
||||
last_err = ee
|
||||
if attempt < self.max_retries:
|
||||
if attempt < self.max_retries and sc >= 500:
|
||||
# 仅 5xx 重试,4xx 不重试(参数/鉴权/配额错误重试无意义)
|
||||
time.sleep(0.5 * (2**attempt))
|
||||
continue
|
||||
return None, last_err, sc, body
|
||||
@@ -435,9 +584,19 @@ class DoubaoClient:
|
||||
if tid:
|
||||
return tid, None, sc, body
|
||||
last_err = RuntimeError(f"create ok but no id: {str(data)[:300]}")
|
||||
except _HTTP_NETWORK_ERRORS as ne:
|
||||
last_err = ne
|
||||
last_sc = 0
|
||||
last_body = f"network error: {ne}"
|
||||
logger.warning(
|
||||
"Seedance 创建网络异常(%s),重试 %d/%d", type(ne).__name__, attempt + 1, self.max_retries + 1
|
||||
)
|
||||
if attempt < self.max_retries:
|
||||
time.sleep(0.5 * (2**attempt))
|
||||
continue
|
||||
except Exception as e:
|
||||
last_err = e
|
||||
if attempt < self.max_retries:
|
||||
if attempt < self.max_retries and not isinstance(e, _HTTP_STATUS_ERROR):
|
||||
wait = 0.5 * (2**attempt)
|
||||
logger.warning(
|
||||
"Seedance 创建任务失败,%.1fs 后重试 (%d/%d): %s",
|
||||
@@ -447,7 +606,7 @@ class DoubaoClient:
|
||||
e,
|
||||
)
|
||||
time.sleep(wait)
|
||||
return None, last_err, 0, ""
|
||||
return None, last_err, last_sc, last_body
|
||||
|
||||
# 第一次尝试
|
||||
task_id, last_err, sc, body = _do_create(create_payload)
|
||||
@@ -466,17 +625,30 @@ class DoubaoClient:
|
||||
logger.warning("Seedance 创建因 ratio 失败,回退 ratio=adaptive 重试")
|
||||
create_payload["ratio"] = "adaptive"
|
||||
task_id, last_err, sc2, body2 = _do_create(create_payload)
|
||||
if task_id:
|
||||
sc, body = sc2, body2
|
||||
else:
|
||||
# 保留第二次的错误信息
|
||||
sc, body = sc2, body2
|
||||
|
||||
if not task_id:
|
||||
err_code, user_msg = _classify_video_error(sc, body, last_err)
|
||||
self.last_video_error = {
|
||||
"error_code": err_code,
|
||||
"user_message": user_msg,
|
||||
"status_code": sc,
|
||||
"detail": (body or "")[:500] or (str(last_err) if last_err else ""),
|
||||
"model": video_model,
|
||||
"base_url": self.base_url,
|
||||
}
|
||||
logger.error(
|
||||
"Seedance 创建任务最终失败: model=%s base_url=%s err=%s body=%s 【排查】"
|
||||
"1) 方舟控制台已开通 %s;2) API Key 有该模型权限;"
|
||||
"3) DOUBAO_BASE_URL=https://ark.cn-beijing.volces.com/api/v3;4) 参考素材 URL 公网可访问。",
|
||||
"Seedance 创建任务最终失败: model=%s base_url=%s status=%d code=%s err=%s body=%s",
|
||||
video_model,
|
||||
self.base_url,
|
||||
sc,
|
||||
err_code,
|
||||
last_err,
|
||||
(body or "")[:500],
|
||||
video_model,
|
||||
)
|
||||
return None
|
||||
|
||||
@@ -489,15 +661,21 @@ class DoubaoClient:
|
||||
usage: dict | None = None
|
||||
last_status: str = "queued"
|
||||
poll_count = 0
|
||||
last_poll_body: str = ""
|
||||
last_poll_sc: int = 0
|
||||
while time.time() < deadline:
|
||||
poll_count += 1
|
||||
try:
|
||||
resp = httpx.get(poll_url, headers=headers, timeout=self.timeout)
|
||||
try:
|
||||
if int(getattr(resp, "status_code", 200)) >= 400:
|
||||
resp.raise_for_status()
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
last_poll_sc = int(getattr(resp, "status_code", 200) or 200)
|
||||
last_poll_body = (getattr(resp, "text", "") or "")[:1500]
|
||||
if last_poll_sc >= 400:
|
||||
logger.warning("Seedance 轮询 HTTP %d: %s", last_poll_sc, last_poll_body[:300])
|
||||
if poll_count < 3:
|
||||
time.sleep(poll_interval)
|
||||
continue
|
||||
last_err = RuntimeError(f"poll HTTP {last_poll_sc}: {last_poll_body[:200]}")
|
||||
break
|
||||
data = resp.json()
|
||||
status = data.get("status", "")
|
||||
last_status = status
|
||||
@@ -508,13 +686,22 @@ class DoubaoClient:
|
||||
if video_url:
|
||||
logger.info("Seedance 任务成功: task_id=%s polls=%d usage=%s", task_id, poll_count, usage)
|
||||
break
|
||||
last_err = RuntimeError(f"task succeeded but no video_url: {str(data)[:300]}")
|
||||
logger.error("Seedance succeeded 但无 video_url: %s", last_err)
|
||||
# 成功但没 video_url:记录完整响应便于排查
|
||||
logger.error(
|
||||
"Seedance succeeded 但无 video_url: task_id=%s full_response=%s",
|
||||
task_id,
|
||||
str(data)[:1000],
|
||||
)
|
||||
last_err = RuntimeError("task succeeded but no video_url in response")
|
||||
last_poll_body = str(data)[:1000]
|
||||
break
|
||||
if status == "failed":
|
||||
err = data.get("error") or {}
|
||||
last_err = RuntimeError(f"task failed: code={err.get('code','')} msg={err.get('message','')}")
|
||||
logger.error("Seedance 任务失败 task_id=%s: %s", task_id, last_err)
|
||||
err_code = str(err.get("code", "") or "")
|
||||
err_msg = str(err.get("message", "") or err.get("msg", "") or "")
|
||||
last_err = RuntimeError(f"task failed: code={err_code} msg={err_msg}")
|
||||
logger.error("Seedance 任务失败 task_id=%s code=%s msg=%s", task_id, err_code, err_msg)
|
||||
last_poll_body = str(data)[:1000]
|
||||
break
|
||||
if status in ("expired", "cancelled"):
|
||||
last_err = RuntimeError(f"task {status}")
|
||||
@@ -525,18 +712,39 @@ class DoubaoClient:
|
||||
logger.info("Seedance 轮询中: task_id=%s status=%s polls=%d", task_id, status, poll_count)
|
||||
except httpx.HTTPStatusError as e:
|
||||
last_err = e
|
||||
logger.warning("Seedance 轮询 HTTP %d: %s", e.response.status_code, (e.response.text or "")[:300])
|
||||
last_poll_sc = e.response.status_code
|
||||
last_poll_body = (e.response.text or "")[:500]
|
||||
logger.warning("Seedance 轮询 HTTP %d: %s", e.response.status_code, last_poll_body[:300])
|
||||
except Exception as e:
|
||||
last_err = e
|
||||
logger.debug("Seedance 轮询异常: %s", e)
|
||||
time.sleep(poll_interval)
|
||||
|
||||
if not video_url:
|
||||
# 区分轮询超时 vs 任务失败
|
||||
if last_status in ("queued", "running", "pending") and poll_count > 0 and time.time() >= deadline:
|
||||
err_code, user_msg = (
|
||||
"network_error",
|
||||
f"视频生成超时(>{total_timeout}s),任务仍在排队,请稍后重试或联系管理员。",
|
||||
)
|
||||
detail = f"timeout after {total_timeout}s, polls={poll_count}, last_status={last_status}"
|
||||
else:
|
||||
err_code, user_msg = _classify_video_error(last_poll_sc, last_poll_body, last_err)
|
||||
detail = (last_poll_body or "")[:500] or (str(last_err) if last_err else f"last_status={last_status}")
|
||||
self.last_video_error = {
|
||||
"error_code": err_code,
|
||||
"user_message": user_msg,
|
||||
"status_code": last_poll_sc,
|
||||
"detail": detail,
|
||||
"task_id": task_id,
|
||||
"last_status": last_status,
|
||||
}
|
||||
logger.error(
|
||||
"Seedance 任务未成功: task_id=%s last_status=%s polls=%d err=%s (总等待 %.0fs)",
|
||||
"Seedance 任务未成功: task_id=%s last_status=%s polls=%d code=%s err=%s (总等待 %.0fs)",
|
||||
task_id,
|
||||
last_status,
|
||||
poll_count,
|
||||
err_code,
|
||||
last_err,
|
||||
total_timeout,
|
||||
)
|
||||
@@ -567,12 +775,41 @@ class DoubaoClient:
|
||||
os.remove(local_path)
|
||||
except Exception:
|
||||
pass
|
||||
self.last_video_error = {
|
||||
"error_code": "unknown",
|
||||
"user_message": "视频生成成功但下载文件为空,请稍后重试。",
|
||||
"status_code": 0,
|
||||
"detail": f"downloaded 0 bytes from {video_url[:120]}",
|
||||
}
|
||||
return None
|
||||
return {"video_path": local_path, "usage": usage}
|
||||
except Exception as e:
|
||||
logger.error("Seedance 视频下载失败: %s", e, exc_info=True)
|
||||
self.last_video_error = {
|
||||
"error_code": "network_error",
|
||||
"user_message": f"视频下载失败:{e!s}"[:200],
|
||||
"status_code": 0,
|
||||
"detail": str(e),
|
||||
}
|
||||
return None
|
||||
|
||||
def get_last_video_error(self) -> dict:
|
||||
"""返回最近一次 video_generation 失败的详细错误。空 dict 表示上次成功或未调用。"""
|
||||
return dict(self.last_video_error or {})
|
||||
|
||||
|
||||
def json_safe_dumps(obj: Any, max_len: int = 2000) -> str:
|
||||
"""安全 json 序列化,失败则 fallback 到 repr,超长截断。"""
|
||||
try:
|
||||
import json as _json
|
||||
|
||||
s = _json.dumps(obj, ensure_ascii=False, default=str)
|
||||
except Exception:
|
||||
s = repr(obj)
|
||||
if len(s) > max_len:
|
||||
s = s[:max_len] + f"...(truncated, total {len(s)})"
|
||||
return s
|
||||
|
||||
|
||||
# ── 单例 ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -618,20 +618,23 @@ def call_video_generation(
|
||||
reference_audios: list[str] | None = None,
|
||||
reference_videos: list[str] | None = None,
|
||||
) -> dict | None:
|
||||
"""调用 Seedance 2.5 生成视频(v1.6.1 单次出片版)。
|
||||
"""调用 Seedance / Wan 视频生成(v1.6.2 多模型版)。
|
||||
|
||||
成功返回 {"video_path": str, "usage": dict | None}(usage 含 completion_tokens),失败返回 None。
|
||||
|
||||
v1.6.1 关键约束(避免 20min 卡死):
|
||||
- 参考音频/视频/多图全部放进 content 数组并带 role=reference_audio/reference_video/reference_image;
|
||||
- 纯首帧无参考时(first_frame 模式),Seedance 2.5 强制 ratio=adaptive;
|
||||
传了参考音/视/多图时走 omni_reference 模式,ratio 可指定为 9:16(客户端内部自动判断)。
|
||||
- ratio 默认 9:16(竖屏),客户端会根据是否有参考自动在 first_frame/adaptive 与 omni/9:16 间切换;
|
||||
若创建任务因 ratio 报错(HTTP 400),客户端会自动回退到 adaptive 再试一次。
|
||||
失败时错误详情会写入 client.last_video_error,可通过 get_last_video_error() 读取:
|
||||
{"error_code": str, "user_message": str, "status_code": int, "detail": str, ...}
|
||||
"""
|
||||
client = get_doubao_client()
|
||||
if not client.is_available:
|
||||
logger.warning("[ai_service] 豆包客户端未配置,跳过视频生成")
|
||||
msg = "豆包客户端未配置(DOUBAO_API_KEY 缺失),跳过视频生成"
|
||||
logger.warning("[ai_service] %s", msg)
|
||||
# 写入 last_video_error 供上层读取
|
||||
client.last_video_error = {
|
||||
"error_code": "auth_error",
|
||||
"user_message": "视频生成服务未配置,请联系管理员。",
|
||||
"status_code": 0,
|
||||
"detail": msg,
|
||||
}
|
||||
return None
|
||||
effective_ratio = ratio or "9:16"
|
||||
try:
|
||||
@@ -653,4 +656,21 @@ def call_video_generation(
|
||||
return client.video_generation(**kwargs)
|
||||
except Exception as e:
|
||||
logger.error("[ai_service] call_video_generation 异常: %s", e, exc_info=True)
|
||||
client.last_video_error = {
|
||||
"error_code": "unknown",
|
||||
"user_message": f"视频生成异常:{e!s}"[:200],
|
||||
"status_code": 0,
|
||||
"detail": str(e),
|
||||
}
|
||||
return None
|
||||
|
||||
|
||||
def get_last_video_error() -> dict:
|
||||
"""读取最近一次视频生成失败的详细错误(含 error_code/user_message/status_code/detail)。
|
||||
成功或未调用过返回空 dict。
|
||||
"""
|
||||
try:
|
||||
client = get_doubao_client()
|
||||
return client.get_last_video_error() if hasattr(client, "get_last_video_error") else {}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
@@ -21,11 +21,59 @@ import httpx
|
||||
|
||||
from packages.shared.config import get_shared_settings
|
||||
|
||||
# 网络/超时类异常父类集合:覆盖 Timeout/Connect/Network/ReadTimeout/WriteTimeout/PoolTimeout
|
||||
_HTTP_NETWORK_ERRORS = ()
|
||||
try:
|
||||
_HTTP_NETWORK_ERRORS = (httpx.TimeoutException, httpx.NetworkError)
|
||||
except Exception:
|
||||
_HTTP_NETWORK_ERRORS = (Exception,)
|
||||
|
||||
_HTTP_STATUS_ERROR = httpx.HTTPStatusError if hasattr(httpx, "HTTPStatusError") else Exception
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_DASHSCOPE_CLIENT_SINGLETON: "DashScopeClient | None" = None
|
||||
|
||||
|
||||
def _classify_dashscope_error(status_code: int, body: str, task_msg: str = "") -> tuple[str, str]:
|
||||
"""DashScope 错误分类,返回 (error_code, user_message)。"""
|
||||
body_lower = (body or "").lower()
|
||||
msg_in_body = task_msg or ""
|
||||
try:
|
||||
import json as _json
|
||||
|
||||
parsed = _json.loads(body or "{}")
|
||||
if isinstance(parsed, dict):
|
||||
msg_in_body = msg_in_body or str(parsed.get("message", "") or "")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if status_code in (401, 403):
|
||||
return "auth_error", "Wan 3.0 服务鉴权失败(DASHSCOPE_API_KEY 无效或过期),请联系管理员。"
|
||||
if status_code == 429 or "rate" in body_lower or "throttl" in body_lower:
|
||||
return "rate_limit", "Wan 3.0 服务繁忙(限流),请稍等1-2分钟后重试。"
|
||||
if status_code == 400 and any(
|
||||
kw in body_lower for kw in ("portrait", "真人", "人脸", "肖像", "content_violation", "risk", "blocked")
|
||||
):
|
||||
return (
|
||||
"portrait_intercept",
|
||||
"参考素材包含真人照片或违规内容被安全策略拦截,请移除真人图片或调整文案后重试。",
|
||||
)
|
||||
if status_code == 404 or ("not found" in body_lower) or ("model" in body_lower and "not exist" in body_lower):
|
||||
return "model_not_found", "Wan 3.0 模型未开通或模型ID无效,请联系管理员。"
|
||||
if status_code in (402, 400) and ("quota" in body_lower or "billing" in body_lower or "insufficient" in body_lower):
|
||||
return "quota_exceeded", "Wan 3.0 服务配额不足,请联系管理员充值或稍后重试。"
|
||||
if status_code == 400:
|
||||
return "invalid_param", f"Wan 3.0 参数错误:{msg_in_body or body[:200]}"
|
||||
if status_code == 0:
|
||||
return "network_error", "Wan 3.0 服务连接失败(网络超时),请稍后重试。"
|
||||
# 任务内失败
|
||||
if task_msg and any(kw in task_msg.lower() for kw in ("portrait", "真人", "人脸", "violation", "blocked")):
|
||||
return "portrait_intercept", "Wan 3.0 视频内容被安全策略拦截,请调整文案或参考图后重试。"
|
||||
detail = msg_in_body or body[:200]
|
||||
return "unknown", f"Wan 3.0 视频生成失败(HTTP {status_code}):{detail}"
|
||||
|
||||
|
||||
class DashScopeClient:
|
||||
"""阿里云 DashScope 异步 API 客户端(Wan 3.0 等视频生成)。"""
|
||||
|
||||
@@ -38,11 +86,24 @@ class DashScopeClient:
|
||||
self.poll_interval: int = int(getattr(settings, "dashscope_video_poll_interval", 10) or 10)
|
||||
self.total_timeout: int = int(getattr(settings, "dashscope_video_timeout", 900) or 900)
|
||||
self.max_retries: int = 2
|
||||
self.last_video_error: dict = {}
|
||||
|
||||
@property
|
||||
def is_available(self) -> bool:
|
||||
return bool(self.api_key)
|
||||
|
||||
def get_last_video_error(self) -> dict:
|
||||
return dict(self.last_video_error or {})
|
||||
|
||||
def _set_error(self, error_code: str, user_message: str, status_code: int = 0, detail: str = "", **extra) -> None:
|
||||
self.last_video_error = {
|
||||
"error_code": error_code,
|
||||
"user_message": user_message,
|
||||
"status_code": status_code,
|
||||
"detail": detail[:500] if detail else "",
|
||||
**extra,
|
||||
}
|
||||
|
||||
def video_generation(
|
||||
self,
|
||||
prompt: str,
|
||||
@@ -57,12 +118,15 @@ class DashScopeClient:
|
||||
) -> dict | None:
|
||||
"""调用 DashScope 异步视频合成接口,轮询完成后下载到本地。
|
||||
|
||||
返回 {"video_path": str, "usage": dict | None};失败返回 None。
|
||||
返回 {"video_path": str, "usage": dict | None};失败返回 None,错误详情写入 self.last_video_error。
|
||||
"""
|
||||
self.last_video_error = {}
|
||||
if not self.is_available:
|
||||
self._set_error("auth_error", "Wan 3.0 API key 未配置,请联系管理员。", detail="dashscope api_key empty")
|
||||
logger.error("[dashscope] API key 未配置,无法调用视频生成")
|
||||
return None
|
||||
if not prompt or not prompt.strip():
|
||||
self._set_error("invalid_param", "视频生成提示词不能为空。", detail="empty prompt")
|
||||
return None
|
||||
|
||||
# DashScope 分辨率参数:720P / 1080P / 480P(大写 P)
|
||||
@@ -106,18 +170,27 @@ class DashScopeClient:
|
||||
ds_res,
|
||||
bool(image_url),
|
||||
)
|
||||
logger.info("[dashscope] 创建任务 payload: model=%s params=%s", model, params)
|
||||
|
||||
# 创建任务
|
||||
task_id: str | None = None
|
||||
last_err: Exception | None = None
|
||||
last_sc = 0
|
||||
last_body = ""
|
||||
for attempt in range(self.max_retries + 1):
|
||||
try:
|
||||
resp = httpx.post(create_url, headers=headers, json=payload, timeout=60)
|
||||
sc = int(getattr(resp, "status_code", 0) or 0)
|
||||
body_text = (getattr(resp, "text", "") or "")[:1500]
|
||||
body_text = (getattr(resp, "text", "") or "")[:2000]
|
||||
last_sc = sc
|
||||
last_body = body_text
|
||||
if sc >= 400:
|
||||
logger.error("[dashscope] 创建任务 HTTP %d: %s", sc, body_text)
|
||||
resp.raise_for_status()
|
||||
if sc >= 500 and attempt < self.max_retries:
|
||||
time.sleep(0.5 * (2**attempt))
|
||||
continue
|
||||
err_code, user_msg = _classify_dashscope_error(sc, body_text)
|
||||
self._set_error(err_code, user_msg, sc, body_text, model=model)
|
||||
return None
|
||||
data = resp.json()
|
||||
tid = (data.get("output") or {}).get("task_id")
|
||||
if tid:
|
||||
@@ -126,17 +199,34 @@ class DashScopeClient:
|
||||
# 部分情况下 code != 错误
|
||||
code = data.get("code")
|
||||
if code and code != "":
|
||||
last_err = RuntimeError(f"dashscope create failed: {body_text[:300]}")
|
||||
err_code, user_msg = _classify_dashscope_error(400, body_text, str(code))
|
||||
self._set_error(err_code, user_msg, sc, body_text, model=model)
|
||||
return None
|
||||
else:
|
||||
last_err = RuntimeError(f"create ok but no task_id: {str(data)[:300]}")
|
||||
except Exception as e:
|
||||
last_err = e
|
||||
self._set_error("unknown", "Wan 3.0 响应格式异常,未返回任务ID", sc, str(data)[:500], model=model)
|
||||
return None
|
||||
except _HTTP_NETWORK_ERRORS as ne:
|
||||
last_sc = 0
|
||||
last_body = f"network error: {ne}"
|
||||
logger.warning(
|
||||
"[dashscope] 网络异常 %s,重试 %d/%d", type(ne).__name__, attempt + 1, self.max_retries + 1
|
||||
)
|
||||
if attempt < self.max_retries:
|
||||
time.sleep(0.5 * (2**attempt))
|
||||
continue
|
||||
logger.error("[dashscope] 创建任务最终失败: %s", last_err)
|
||||
self._set_error("network_error", "Wan 3.0 服务连接失败(网络超时),请稍后重试。", 0, str(ne))
|
||||
return None
|
||||
except Exception as _e:
|
||||
if attempt < self.max_retries:
|
||||
time.sleep(0.5 * (2**attempt))
|
||||
continue
|
||||
logger.error("[dashscope] 创建任务最终失败: %s", _e)
|
||||
self._set_error("unknown", f"Wan 3.0 创建任务异常:{_e!s}"[:200], 0, str(_e))
|
||||
return None
|
||||
if not task_id:
|
||||
if not self.last_video_error:
|
||||
err_code, user_msg = _classify_dashscope_error(last_sc, last_body)
|
||||
self._set_error(err_code, user_msg, last_sc, last_body, model=model)
|
||||
return None
|
||||
|
||||
# 轮询任务
|
||||
@@ -144,16 +234,26 @@ class DashScopeClient:
|
||||
deadline = time.time() + self.total_timeout
|
||||
video_url: str | None = None
|
||||
usage: dict | None = None
|
||||
poll_count = 0
|
||||
last_status = ""
|
||||
while time.time() < deadline:
|
||||
poll_count += 1
|
||||
try:
|
||||
r = httpx.get(poll_url, headers=headers, timeout=30)
|
||||
if int(getattr(r, "status_code", 0) or 0) >= 400:
|
||||
logger.warning("[dashscope] 轮询 HTTP %d", r.status_code)
|
||||
time.sleep(self.poll_interval)
|
||||
continue
|
||||
psc = int(getattr(r, "status_code", 0) or 0)
|
||||
pbody = (getattr(r, "text", "") or "")[:1500]
|
||||
if psc >= 400:
|
||||
logger.warning("[dashscope] 轮询 HTTP %d: %s", psc, pbody[:300])
|
||||
if poll_count < 3:
|
||||
time.sleep(self.poll_interval)
|
||||
continue
|
||||
err_code, user_msg = _classify_dashscope_error(psc, pbody)
|
||||
self._set_error(err_code, user_msg, psc, pbody, task_id=task_id)
|
||||
return None
|
||||
d = r.json()
|
||||
out = d.get("output") or {}
|
||||
task_status = out.get("task_status") or d.get("task_status") or ""
|
||||
last_status = task_status
|
||||
if task_status == "SUCCEEDED":
|
||||
video_url = out.get("video_url") or ""
|
||||
usage = d.get("usage")
|
||||
@@ -165,22 +265,41 @@ class DashScopeClient:
|
||||
if video_url:
|
||||
logger.info("[dashscope] 任务 %s 完成: %s", task_id, video_url[:120])
|
||||
break
|
||||
logger.error("[dashscope] 任务 %s SUCCEEDED 但无 video_url", task_id)
|
||||
logger.error("[dashscope] 任务 %s SUCCEEDED 但无 video_url: %s", task_id, str(d)[:500])
|
||||
self._set_error(
|
||||
"unknown",
|
||||
"Wan 3.0 任务成功但未返回视频URL,请联系管理员。",
|
||||
200,
|
||||
str(d)[:500],
|
||||
task_id=task_id,
|
||||
)
|
||||
return None
|
||||
if task_status in ("FAILED", "FAILED_WITH_ERROR", "ERROR"):
|
||||
msg = out.get("message") or d.get("message") or "unknown error"
|
||||
msg = out.get("message") or d.get("message") or out.get("error_msg") or "unknown error"
|
||||
logger.error("[dashscope] 任务 %s 失败: %s", task_id, msg)
|
||||
err_code, user_msg = _classify_dashscope_error(200, "", msg)
|
||||
self._set_error(err_code, user_msg, 200, msg, task_id=task_id, last_status=task_status)
|
||||
return None
|
||||
if task_status in ("CANCELED", "CANCELLED"):
|
||||
logger.warning("[dashscope] 任务 %s 被取消", task_id)
|
||||
self._set_error("unknown", "Wan 3.0 任务被取消。", 200, "task cancelled", task_id=task_id)
|
||||
return None
|
||||
# PENDING / RUNNING / SUSPENDED → 继续轮询
|
||||
logger.debug("[dashscope] 任务 %s 状态 %s,继续轮询", task_id, task_status)
|
||||
if poll_count % 5 == 0:
|
||||
logger.info("[dashscope] 轮询中 task=%s status=%s polls=%d", task_id, task_status, poll_count)
|
||||
except Exception as e:
|
||||
logger.warning("[dashscope] 轮询异常: %s", e)
|
||||
time.sleep(self.poll_interval)
|
||||
if not video_url:
|
||||
logger.error("[dashscope] 任务 %s 轮询超时(%ds)", task_id, self.total_timeout)
|
||||
self._set_error(
|
||||
"network_error",
|
||||
f"Wan 3.0 视频生成超时(>{self.total_timeout}s),任务仍在排队,请稍后重试。",
|
||||
0,
|
||||
f"timeout after {self.total_timeout}s, polls={poll_count}, last_status={last_status}",
|
||||
task_id=task_id,
|
||||
last_status=last_status,
|
||||
)
|
||||
return None
|
||||
|
||||
# 下载视频
|
||||
@@ -193,19 +312,23 @@ class DashScopeClient:
|
||||
out_path = os.path.join(out_dir, f"wan_{safe_tid}{suffix}")
|
||||
try:
|
||||
with httpx.stream("GET", video_url, timeout=300, follow_redirects=True) as resp:
|
||||
if int(getattr(resp, "status_code", 0) or 0) >= 400:
|
||||
logger.error("[dashscope] 下载 HTTP %d", resp.status_code)
|
||||
dsc = int(getattr(resp, "status_code", 0) or 0)
|
||||
if dsc >= 400:
|
||||
logger.error("[dashscope] 下载 HTTP %d", dsc)
|
||||
self._set_error("network_error", "Wan 3.0 视频下载失败(HTTP错误),请稍后重试。", dsc)
|
||||
return None
|
||||
with open(out_path, "wb") as f:
|
||||
for chunk in resp.iter_bytes(chunk_size=1024 * 256):
|
||||
if chunk:
|
||||
f.write(chunk)
|
||||
except Exception as e:
|
||||
logger.error("[dashscope] 下载视频失败: %s", e)
|
||||
logger.error("[dashscope] 下载视频失败: %s", e, exc_info=True)
|
||||
self._set_error("network_error", f"Wan 3.0 视频下载失败:{e!s}"[:200], 0, str(e))
|
||||
return None
|
||||
size = os.path.getsize(out_path) if os.path.exists(out_path) else 0
|
||||
if size < 1024:
|
||||
logger.error("[dashscope] 下载文件过小: %d bytes", size)
|
||||
self._set_error("unknown", "Wan 3.0 视频下载文件过小,请稍后重试。", 0, f"downloaded only {size} bytes")
|
||||
return None
|
||||
logger.info("[dashscope] 视频已下载: %s (%d bytes)", out_path, size)
|
||||
return {"video_path": out_path, "usage": usage}
|
||||
|
||||
@@ -556,3 +556,103 @@ class TestResolveVideoModelId:
|
||||
# 未知 model key 会通过 get_viral_video_model_config 回落到 seedance-2.5
|
||||
with caplog.at_level(logging.WARNING, logger="shared.ai_client"):
|
||||
assert fn("some-random-model") == "doubao-seedance-2-5-260628"
|
||||
|
||||
|
||||
# ── #2165 详细错误信息和 last_video_error ─────────────────────────
|
||||
|
||||
|
||||
class TestVideoGenerationLastError:
|
||||
def test_create_400_portrait_returns_user_message(self, tmp_path):
|
||||
"""HTTP 400 + 真人拦截关键词 → portrait_intercept 错误码,用户提示友好。"""
|
||||
client = _make_client(max_retries=0)
|
||||
create_resp = MagicMock()
|
||||
create_resp.status_code = 400
|
||||
create_resp.text = '{"error":{"code":"ContentRisk","message":"Real person face detected in reference image, portrait blocked"}}'
|
||||
create_resp.json.return_value = {"error": {"code": "ContentRisk", "message": "..."}}
|
||||
create_resp.raise_for_status.side_effect = httpx.HTTPStatusError(
|
||||
"bad", request=MagicMock(), response=create_resp
|
||||
)
|
||||
with (
|
||||
patch("packages.shared.ai_client.httpx.post", return_value=create_resp),
|
||||
patch("packages.shared.ai_client.time.sleep", return_value=None),
|
||||
patch("packages.shared.ai_client.get_shared_settings") as mock_s,
|
||||
):
|
||||
mock_s.return_value = MagicMock(
|
||||
doubao_video_poll_interval=0, doubao_video_timeout=1, doubao_video_model="seedance"
|
||||
)
|
||||
result = client.video_generation("p", output_dir=str(tmp_path), image_url="https://img/x.jpg")
|
||||
assert result is None
|
||||
err = client.get_last_video_error()
|
||||
assert err["error_code"] == "portrait_intercept"
|
||||
assert "真人" in err["user_message"]
|
||||
assert err["status_code"] == 400
|
||||
|
||||
def test_create_401_returns_auth_error(self, tmp_path):
|
||||
client = _make_client(max_retries=0)
|
||||
create_resp = MagicMock()
|
||||
create_resp.status_code = 401
|
||||
create_resp.text = '{"error":{"message":"Unauthorized"}}'
|
||||
create_resp.json.return_value = {"error": {"message": "Unauthorized"}}
|
||||
create_resp.raise_for_status.side_effect = httpx.HTTPStatusError(
|
||||
"auth", request=MagicMock(), response=create_resp
|
||||
)
|
||||
with (
|
||||
patch("packages.shared.ai_client.httpx.post", return_value=create_resp),
|
||||
patch("packages.shared.ai_client.time.sleep", return_value=None),
|
||||
patch("packages.shared.ai_client.get_shared_settings") as mock_s,
|
||||
):
|
||||
mock_s.return_value = MagicMock(
|
||||
doubao_video_poll_interval=0, doubao_video_timeout=1, doubao_video_model="seedance"
|
||||
)
|
||||
result = client.video_generation("p", output_dir=str(tmp_path))
|
||||
assert result is None
|
||||
err = client.get_last_video_error()
|
||||
assert err["error_code"] == "auth_error"
|
||||
assert err["status_code"] == 401
|
||||
|
||||
def test_poll_failed_returns_task_failed_error(self, tmp_path):
|
||||
"""轮询 status=failed 时应记录 task_failed 错误并含 detail。"""
|
||||
client = _make_client(max_retries=0)
|
||||
create_resp = MagicMock()
|
||||
create_resp.status_code = 200
|
||||
create_resp.json.return_value = {"id": "t-fail"}
|
||||
create_resp.raise_for_status = MagicMock()
|
||||
poll_resp = MagicMock()
|
||||
poll_resp.status_code = 200
|
||||
poll_resp.json.return_value = {
|
||||
"status": "failed",
|
||||
"error": {"code": "InvalidParam", "message": "resolution invalid"},
|
||||
}
|
||||
poll_resp.raise_for_status = MagicMock()
|
||||
with (
|
||||
patch("packages.shared.ai_client.httpx.post", return_value=create_resp),
|
||||
patch("packages.shared.ai_client.httpx.get", return_value=poll_resp),
|
||||
patch("packages.shared.ai_client.time.sleep", return_value=None),
|
||||
patch("packages.shared.ai_client.time.time", side_effect=_fake_time_factory()),
|
||||
patch("packages.shared.ai_client.get_shared_settings") as mock_s,
|
||||
):
|
||||
mock_s.return_value = MagicMock(
|
||||
doubao_video_poll_interval=0, doubao_video_timeout=10, doubao_video_model="seedance"
|
||||
)
|
||||
result = client.video_generation("p", output_dir=str(tmp_path))
|
||||
assert result is None
|
||||
err = client.get_last_video_error()
|
||||
assert err["error_code"] == "task_failed"
|
||||
assert "InvalidParam" in err.get("detail", "") or err["status_code"] == 200
|
||||
|
||||
|
||||
class TestAiServiceLastVideoError:
|
||||
def test_call_video_generation_returns_none_sets_error(self):
|
||||
"""失败后 get_last_video_error 应返回结构化错误信息。"""
|
||||
from packages.shared import ai_service
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_client.is_available = True
|
||||
mock_client.last_video_error = {"error_code": "unknown", "user_message": "test"}
|
||||
mock_client.get_last_video_error.return_value = {"error_code": "unknown", "user_message": "test"}
|
||||
mock_client.video_generation.return_value = None
|
||||
with patch("packages.shared.ai_service.get_doubao_client", return_value=mock_client):
|
||||
assert ai_service.call_video_generation("p") is None
|
||||
err = ai_service.get_last_video_error()
|
||||
assert err["error_code"] == "unknown"
|
||||
assert "user_message" in err
|
||||
|
||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock, mock_open, patch
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
_SINGLETON = "_DASHSCOPE_CLIENT_SINGLETON"
|
||||
@@ -157,3 +158,21 @@ class TestDashScopeVideoGeneration:
|
||||
c.video_generation(prompt=" ", duration=5, ratio="9:16", resolution="720p", output_dir="/tmp/videos")
|
||||
is None
|
||||
)
|
||||
|
||||
def test_create_400_sets_last_video_error(self, tmp_path):
|
||||
"""创建任务 HTTP 400 时应写 last_video_error。"""
|
||||
from packages.shared import dashscope_client as dc
|
||||
|
||||
dc._DASHSCOPE_CLIENT_SINGLETON = None
|
||||
with patch.dict("os.environ", {"DASHSCOPE_API_KEY": "test-key"}):
|
||||
c = dc.DashScopeClient()
|
||||
r = MagicMock()
|
||||
r.status_code = 401
|
||||
r.text = '{"code":"InvalidApiKey","message":"bad key"}'
|
||||
r.raise_for_status.side_effect = httpx.HTTPStatusError("auth", request=MagicMock(), response=r)
|
||||
with patch.object(dc.httpx, "post", return_value=r), patch.object(dc, "time"):
|
||||
out = c.video_generation("p", output_dir=str(tmp_path))
|
||||
assert out is None
|
||||
err = c.get_last_video_error()
|
||||
assert err["error_code"] == "auth_error"
|
||||
assert c.last_video_error is not None
|
||||
|
||||
Reference in New Issue
Block a user