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