Skip to content

Commit b10105d

Browse files
committed
feat: add V2 (MiniMax-H3) support with API version detection and payload construction
1 parent 9737a8b commit b10105d

1 file changed

Lines changed: 137 additions & 23 deletions

File tree

  • apps/models_provider/impl/minimax_model_provider/model

apps/models_provider/impl/minimax_model_provider/model/ttv.py

Lines changed: 137 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
21
import time
32
from typing import Dict
43

@@ -17,14 +16,22 @@ class GenerationVideoModel(MaxKBBaseModel, BaseGenerationVideo):
1716
max_retries: int = 3
1817
retry_delay: int = 10 # seconds
1918

19+
# V2 (MiniMax-H3) 专用参数
20+
v2_extra_fields = ("resolution", "duration", "ratio", "callback_url")
21+
# V2 完成 / 失败状态
22+
v2_success_status = ("succeeded", "Success")
23+
v2_fail_status = ("failed", "Fail", "cancelled", "Cancel")
24+
2025
def __init__(self, **kwargs):
2126
super().__init__(**kwargs)
2227
self.api_key = kwargs.get('api_key')
2328
self.api_base = kwargs.get('api_base', 'https://api.minimaxi.com/v1')
2429
self.model_name = kwargs.get('model_name')
25-
self.params = kwargs.get('params', {})
30+
self.params = kwargs.get('params', {}) or {}
2631
self.max_retries = kwargs.get('max_retries', 3)
27-
self.retry_delay = 10
32+
self.retry_delay = kwargs.get('retry_delay', 10)
33+
# 显式参数可覆盖自动探测(params.api_version: 'v1' / 'v2')
34+
self.api_version = self.params.get('api_version', 'auto')
2835

