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