返回 Pixelle-Video
video_kling.py
根目录 / pixelle_video / services / api_services / video_kling.py
1 """
2 可灵(Kling AI)视频生成客户端
3 基于可灵 API 的文生视频 (text2video) / 图生视频 (image2video) 功能
4 支持模型: kling-v3, kling-v2-6, kling-v2-5-turbo
5 """
6
7 import os
8 import io
9 import ssl
10 import time
11 import base64
12 import logging
13 from typing import Optional
14
15 import jwt
16 import requests
17 from requests.adapters import HTTPAdapter
18 from urllib3.util.retry import Retry
19 from PIL import Image
20
21 logger = logging.getLogger(__name__)
22
23 # 可灵 API 基础地址
24 KLING_BASE_URL = "https://api-beijing.klingai.com"
25
26
27 class _TLSAdapter(HTTPAdapter):
28 """强制 TLS 1.2 的 HTTPS 适配器,兼容老版本 LibreSSL"""
29
30 def init_poolmanager(self, *args, **kwargs):
31 ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
32 ctx.minimum_version = ssl.TLSVersion.TLSv1_2
33 ctx.maximum_version = ssl.TLSVersion.TLSv1_2
34 ctx.load_default_certs()
35 kwargs["ssl_context"] = ctx
36 return super().init_poolmanager(*args, **kwargs)
37
38
39 def _build_session(max_retries: int = 3) -> requests.Session:
40 """创建带 TLS 适配器和自动重试的 requests Session"""
41 session = requests.Session()
42 retry = Retry(
43 total=max_retries,
44 backoff_factor=1,
45 status_forcelist=[502, 503, 504],
46 allowed_methods=["GET", "POST"],
47 )
48 adapter = _TLSAdapter(max_retries=retry)
49 session.mount("https://", adapter)
50 return session
51
52
53 def _proxy_dict(local_proxy: Optional[str]) -> dict:
54 if not local_proxy:
55 return {}
56 return {"http": local_proxy, "https": local_proxy}
57
58
59 class KlingVideoClient:
60 """
61 可灵 AI 视频生成客户端
62 使用 JWT (HMAC-SHA256) 鉴权,调用 /v1/videos/text2video 或 /v1/videos/image2video 接口
63 """
64
65 def __init__(
66 self,
67 access_key: Optional[str] = None,
68 secret_key: Optional[str] = None,
69 base_url: Optional[str] = None,
70 local_proxy: Optional[str] = None,
71 token_ttl: int = 1800,
72 poll_interval: int = 5,
73 max_polls: int = 120,
74 ) -> None:
75 """
76 Args:
77 access_key: 可灵 API Access Key
78 secret_key: 可灵 API Secret Key
79 base_url: 可灵 API 基础 URL (默认北京节点)
80 token_ttl: JWT 有效期(秒),默认 30 分钟
81 poll_interval: 轮询间隔(秒)
82 max_polls: 最大轮询次数
83 """
84 self.access_key = access_key or os.getenv("KLING_ACCESS_KEY", "")
85 self.secret_key = secret_key or os.getenv("KLING_SECRET_KEY", "")
86 self.base_url = (base_url or os.getenv("KLING_BASE_URL", "")).rstrip("/") or KLING_BASE_URL
87 self.local_proxy = local_proxy
88 self.token_ttl = token_ttl
89 self.poll_interval = poll_interval
90 self.max_polls = max_polls
91
92 if not self.access_key or not self.secret_key:
93 logger.warning(
94 "KlingVideoClient: KLING_ACCESS_KEY / KLING_SECRET_KEY 未设置,请检查配置"
95 )
96
97 # 使用强制 TLS 1.2 + 自动重试的 Session
98 self._session = _build_session()
99
100 # ─── JWT 鉴权 ───
101
102 def _generate_token(self) -> str:
103 """
104 使用 Access Key / Secret Key 生成 JWT Token
105 算法: HS256
106 Payload:
107 - iss: Access Key
108 - iat: 签发时间
109 - exp: 过期时间
110 - nbf: 生效时间
111 """
112 now = int(time.time())
113 payload = {
114 "iss": self.access_key,
115 "iat": now,
116 "exp": now + self.token_ttl,
117 "nbf": now - 5, # 允许 5 秒时钟偏差
118 }
119 token = jwt.encode(payload, self.secret_key, algorithm="HS256")
120 return token
121
122 def _auth_headers(self) -> dict:
123 """构建带 JWT 鉴权的请求头"""
124 token = self._generate_token()
125 return {
126 "Content-Type": "application/json",
127 "Authorization": f"Bearer {token}",
128 }
129
130 # ─── 图片处理 ───
131
132 @staticmethod
133 def _encode_image(image_path: str, quality: int = 85) -> str:
134 """
135 将本地图片编码为 Base64 字符串
136 可灵要求:不添加 data:image/xxx;base64, 前缀,直接传 Base64 字符串
137 图片大小 ≤ 10MB,宽高 ≥ 300px,宽高比 1:2.5 ~ 2.5:1
138 """
139 try:
140 with Image.open(image_path) as img:
141 if img.mode in ("RGBA", "P"):
142 img = img.convert("RGB")
143 buf = io.BytesIO()
144 img.save(buf, format="JPEG", quality=quality)
145 return base64.b64encode(buf.getvalue()).decode("utf-8")
146 except Exception as e:
147 logger.warning(f"图片压缩失败 ({image_path}),使用原始文件: {e}")
148 with open(image_path, "rb") as f:
149 return base64.b64encode(f.read()).decode("utf-8")
150
151 # ─── 创建任务 ───
152
153 def _submit_task(
154 self,
155 image_path: Optional[str],
156 prompt: str = "",
157 negative_prompt: str = "",
158 model_name: str = "kling-v3",
159 mode: str = "pro",
160 duration: str = "5",
161 cfg_scale: float = 0.5,
162 sound: str = "",
163 aspect_ratio: str = "16:9",
164 ) -> str:
165 """提交文生视频或图生视频任务。
166
167 Args:
168 image_path: 本地图片路径;为空时调用文生视频接口
169 prompt: 正向提示词(≤2500字符)
170 negative_prompt: 负向提示词(≤2500字符)
171 model_name: 可灵模型名 (kling-v3 / kling-v2-6 / kling-v2-5-turbo)
172 mode: 生成模式 std (标准) / pro (高品质)
173 duration: 视频时长,v3: "3"~"15", v2: "5"或"10"
174 cfg_scale: 自由度 [0,1],越大越贴合提示词
175 sound: 是否生成声音 "on"/"off"
176 aspect_ratio: 文生视频画幅比例
177
178 Returns:
179 task_id: 任务 ID
180 """
181 if image_path and not os.path.exists(image_path):
182 raise FileNotFoundError(f"输入图片不存在: {image_path}")
183
184 # 根据模型系列确定 duration 范围
185 model_lower = model_name.lower()
186 is_v3 = "v3" in model_lower or "video-o1" in model_lower
187 is_v26 = any(tag in model_lower for tag in ("v2-6", "v2.6"))
188
189 if is_v3:
190 # v3 系列支持 3~15s
191 clamped = str(min(max(int(duration), 3), 15))
192 else:
193 # v2 系列仅支持 5 或 10
194 clamped = "10" if int(duration) >= 8 else "5"
195
196 body = {
197 "model_name": model_name,
198 "mode": mode,
199 "duration": clamped,
200 }
201 endpoint = "image2video"
202 if image_path:
203 body["image"] = self._encode_image(image_path)
204 else:
205 endpoint = "text2video"
206 body["aspect_ratio"] = aspect_ratio
207
208 # sound 参数处理
209 # v3 / v2-6: 默认开启声音,除非显式 sound="off"
210 # v2-6 的 sound=on 必须搭配 pro 模式
211 # kling-v2-5-turbo 不支持 sound
212 if is_v3 or is_v26:
213 if sound == "off":
214 body["sound"] = "off"
215 else:
216 body["sound"] = "on"
217 # v2-6 的 sound=on 必须搭配 pro 模式; v3 无此限制
218 if is_v26 and mode != "pro":
219 mode = "pro"
220 body["mode"] = mode
221 logger.info("KlingVideoClient: v2-6 sound=on 需要 pro 模式,已自动切换")
222 elif sound == "on":
223 logger.warning(f"KlingVideoClient: 模型 {model_name} 不支持 sound 参数,已忽略")
224
225 if prompt:
226 body["prompt"] = prompt
227 if negative_prompt:
228 body["negative_prompt"] = negative_prompt
229
230 url = f"{self.base_url}/v1/videos/{endpoint}"
231 headers = self._auth_headers()
232
233 logger.info(
234 f"KlingVideoClient: 提交{endpoint}任务 model={model_name}, "
235 f"mode={mode}, duration={clamped}s, sound={body.get('sound', 'off')}"
236 )
237
238 resp = self._session.post(
239 url,
240 json=body,
241 headers=headers,
242 timeout=300,
243 proxies=_proxy_dict(self.local_proxy),
244 )
245 if not resp.ok:
246 try:
247 err_body = resp.json()
248 except Exception:
249 err_body = resp.text
250 logger.error(f"KlingVideoClient: HTTP {resp.status_code}, 响应: {err_body}")
251 resp.raise_for_status()
252 data = resp.json()
253
254 if data.get("code") != 0:
255 raise RuntimeError(
256 f"可灵 API 错误: code={data.get('code')}, message={data.get('message')}"
257 )
258
259 task_id = data["data"]["task_id"]
260 logger.info(f"KlingVideoClient: 任务已提交 task_id={task_id}")
261 return task_id
262
263 # ─── 查询任务 ───
264
265 def _query_task(self, task_id: str, endpoint: str = "image2video") -> dict:
266 """
267 查询单个任务状态
268
269 Returns:
270 API 响应中的 data 字段
271 """
272 url = f"{self.base_url}/v1/videos/{endpoint}/{task_id}"
273 headers = self._auth_headers()
274
275 resp = self._session.get(
276 url,
277 headers=headers,
278 timeout=30,
279 proxies=_proxy_dict(self.local_proxy),
280 )
281 resp.raise_for_status()
282 data = resp.json()
283
284 if data.get("code") != 0:
285 raise RuntimeError(
286 f"可灵查询 API 错误: code={data.get('code')}, message={data.get('message')}"
287 )
288
289 return data["data"]
290
291 # ─── 轮询等待 ───
292
293 def _poll_until_done(self, task_id: str, endpoint: str = "image2video") -> dict:
294 """
295 轮询任务直到完成或失败
296
297 Returns:
298 任务结果数据
299
300 Raises:
301 RuntimeError: 任务失败
302 TimeoutError: 超过最大轮询次数
303 """
304 for attempt in range(self.max_polls):
305 result = self._query_task(task_id, endpoint=endpoint)
306 status = result.get("task_status", "")
307
308 if status == "succeed":
309 logger.info(f"KlingVideoClient: 任务完成 task_id={task_id}")
310 return result
311 elif status == "failed":
312 msg = result.get("task_status_msg", "未知错误")
313 raise RuntimeError(f"可灵视频生成失败: {msg} (task_id={task_id})")
314 else:
315 # submitted / processing
316 logger.debug(
317 f"KlingVideoClient: 任务进行中 task_id={task_id}, "
318 f"status={status}, attempt={attempt + 1}/{self.max_polls}"
319 )
320 time.sleep(self.poll_interval)
321
322 raise TimeoutError(f"可灵视频生成超时 (task_id={task_id}, 已等待 {self.max_polls * self.poll_interval}s)")
323
324 # ─── 下载视频 ───
325
326 @staticmethod
327 def _download_video(video_url: str, save_path: str) -> None:
328 """从 URL 下载视频到本地"""
329 save_dir = os.path.dirname(save_path)
330 if save_dir:
331 os.makedirs(save_dir, exist_ok=True)
332 # 下载也用 TLS 安全 Session
333 dl_session = _build_session(max_retries=2)
334 resp = dl_session.get(video_url, stream=True, timeout=600)
335 resp.raise_for_status()
336 with open(save_path, "wb") as f:
337 for chunk in resp.iter_content(chunk_size=8192):
338 if chunk:
339 f.write(chunk)
340 logger.info(f"KlingVideoClient: 视频已保存: {save_path}")
341
342 # ─── 主入口 ───
343
344 def generate_video(
345 self,
346 prompt: str,
347 image_path: Optional[str],
348 save_path: str,
349 model: str = "kling-v3",
350 duration: int = 5,
351 mode: str = "pro",
352 cfg_scale: float = 0.5,
353 negative_prompt: str = "",
354 sound: str = "",
355 aspect_ratio: str = "16:9",
356 ) -> str:
357 """
358 文生/图生视频完整流程:提交任务 → 轮询等待 → 下载视频
359
360 Args:
361 prompt: 视频描述提示词
362 image_path: 输入图片本地路径;为空时调用文生视频
363 save_path: 输出视频保存路径
364 model: 可灵模型名 (kling-v3 / kling-v2-6 / kling-v2-5-turbo)
365 duration: 视频时长(秒),v3: 3~15, v2: 5或10
366 mode: 生成模式 "std" (标准) 或 "pro" (高品质)
367 cfg_scale: 自由度 [0,1]
368 negative_prompt: 负向提示词
369 sound: 是否生成声音 "on"/"off"
370 aspect_ratio: 文生视频画幅比例
371
372 Returns:
373 video_url: 远端视频 URL
374 """
375 # 1. 提交任务
376 endpoint = "image2video" if image_path else "text2video"
377 task_id = self._submit_task(
378 image_path=image_path,
379 prompt=prompt,
380 negative_prompt=negative_prompt,
381 model_name=model,
382 mode=mode,
383 duration=str(duration),
384 cfg_scale=cfg_scale,
385 sound=sound,
386 aspect_ratio=aspect_ratio,
387 )
388
389 # 2. 轮询等待
390 result = self._poll_until_done(task_id, endpoint=endpoint)
391
392 # 3. 提取视频 URL
393 videos = result.get("task_result", {}).get("videos", [])
394 if not videos:
395 raise RuntimeError(f"可灵任务成功但未返回视频数据 (task_id={task_id})")
396
397 video_url = videos[0].get("url", "")
398 if not video_url:
399 raise RuntimeError(f"可灵任务成功但视频 URL 为空 (task_id={task_id})")
400
401 # 4. 下载到本地
402 self._download_video(video_url, save_path)
403
404 return video_url
405
406
407 if __name__ == "__main__":
408 import sys
409 sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
410 from config import Config
411
412 logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
413
414 # ── 测试参数(按需修改) ──
415 IMAGE_PATH = "code/result/image/test_avail/test_input.png"
416 OUTPUT_PATH = "code/result/video/test_avail/kling_test_output.mp4"
417 PROMPT = ""
418 MODEL = "kling-v3" # kling-v3 / kling-v2-6 / kling-v2-5-turbo
419 DURATION = 5 # v3: 3~15, v2: 5 或 10
420 MODE = "pro" # std 或 pro
421 SOUND = "" # "" = 自动开启, "on", "off"
422
423 print("=== 可灵 (Kling) 图生视频测试 ===")
424 ak = Config.KLING_ACCESS_KEY
425 sk = Config.KLING_SECRET_KEY
426 base_url = Config.KLING_BASE_URL
427 if not ak or not sk:
428 print("✗ KLING_ACCESS_KEY / KLING_SECRET_KEY 未设置,请检查 .env 配置")
429 sys.exit(1)
430
431 if not os.path.exists(IMAGE_PATH):
432 print(f"✗ 输入图片不存在: {IMAGE_PATH}")
433 sys.exit(1)
434
435 print(f" Access Key : {ak[:6]}***{ak[-4:]}")
436 print(f" Base URL : {base_url}")
437 print(f" 输入图片 : {IMAGE_PATH}")
438 print(f" 输出路径 : {OUTPUT_PATH}")
439 print(f" 模型 : {MODEL}")
440 print(f" 时长 : {DURATION}s")
441 print(f" 模式 : {MODE}")
442 print(f" 声音 : {SOUND or '自动'}")
443 if PROMPT:
444 print(f" 提示词 : {PROMPT[:80]}")
445 print("-" * 40)
446
447 try:
448 client = KlingVideoClient(access_key=ak, secret_key=sk, base_url=base_url)
449 print("✓ 客户端初始化成功")
450
451 start = time.time()
452 video_url = client.generate_video(
453 prompt=PROMPT,
454 image_path=IMAGE_PATH,
455 save_path=OUTPUT_PATH,
456 model=MODEL,
457 duration=DURATION,
458 mode=MODE,
459 sound=SOUND,
460 )
461 elapsed = time.time() - start
462
463 print(f"✓ 视频生成完成!耗时 {elapsed:.1f}s")
464 print(f" 远端 URL : {video_url}")
465 print(f" 本地文件 : {os.path.abspath(OUTPUT_PATH)}")
466 print(f" 文件大小 : {os.path.getsize(OUTPUT_PATH) / 1024 / 1024:.2f} MB")
467 except Exception as e:
468 print(f"✗ 失败: {e}")
469 sys.exit(1)
470
470 lines PYTHON