2936
@staticmethod
3037
def is_cache_model():
@@ -37,7 +44,7 @@ def new_instance(model_type, model_name, model_credential: Dict[str, object], **
3744
if key not in ['model_id', 'use_local', 'streaming']:
3845
optional_params['params'][key] = value
3946

40-
api_base = model_credential.get('api_base','https://api.minimaxi.com/v1')
47+
api_base = model_credential.get('api_base', 'https://api.minimaxi.com/v1')
4148

4249
return GenerationVideoModel(
4350
model_name=model_name,
@@ -49,6 +56,31 @@ def new_instance(model_type, model_name, model_credential: Dict[str, object], **
4956
def check_auth(self):
5057
return True
5158

59+
# ---------- API 版本探测 / URL 构建 ----------
60+
61+
def _detect_api_version(self) -> str:
62+
"""探测当前使用 V1 还是 V2 (MiniMax-H3)。"""
63+
if self.api_version in ('v1', 'v2'):
64+
return self.api_version
65+
# 模型名包含 H3 -> V2
66+
if self.model_name and 'H3' in self.model_name.upper():
67+
return 'v2'
68+
# api_base 路径包含 /v2 -> V2
69+
base_path = self.api_base.split('://', 1)[-1] if '://' in self.api_base else self.api_base
70+
if '/v2' in base_path:
71+
return 'v2'
72+
return 'v1'
73+
74+
def _base_url(self) -> str:
75+
"""去掉结尾的 /v1 或 /v2,返回纯净 base,便于拼装两套路径。"""
76+
base = self.api_base.rstrip('/')
77+
if base.endswith('/v1') or base.endswith('/v2'):
78+
base = base[:-3]
79+
return base.rstrip('/')
80+
81+
def _v2(self) -> bool:
82+
return self._detect_api_version() == 'v2'
83+
5284
def _safe_call(self, method, url, **kwargs):
5385
"""带重试的请求封装"""
5486
headers = {"Authorization": f"Bearer {self.api_key}"}
@@ -85,7 +117,95 @@ def generate_video(self, prompt, negative_prompt=None, first_frame_url=None, las
85117
86118
返回: 视频下载 URL
87119
"""
88-
base_url = f"{self.api_base}/video_generation"
120+
# 自动兼容 V1 / V2 (MiniMax-H3) 两套参数逻辑
121+
if self._v2():
122+
return self._generate_video_v2(prompt, first_frame_url, last_frame_url, **kwargs)
123+
return self._generate_video_v1(prompt, first_frame_url, last_frame_url, **kwargs)
124+
125+
# ---------- V2 (MiniMax-H3) 流程 ----------
126+
127+
def _build_v2_payload(self, prompt, first_frame_url, last_frame_url):
128+
content = [{"type": "text", "text": prompt}]
129+
if first_frame_url:
130+
content.append({
131+
"type": "image_url",
132+
"image_url": {"url": first_frame_url},
133+
"role": "first_frame",
134+
})
135+
if last_frame_url:
136+
content.append({
137+
"type": "image_url",
138+
"image_url": {"url": last_frame_url},
139+
"role": "last_frame",
140+
})
141+
142+
payload = {
143+
"model": self.model_name,
144+
"content": content,
145+
}
146+
# V2 必需的 resolution / duration,以及可选的 ratio / callback_url 均来自 params
147+
for key in self.v2_extra_fields:
148+
if key in self.params:
149+
payload[key] = self.params[key]
150+
return payload
151+
152+
def _generate_video_v2(self, prompt, first_frame_url=None, last_frame_url=None, **kwargs):
153+
base_url = f"{self._base_url()}/v2/video_generation"
154+
payload = self._build_v2_payload(prompt, first_frame_url, last_frame_url)
155+
156+
maxkb_logger.info(f"提交视频生成任务(V2/H3),模型: {self.model_name}")
157+
response_data = self._safe_call('POST', base_url, json=payload)
158+
159+
task_id = response_data.get("task_id")
160+
if not task_id:
161+
raise RuntimeError(f"提交任务失败,未获取到 task_id: {response_data}")
162+
163+
maxkb_logger.info(f"任务已提交,task_id: {task_id}")
164+
return self._poll_task_status_v2(task_id)
165+
166+
def _poll_task_status_v2(self, task_id: str) -> str:
167+
"""轮询 V2 任务状态,成功时直接返回视频 URL。"""
168+
query_url = f"{self._base_url()}/v2/query/video_generation/{task_id}"
169+
max_attempts = 60 # 最多轮询 60 次(约 10 分钟)
170+
171+
for attempt in range(max_attempts):
172+
response_data = self._safe_call('GET', query_url)
173+
task = response_data.get("task") or response_data
174+
status = task.get("status")
175+
176+
maxkb_logger.info(f"当前任务状态 (尝试 {attempt + 1}/{max_attempts}): {status}")
177+
178+
if status in self.v2_success_status:
179+
content = task.get("content") or {}
180+
video_url = content.get("url")
181+
if not video_url:
182+
raise RuntimeError(f"任务成功但未获取到视频 URL: {response_data}")
183+
maxkb_logger.info(f"任务处理成功,视频 URL: {video_url}")
184+
return video_url
185+
elif status in self.v2_fail_status:
186+
error_msg = self._extract_error(task, response_data)
187+
raise RuntimeError(f"视频生成失败: {error_msg}")
188+
else:
189+
# queued / running 等状态,继续轮询
190+
time.sleep(self.retry_delay)
191+
192+
raise RuntimeError(f"任务超时:经过 {max_attempts} 次轮询后仍未完成")
193+
194+
@staticmethod
195+
def _extract_error(task: dict, response_data: dict) -> str:
196+
for container in (task, response_data):
197+
if not isinstance(container, dict):
198+
continue
199+
for key in ("error_message", "error", "detail", "message", "msg"):
200+
value = container.get(key)
201+
if value:
202+
return str(value)
203+
return "未知错误"
204+
205+
# ---------- V1 流程(兼容老接口) ----------
206+
207+
def _generate_video_v1(self, prompt, first_frame_url=None, last_frame_url=None, **kwargs):
208+
base_url = f"{self._base_url()}/v1/video_generation"
89209

90210
# 构建基础参数
91211
payload = {
@@ -95,20 +215,17 @@ def generate_video(self, prompt, negative_prompt=None, first_frame_url=None, las
95215

96216
# 根据提供的参数判断生成模式
97217
if first_frame_url and last_frame_url:
98-
# 模式三:首尾帧生成视频
99218
payload["first_frame_image"] = first_frame_url
100219
payload["last_frame_image"] = last_frame_url
101220
maxkb_logger.info("使用首尾帧模式生成视频")
102221
elif first_frame_url:
103-
# 模式二:图生视频
104222
payload["first_frame_image"] = first_frame_url
105223
maxkb_logger.info("使用图生视频模式")
106224
else:
107-
# 模式一:文生视频
108225
maxkb_logger.info("使用文生视频模式")
109226

110-
# 合并额外参数(duration, resolution 等)
111-
payload.update(self.params)
227+
# 合并额外参数(duration, resolution 等),跳过版本探测专用字段
228+
payload.update({k: v for k, v in self.params.items() if k != 'api_version'})
112229

113230
# --- 步骤 1: 提交任务 ---
114231
maxkb_logger.info(f"提交视频生成任务,模型: {self.model_name}")
@@ -121,17 +238,14 @@ def generate_video(self, prompt, negative_prompt=None, first_frame_url=None, las
121238
maxkb_logger.info(f"任务已提交,task_id: {task_id}")
122239

123240
# --- 步骤 2: 轮询查询任务状态 ---
124-
query_url = f"{self.api_base}/query/video_generation"
125-
file_id = self._poll_task_status(query_url, task_id)
241+
query_url = f"{self._base_url()}/v1/query/video_generation"
242+
file_id = self._poll_task_status_v1(query_url, task_id)
126243

127244
# --- 步骤 3: 获取视频下载链接 ---
128-
video_url = self._get_video_download_url(file_id)
129-
130-
maxkb_logger.info(f"视频生成完成!视频 URL: {video_url}")
131-
return video_url
245+
return self._get_video_download_url_v1(file_id)
132246

133-
def _poll_task_status(self, query_url: str, task_id: str) -> str:
134-
"""轮询任务状态,直至成功或失败"""
247+
def _poll_task_status_v1(self, query_url: str, task_id: str) -> str:
248+
"""轮询 V1 任务状态,直至成功或失败"""
135249
params = {"task_id": task_id}
136250
max_attempts = 60 # 最多轮询 60 次(约 10 分钟)
137251

@@ -141,13 +255,13 @@ def _poll_task_status(self, query_url: str, task_id: str) -> str:
141255

142256
maxkb_logger.info(f"当前任务状态 (尝试 {attempt + 1}/{max_attempts}): {status}")
143257

144-
if status == "Success":
258+
if status in self.v2_success_status:
145259
file_id = response_data.get("file_id")
146260
if not file_id:
147261
raise RuntimeError(f"任务成功但未获取到 file_id: {response_data}")
148262
maxkb_logger.info(f"任务处理成功,file_id: {file_id}")
149263
return file_id
150-
elif status == "Fail":
264+
elif status in self.v2_fail_status:
151265
error_msg = response_data.get("error_message", "未知错误")
152266
maxkb_logger.error(f"视频生成失败: {error_msg}")
153267
raise RuntimeError(f"视频生成失败: {error_msg}")
@@ -157,9 +271,9 @@ def _poll_task_status(self, query_url: str, task_id: str) -> str:
157271

158272
raise RuntimeError(f"任务超时:经过 {max_attempts} 次轮询后仍未完成")
159273

160-
def _get_video_download_url(self, file_id: str) -> str:
161-
"""根据 file_id 获取视频下载链接"""
162-
retrieve_url = f"{self.api_base}/files/retrieve"
274+
def _get_video_download_url_v1(self, file_id: str) -> str:
275+
"""根据 file_id 获取视频下载链接(V1)"""
276+
retrieve_url = f"{self._base_url()}/v1/files/retrieve"
163277
params = {"file_id": file_id}
164278

165279
response_data = self._safe_call('GET', retrieve_url, params=params)

0 commit comments

Comments
 (0)