1-
21import time
32from 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