| 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 |