""" KlingAI (可灵 AI) API Provider API 域名: https://api-beijing.klingai.com 认证方式: JWT (AK + SK) """ import json import logging from typing import Any from pydantic import BaseModel, Field from app.core.token_manager import JWTTokenStrategy, TokenManager logger = logging.getLogger(__name__) class KlingAIConfig(BaseModel): """Kling AI Provider 配置""" access_key: str = Field(default="", description="Access Key") secret_key: str = Field(default="", description="Secret Key") base_url: str = Field(default="https://api-beijing.klingai.com", description="API Base URL") class KlingPromptBuilder: """Kling Prompt 构建器 将业务语义(scene, voiceover, human_id)转换为 Kling API 专用语法。 所有 Kling 语法(<<>>, <<>>)仅限此类内部使用。 """ @staticmethod def omni_segment(scene: str, voiceover: str, human_id: str | None = None) -> str: """构建 Omni-Video 分镜提示词""" return f'<<>>{scene},说:"{voiceover}"' @staticmethod def empty_shot(scene: str, voiceover: str) -> str: """构建空镜图生视频提示词""" if voiceover: return f'{scene}。<<>>画外音:"{voiceover}"' return scene class KlingAIProvider: """ KlingAI API Provider 官方音色ID: - 829824295735410756: 钓系女友 - 829826751244537879: 温柔女声 - 829826792415842333: 播报男声 - 829826834144964676: 盐系少年 - 829826884271091753: 撒娇女友 """ provider_id = "klingai" DEFAULT_BASE_URL = "https://api-beijing.klingai.com" # 官方预设音色 PRESET_VOICES = { "829824295735410756": "钓系女友", "829826751244537879": "温柔女声", "829826792415842333": "播报男声", "829826834144964676": "盐系少年", "829826884271091753": "撒娇女友", } def __init__(self, config: dict[str, Any] = None): self.config = config or {} self.access_key = self.config.get("access_key", "") self.secret_key = self.config.get("secret_key", "") self.base_url = self.config.get("base_url", self.DEFAULT_BASE_URL).rstrip("/") # 初始化 Token 策略 self._token_strategy: JWTTokenStrategy | None = None if self.access_key and self.secret_key: self._token_strategy = JWTTokenStrategy( access_key=self.access_key, secret_key=self.secret_key, expires_in=1800, # 30分钟 ) async def _get_headers(self) -> dict[str, str]: """获取请求头(使用 TokenManager 缓存)""" if not self._token_strategy: raise ValueError("KlingAI access_key and secret_key are required") token_info = await TokenManager.get_instance().get_token(self._token_strategy) return { "Authorization": f"Bearer {token_info.token}", "Content-Type": "application/json", } # ==================== 文生视频 ==================== # ==================== Omni 视频生成 ==================== async def generate_video_omni( self, prompt: str, model: str = "kling-v3-omni", mode: str = "pro", aspect_ratio: str = "9:16", duration: int | None = None, sound: str = "on", negative_prompt: str | None = None, multi_shot: bool = False, shot_type: str | None = None, multi_prompt: list[dict | None] = None, image_list: list[dict | None] = None, element_list: list[dict | None] = None, video_list: list[dict | None] = None, voice_list: list[dict | None] = None, callback_url: str | None = None, external_task_id: str | None = None, **kwargs, ) -> dict[str, Any]: """Omni-Video 视频生成(多模态视频生成) POST /v1/videos/omni-video 支持文本、图片、主体、视频多种输入方式组合生成视频。 是 kling-v3-omni 和 kling-video-o1 模型的专用接口。 Args: prompt: 正向提示词(≤2500字符),支持 <<>>/<<>>/<<>> 引用语法 model: 模型名称,kling-v3-omni 或 kling-video-o1 mode: 生成模式,pro=1080p(默认), std=720p aspect_ratio: 宽高比,可选 16:9/9:16/1:1 duration: 时长,3/5/10/15(秒),Omni 支持 3-15s sound: 声音控制,on=音画同出, off=无声 negative_prompt: 负向提示词 multi_shot: 是否多镜头模式 shot_type: 分镜方式,customize=自定义, intelligence=智能分镜 multi_prompt: 多镜头提示词列表,每个元素包含 index, prompt, duration image_list: 参考图片列表,最多 4 张 element_list: 主体参考列表,最多 7 个,格式:[{"element_id": 123}] video_list: 参考视频列表 callback_url: 回调地址 external_task_id: 自定义任务ID Returns: 包含 task_id 和任务状态的字典 """ import aiohttp url = f"{self.base_url}/v1/videos/omni-video" payload = { "model_name": model, "prompt": prompt, "mode": mode, "aspect_ratio": aspect_ratio, "sound": sound, } if duration is not None: payload["duration"] = str(duration) if negative_prompt: payload["negative_prompt"] = negative_prompt if multi_shot: payload["multi_shot"] = True if shot_type: payload["shot_type"] = shot_type if multi_prompt: payload["multi_prompt"] = multi_prompt if image_list: payload["image_list"] = image_list if element_list: payload["element_list"] = element_list if video_list: payload["video_list"] = video_list if voice_list: payload["voice_list"] = voice_list if callback_url: payload["callback_url"] = callback_url if external_task_id: payload["external_task_id"] = external_task_id # 记录请求参数(调试用) logger.info( f"[KlingAI] omni-video 请求: {json.dumps(payload, ensure_ascii=False, indent=2)}" ) async with ( aiohttp.ClientSession() as session, session.post(url, json=payload, headers=await self._get_headers()) as resp, ): data = await resp.json() logger.info(f"[KlingAI] omni-video 响应: {json.dumps(data, ensure_ascii=False)}") if data.get("code") != 0: raise Exception(f"KlingAI API error: {data.get('message')}") result_data = data.get("data", {}) if isinstance(result_data, list) and len(result_data) > 0: return result_data[0] if isinstance(result_data[0], dict) else {} return result_data if isinstance(result_data, dict) else {} async def get_omni_video_task(self, task_id: str, **kwargs) -> dict[str, Any]: """查询 Omni-Video 任务状态 GET /v1/videos/omni-video/{task_id} Args: task_id: 任务ID """ import aiohttp url = f"{self.base_url}/v1/videos/omni-video/{task_id}" async with ( aiohttp.ClientSession() as session, session.get(url, headers=await self._get_headers()) as resp, ): data = await resp.json() if data.get("code") != 0: raise Exception(f"KlingAI API error: {data.get('message')}") result_data = data.get("data", {}) if isinstance(result_data, list) and len(result_data) > 0: return result_data[0] if isinstance(result_data[0], dict) else {} return result_data if isinstance(result_data, dict) else {} async def list_omni_video_tasks( self, page: int = 1, page_size: int = 30, **kwargs ) -> dict[str, Any]: """查询 Omni-Video 任务列表 GET /v1/videos/omni-video?pageNum={page}&pageSize={page_size} Args: page: 页码 page_size: 每页数量 """ import aiohttp url = f"{self.base_url}/v1/videos/omni-video?pageNum={page}&pageSize={page_size}" async with ( aiohttp.ClientSession() as session, session.get(url, headers=await self._get_headers()) as resp, ): data = await resp.json() if data.get("code") != 0: raise Exception(f"KlingAI API error: {data.get('message')}") return data.get("data", {}) # ==================== 文生视频 ==================== async def generate_video_text2video( self, prompt: str, model: str = "kling-v3", mode: str = "pro", aspect_ratio: str = "9:16", duration: int | None = None, sound: str = "on", negative_prompt: str | None = None, multi_shot: bool = False, shot_type: str | None = None, multi_prompt: list[dict | None] = None, element_list: list[dict | None] = None, voice_list: list[dict | None] = None, callback_url: str | None = None, external_task_id: str | None = None, **kwargs, ) -> dict[str, Any]: """文生视频 POST /v1/videos/text2video Args: prompt: 正向提示词(≤2500字符),支持 <<>> 引用语法 model: 模型名称,可选 kling-v1/kling-v1-6/kling-v2-master/kling-v2-1-master/kling-v2-5-turbo/kling-v2-6/kling-v3 mode: 生成模式,pro=1080p(默认), std=720p aspect_ratio: 宽高比,可选 16:9/9:16/1:1/4:3/3:4 duration: 时长,可选 3/5/10(秒) sound: 声音控制,on/off(kling-v3支持) negative_prompt: 负向提示词 multi_shot: 是否多镜头 shot_type: 分镜方式,customize/intelligence multi_prompt: 多镜头提示词列表,每个元素包含 index, prompt, duration element_list: 主体参考列表,格式: [{"element_id": 123}], kling-v3支持 voice_list: 音色列表,格式: [{"voice_id": "xxx"}], 配合 prompt 中 <<>> 使用 callback_url: 回调地址 external_task_id: 自定义任务ID """ import aiohttp url = f"{self.base_url}/v1/videos/text2video" payload = { "model_name": model, "prompt": prompt, "mode": mode, "aspect_ratio": aspect_ratio, "sound": sound, } if duration is not None: payload["duration"] = str(duration) if negative_prompt: payload["negative_prompt"] = negative_prompt if multi_shot: payload["multi_shot"] = True if shot_type: payload["shot_type"] = shot_type if multi_prompt: payload["multi_prompt"] = multi_prompt if element_list: payload["element_list"] = element_list if voice_list: payload["voice_list"] = voice_list if callback_url: payload["callback_url"] = callback_url if external_task_id: payload["external_task_id"] = external_task_id async with ( aiohttp.ClientSession() as session, session.post(url, json=payload, headers=await self._get_headers()) as resp, ): data = await resp.json() logger.info(f"KlingAI text2video response: {data}") if data.get("code") != 0: raise Exception(f"KlingAI API error: {data.get('message')}") result_data = data.get("data", {}) if isinstance(result_data, list) and len(result_data) > 0: return result_data[0] if isinstance(result_data[0], dict) else {} return result_data if isinstance(result_data, dict) else {} # ==================== 图生视频 ==================== async def generate_video_image2video( self, prompt: str, image_url: str, model: str = "kling-v3", mode: str = "pro", duration: int | str | None = None, image_tail_url: str | None = None, camera_control: dict | None = None, static_mask: str | None = None, dynamic_masks: list[dict | None] = None, voice_list: list[dict | None] = None, sound: str = "on", callback_url: str | None = None, **kwargs, ) -> dict[str, Any]: """图生视频 Args: prompt: 视频运动描述 image_url: 首帧图像URL或Base64 model: 模型名称 mode: 生成模式,pro=1080p(默认), std=720p duration: 视频时长 image_tail_url: 尾帧图像URL或Base64 camera_control: 相机控制参数,仅kling-v1模型支持 static_mask: 静态笔刷涂抹区域 dynamic_masks: 动态笔刷配置列表,包含mask和trajectories voice_list: 音色列表,格式: [{"voice_id": "xxx"}], 配合 prompt 中 <<>> 使用 sound: 声音控制,on/off(kling-v2-6支持) callback_url: 回调地址 """ import aiohttp url = f"{self.base_url}/v1/videos/image2video" payload = { "model_name": model, "image": image_url, "prompt": prompt, "mode": mode, "sound": sound, } if duration is not None: payload["duration"] = str(duration) if image_tail_url: payload["image_tail"] = image_tail_url if camera_control: payload["camera_control"] = camera_control if static_mask: payload["static_mask"] = static_mask if dynamic_masks: payload["dynamic_masks"] = dynamic_masks if voice_list: payload["voice_list"] = voice_list if callback_url: payload["callback_url"] = callback_url # 记录请求参数(调试用) logger.info( f"[KlingAI] image2video 请求: {json.dumps(payload, ensure_ascii=False, indent=2)}" ) async with ( aiohttp.ClientSession() as session, session.post(url, json=payload, headers=await self._get_headers()) as resp, ): data = await resp.json() logger.info(f"[KlingAI] image2video 响应: {json.dumps(data, ensure_ascii=False)}") if data.get("code") != 0: raise Exception(f"KlingAI API error: {data.get('message')}") return data.get("data", {}) # ==================== 视频延长 ==================== async def extend_video( self, video_id: str, prompt: str, duration: int = 5, model: str = "kling-v1-6", callback_url: str | None = None, **kwargs, ) -> dict[str, Any]: """视频延长 Args: video_id: 要延长的视频ID prompt: 延长部分的提示词 duration: 延长时间(5或10秒) model: 模型名称 callback_url: 回调地址 """ import aiohttp url = f"{self.base_url}/v1/videos/extend" payload = { "model_name": model, "video_id": video_id, "prompt": prompt, "duration": duration, } if callback_url: payload["callback_url"] = callback_url async with ( aiohttp.ClientSession() as session, session.post(url, json=payload, headers=await self._get_headers()) as resp, ): data = await resp.json() if data.get("code") != 0: raise Exception(f"KlingAI API error: {data.get('message')}") return data.get("data", {}) # ==================== 人脸识别 ==================== async def identify_face( self, video_id: str | None = None, video_url: str | None = None, **kwargs ) -> dict[str, Any]: """人脸识别(对口型前置步骤) POST /v1/videos/identify-face Args: video_id: KlingAI生成的视频ID video_url: 上传的视频URL(二选一) Returns: 包含 session_id 和 face_data 的字典 """ import aiohttp url = f"{self.base_url}/v1/videos/identify-face" payload = {} if video_id: payload["video_id"] = video_id elif video_url: payload["video_url"] = video_url else: raise ValueError("必须提供 video_id 或 video_url") async with ( aiohttp.ClientSession() as session, session.post(url, json=payload, headers=await self._get_headers()) as resp, ): data = await resp.json() if data.get("code") != 0: raise Exception(f"KlingAI API error: {data.get('message')}") return data.get("data", {}) # ==================== 对口型(新版 advanced-lip-sync)==================== async def advanced_lip_sync( self, session_id: str, face_choose: list[dict[str, Any]], callback_url: str | None = None, **kwargs, ) -> dict[str, Any]: """新版对口型视频生成 POST /v1/videos/advanced-lip-sync Args: session_id: 人脸识别返回的会话ID face_choose: 人脸选择配置列表,每项包含: - face_id: 人脸ID(必填) - audio_id: 通过TTS试听接口生成的音频ID(与sound_file二选一) - sound_file: 音频文件URL或Base64(与audio_id二选一) - sound_start_time: 音频裁剪起点时间(ms,必填) - sound_end_time: 音频裁剪终点时间(ms,必填) - sound_insert_time: 裁剪后音频插入时间(ms,必填) callback_url: 回调地址 Returns: 包含 task_id 的任务信息 """ import aiohttp url = f"{self.base_url}/v1/videos/advanced-lip-sync" payload = { "session_id": session_id, "face_choose": face_choose, } if callback_url: payload["callback_url"] = callback_url async with ( aiohttp.ClientSession() as session, session.post(url, json=payload, headers=await self._get_headers()) as resp, ): data = await resp.json() if data.get("code") != 0: raise Exception(f"KlingAI API error: {data.get('message')}") result_data = data.get("data", {}) return result_data if isinstance(result_data, dict) else {} async def get_advanced_lip_sync_task(self, task_id: str, **kwargs) -> dict[str, Any]: """查询新版对口型任务状态 GET /v1/videos/advanced-lip-sync/{task_id} """ import aiohttp url = f"{self.base_url}/v1/videos/advanced-lip-sync/{task_id}" async with ( aiohttp.ClientSession() as session, session.get(url, headers=await self._get_headers()) as resp, ): data = await resp.json() if data.get("code") != 0: raise Exception(f"KlingAI API error: {data.get('message')}") return data.get("data", {}) # ==================== 自定义音色 ==================== async def create_custom_voice( self, voice_name: str, audio_url: str | None = None, video_url: str | None = None, video_id: str | None = None, callback_url: str | None = None, external_task_id: str | None = None, **kwargs, ) -> dict[str, Any]: """创建自定义音色 Args: voice_name: 音色名称(≤20字符) audio_url: 音频文件URL(5-30秒,mp3/wav格式) video_url: 视频文件URL(可选) video_id: 历史作品ID(可选) callback_url: 回调地址 external_task_id: 自定义任务ID 音频要求: 人声干净、无杂音、单一人声、5-30秒 """ import aiohttp url = f"{self.base_url}/v1/general/custom-voices" payload = { "voice_name": voice_name, } # 三者至少填一个 (KlingAI API 使用 voice_url 参数名) if audio_url: payload["voice_url"] = audio_url elif video_url: payload["voice_url"] = video_url # KlingAI 使用 voice_url 而不是 video_url elif video_id: payload["video_id"] = video_id else: raise ValueError("必须提供 audio_url、video_url 或 video_id 之一") if callback_url: payload["callback_url"] = callback_url if external_task_id: payload["external_task_id"] = external_task_id async with ( aiohttp.ClientSession() as session, session.post(url, json=payload, headers=await self._get_headers()) as resp, ): data = await resp.json() if data.get("code") != 0: raise Exception(f"KlingAI API error: {data.get('message')}") return data.get("data", {}) async def list_custom_voices(self, **kwargs) -> list[dict[str, Any]]: """查询自定义音色列表 Returns: 自定义音色列表,每个音色包含 voice_id, voice_name, status 等字段 """ import aiohttp url = f"{self.base_url}/v1/general/custom-voices" async with ( aiohttp.ClientSession() as session, session.get(url, headers=await self._get_headers()) as resp, ): data = await resp.json() logger.info(f"KlingAI list_custom_voices response: {data}") if data.get("code") != 0: raise Exception(f"KlingAI API error: {data.get('message')}") # KlingAI API 返回的是任务列表,每个任务包含 task_result.voices result_data = data.get("data", []) voices = [] if isinstance(result_data, list): for task in result_data: if isinstance(task, dict) and task.get("task_status") == "succeed": task_result = task.get("task_result", {}) if isinstance(task_result, dict): voice_list = task_result.get("voices", []) if isinstance(voice_list, list): voices.extend(voice_list) return voices async def list_preset_voices(self, **kwargs) -> list[dict[str, Any]]: """查询官方音色列表 KlingAI API 返回的是任务列表格式,每个任务包含音色信息: { 'code': 0, 'data': [ { 'task_id': '...', 'task_status': 'succeed', 'task_result': {'voices': [{'voice_id': '...', 'voice_name': '...'}]} } ] } """ import aiohttp url = f"{self.base_url}/v1/general/presets-voices" async with ( aiohttp.ClientSession() as session, session.get(url, headers=await self._get_headers()) as resp, ): data = await resp.json() if data.get("code") != 0: raise Exception(f"KlingAI API error: {data.get('message')}") # 解析任务列表格式的响应 voices = [] task_list = data.get("data", []) for task in task_list: if task.get("task_status") == "succeed": task_result = task.get("task_result", {}) voice_list = task_result.get("voices", []) voices.extend(voice_list) return voices async def delete_custom_voice(self, voice_id: str, **kwargs) -> dict[str, Any]: """删除自定义音色 Args: voice_id: 音色ID """ import aiohttp url = f"{self.base_url}/v1/general/delete-voices" payload = {"voice_id": voice_id} async with ( aiohttp.ClientSession() as session, session.post(url, json=payload, headers=await self._get_headers()) as resp, ): data = await resp.json() if data.get("code") != 0: raise Exception(f"KlingAI API error: {data.get('message')}") return data.get("data", {}) # ==================== 任务查询 ==================== async def get_video_task( self, task_id: str, task_type: str = "text2video", **kwargs ) -> dict[str, Any]: """查询视频任务状态 Args: task_id: 任务ID task_type: 任务类型,可选 text2video/image2video/lip-sync Returns: 任务详情,包含 task_status 字段: - submitted: 已提交 - processing: 处理中 - succeed: 成功 - failed: 失败 """ import aiohttp endpoint_map = { "text2video": "videos/text2video", "image2video": "videos/image2video", "lip-sync": "videos/lip-sync", "extend": "videos/extend", } endpoint = endpoint_map.get(task_type, "videos/text2video") url = f"{self.base_url}/v1/{endpoint}/{task_id}" async with ( aiohttp.ClientSession() as session, session.get(url, headers=await self._get_headers()) as resp, ): data = await resp.json() if data.get("code") != 0: raise Exception(f"KlingAI API error: {data.get('message')}") return data.get("data", {}) async def list_video_tasks( self, task_type: str = "text2video", page: int = 1, page_size: int = 10, **kwargs, ) -> dict[str, Any]: """查询视频任务列表 Args: task_type: 任务类型 page: 页码 page_size: 每页数量 """ import aiohttp endpoint_map = { "text2video": "videos/text2video", "image2video": "videos/image2video", } endpoint = endpoint_map.get(task_type, "videos/text2video") url = f"{self.base_url}/v1/{endpoint}?page={page}&page_size={page_size}" async with ( aiohttp.ClientSession() as session, session.get(url, headers=await self._get_headers()) as resp, ): data = await resp.json() if data.get("code") != 0: raise Exception(f"KlingAI API error: {data.get('message')}") return data.get("data", {}) async def get_custom_voice_task(self, task_id: str, **kwargs) -> dict[str, Any]: """查询自定义音色任务状态""" import aiohttp url = f"{self.base_url}/v1/general/custom-voices/{task_id}" async with ( aiohttp.ClientSession() as session, session.get(url, headers=await self._get_headers()) as resp, ): data = await resp.json() if data.get("code") != 0: raise Exception(f"KlingAI API error: {data.get('message')}") return data.get("data", {}) # ==================== TTS 语音合成 ==================== async def generate_tts( self, text: str, voice_id: str, voice_language: str = "zh", voice_speed: float = 1.0, **kwargs, ) -> dict[str, Any]: """ 语音合成 (TTS) POST /v1/audio/tts Args: text: 要合成的文本 voice_id: 音色ID(官方预设或自定义音色) voice_language: 语言 (zh/en) voice_speed: 语速 (0.8-2.0) Returns: 包含音频URL和任务信息的字典 """ import aiohttp url = f"{self.base_url}/v1/audio/tts" payload = { "text": text, "voice_id": voice_id, "voice_language": voice_language, "voice_speed": str(voice_speed), } async with ( aiohttp.ClientSession() as session, session.post(url, json=payload, headers=await self._get_headers()) as resp, ): data = await resp.json() if data.get("code") != 0: raise Exception(f"KlingAI TTS API error: {data.get('message')}") return data.get("data", {}) async def get_tts_task(self, task_id: str, **kwargs) -> dict[str, Any]: """查询 TTS 任务状态""" import aiohttp url = f"{self.base_url}/v1/audio/tts/{task_id}" async with ( aiohttp.ClientSession() as session, session.get(url, headers=await self._get_headers()) as resp, ): data = await resp.json() if data.get("code") != 0: raise Exception(f"KlingAI API error: {data.get('message')}") return data.get("data", {}) async def list_tts_tasks(self, page: int = 1, page_size: int = 30, **kwargs) -> dict[str, Any]: """ 查询 TTS 任务列表 Args: page: 页码 page_size: 每页数量 Returns: 任务列表数据 """ import aiohttp url = f"{self.base_url}/v1/audio/tts?pageNum={page}&pageSize={page_size}" async with ( aiohttp.ClientSession() as session, session.get(url, headers=await self._get_headers()) as resp, ): data = await resp.json() if data.get("code") != 0: raise Exception(f"KlingAI API error: {data.get('message')}") return data.get("data", {}) # ==================== 主体管理 (Element) ==================== async def create_element( self, element_name: str, element_description: str, reference_type: str = "image_refer", element_image_list: dict[str, Any | None] = None, element_video_list: dict[str, Any | None] = None, element_voice_id: str | None = None, callback_url: str | None = None, **kwargs, ) -> dict[str, Any]: """创建主体(自定义元素) POST /v1/general/advanced-custom-elements Args: element_name: 主体名称(≤20字符) element_description: 主体描述(≤100字符) reference_type: 参考类型,image_refer(图片参考) / video_refer(视频参考) element_image_list: 图片参考对象,图片定制时必填 格式: { "frontal_image": "正面图URL", "refer_images": [{"image_url": "其他角度URL1"}, ...] } 要求:正面图+1-3张其他角度,jpg/jpeg/png,≤10MB element_video_list: 视频参考对象,视频定制时必填 格式: { "frontal_video": "正面视频URL", "refer_videos": [{"video_url": "其他角度URL"}] } 要求:3s~8s,MP4/MOV,高度720px~2160px,≤200MB 限制:仅支持写实风格、人形主体 element_voice_id: 音色ID,绑定音色到主体 callback_url: 回调地址 Returns: 包含 task_id 和任务状态的字典 """ import aiohttp url = f"{self.base_url}/v1/general/advanced-custom-elements" payload = { "element_name": element_name, "element_description": element_description, "reference_type": reference_type, } # 修正 element_image_list 内部格式:refer_images 数组元素使用 image_url 而非 url if element_image_list and isinstance(element_image_list, dict): formatted_image_list = {"frontal_image": element_image_list.get("frontal_image", "")} refer_images = element_image_list.get("refer_images", []) if refer_images: formatted_image_list["refer_images"] = [ ( {"image_url": img.get("url", img.get("image_url", ""))} if isinstance(img, dict) else {"image_url": img} ) for img in refer_images ] payload["element_image_list"] = formatted_image_list # 修正 element_video_list 内部格式 # element_video_list 是对象格式,不是数组 # 正确格式: {"refer_videos": [{"video_url": "..."}]} if element_video_list and isinstance(element_video_list, dict): formatted_video_list = {} # 处理 refer_videos 数组 refer_videos = element_video_list.get("refer_videos", []) if refer_videos: formatted_video_list["refer_videos"] = [ ( {"video_url": vid.get("url", vid.get("video_url", ""))} if isinstance(vid, dict) else {"video_url": vid} ) for vid in refer_videos ] # 处理 frontal_video(如果提供了)- 转为 refer_videos 格式 frontal_video = element_video_list.get("frontal_video", "") if frontal_video: if "refer_videos" not in formatted_video_list: formatted_video_list["refer_videos"] = [] formatted_video_list["refer_videos"].append({"video_url": frontal_video}) if formatted_video_list: # API 要求 element_video_list 是对象格式 payload["element_video_list"] = formatted_video_list if element_voice_id: payload["element_voice_id"] = element_voice_id if callback_url: payload["callback_url"] = callback_url async with ( aiohttp.ClientSession() as session, session.post(url, json=payload, headers=await self._get_headers()) as resp, ): data = await resp.json() logger.info(f"KlingAI create_element response: {data}") if data.get("code") != 0: raise Exception(f"KlingAI API error: {data.get('message')}") result_data = data.get("data", {}) if isinstance(result_data, list) and len(result_data) > 0: return result_data[0] if isinstance(result_data[0], dict) else {} return result_data if isinstance(result_data, dict) else {} async def list_elements(self, **kwargs) -> list[dict[str, Any]]: """查询主体列表 GET /v1/general/advanced-custom-elements KlingAI API 返回任务列表格式,每个任务包含 task_result.elements 数组。 需要从任务列表中提取所有主体信息。 Returns: 主体列表,每个主体包含 element_id, element_name 等字段 """ import aiohttp url = f"{self.base_url}/v1/general/advanced-custom-elements" async with ( aiohttp.ClientSession() as session, session.get(url, headers=await self._get_headers()) as resp, ): data = await resp.json() logger.info(f"KlingAI list_elements response: {data}") if data.get("code") != 0: raise Exception(f"KlingAI API error: {data.get('message')}") # KlingAI 返回任务列表,每个任务包含 task_result.elements task_list = data.get("data", []) elements = [] for task in task_list: if isinstance(task, dict) and task.get("task_status") == "succeed": task_result = task.get("task_result", {}) if isinstance(task_result, dict): element_list = task_result.get("elements", []) if isinstance(element_list, list): for element in element_list: # 添加任务信息到主体数据中 element["task_id"] = task.get("task_id") element["task_status"] = task.get("task_status") element["created_at"] = task.get("created_at") element["updated_at"] = task.get("updated_at") elements.append(element) return elements async def get_element_task(self, task_id: str, **kwargs) -> dict[str, Any]: """查询创建主体任务状态 GET /v1/general/advanced-custom-elements/{task_id} Args: task_id: 创建任务的ID """ import aiohttp url = f"{self.base_url}/v1/general/advanced-custom-elements/{task_id}" async with ( aiohttp.ClientSession() as session, session.get(url, headers=await self._get_headers()) as resp, ): data = await resp.json() logger.info(f"KlingAI get_element_task response: {data}") if data.get("code") != 0: raise Exception(f"KlingAI API error: {data.get('message')}") result_data = data.get("data", {}) return result_data if isinstance(result_data, dict) else {} async def get_element(self, element_id: str, **kwargs) -> dict[str, Any]: """查询单个主体详情 GET /v1/general/advanced-custom-elements/{element_id} Args: element_id: 主体ID """ import aiohttp url = f"{self.base_url}/v1/general/advanced-custom-elements/{element_id}" async with ( aiohttp.ClientSession() as session, session.get(url, headers=await self._get_headers()) as resp, ): data = await resp.json() logger.info(f"KlingAI get_element response: {data}") if data.get("code") != 0: raise Exception(f"KlingAI API error: {data.get('message')}") result_data = data.get("data", {}) return result_data if isinstance(result_data, dict) else {} async def delete_element(self, element_id: str, **kwargs) -> dict[str, Any]: """删除主体 POST /v1/general/delete-elements Args: element_id: 主体ID """ import aiohttp url = f"{self.base_url}/v1/general/delete-elements" payload = {"element_id": element_id} async with ( aiohttp.ClientSession() as session, session.post(url, json=payload, headers=await self._get_headers()) as resp, ): data = await resp.json() logger.info(f"KlingAI delete_element response: {data}") if data.get("code") != 0: raise Exception(f"KlingAI API error: {data.get('message')}") result_data = data.get("data", {}) return result_data if isinstance(result_data, dict) else {} # ==================== 智能补全主体图 ==================== async def ai_multi_shot( self, frontal_image: str, callback_url: str | None = None, **kwargs ) -> dict[str, Any]: """智能补全主体不同角度图片(AI Multi Shot) POST /v1/general/ai-multi-shot 通过主体正面图,自动推理出该主体其他角度图片。 每次可生成3组结果供选择,每次扣减0.5积分。 Args: frontal_image: 主体正面参考图 URL callback_url: 回调地址 Returns: 包含生成结果的任务信息 """ import aiohttp url = f"{self.base_url}/v1/general/ai-multi-shot" payload = { "element_frontal_image": frontal_image, } if callback_url: payload["callback_url"] = callback_url async with ( aiohttp.ClientSession() as session, session.post(url, json=payload, headers=await self._get_headers()) as resp, ): data = await resp.json() logger.info(f"KlingAI ai_multi_shot response: {data}") if data.get("code") != 0: raise Exception(f"KlingAI API error: {data.get('message')}") result_data = data.get("data", {}) return result_data if isinstance(result_data, dict) else {} async def get_ai_multi_shot_task(self, task_id: str, **kwargs) -> dict[str, Any]: """查询智能补全主体图任务状态 GET /v1/general/ai-multi-shot/{task_id} Args: task_id: 任务ID """ import aiohttp url = f"{self.base_url}/v1/general/ai-multi-shot/{task_id}" async with ( aiohttp.ClientSession() as session, session.get(url, headers=await self._get_headers()) as resp, ): data = await resp.json() if data.get("code") != 0: raise Exception(f"KlingAI API error: {data.get('message')}") return data.get("data", {}) # ==================== Omni-Image 图像生成 ==================== async def generate_omni_image( self, prompt: str, model: str = "kling-image-o1", aspect_ratio: str = "9:16", resolution: str = "1k", result_type: str = "single", n: int = 1, element_list: list[dict[str, Any]] | None = None, image_list: list[dict[str, Any]] | None = None, callback_url: str | None = None, **kwargs, ) -> dict[str, Any]: """Omni-Image 图像生成 POST /v1/images/omni-image Args: prompt: 正向提示词 model: 模型名称,kling-image-o1 或 kling-v3-omni aspect_ratio: 宽高比,可选 16:9/9:16/1:1/4:3/3:4 resolution: 清晰度,1k/2k/4k result_type: 结果类型,single/series n: 生成图片数量(1-9) element_list: 主体参考列表 image_list: 参考图列表 callback_url: 回调地址 """ import aiohttp url = f"{self.base_url}/v1/images/omni-image" payload: dict[str, Any] = { "model_name": model, "prompt": prompt, "aspect_ratio": aspect_ratio, "resolution": resolution, "result_type": result_type, "n": n, } if element_list: payload["element_list"] = element_list if image_list: payload["image_list"] = image_list if callback_url: payload["callback_url"] = callback_url logger.info( f"[KlingAI] omni-image 请求: {json.dumps(payload, ensure_ascii=False, indent=2)}" ) async with ( aiohttp.ClientSession() as session, session.post(url, json=payload, headers=await self._get_headers()) as resp, ): data = await resp.json() logger.info(f"[KlingAI] omni-image 响应: {json.dumps(data, ensure_ascii=False)}") if data.get("code") != 0: raise Exception(f"KlingAI API error: {data.get('message')}") result_data = data.get("data", {}) if isinstance(result_data, list) and len(result_data) > 0: return result_data[0] if isinstance(result_data[0], dict) else {} return result_data if isinstance(result_data, dict) else {} async def get_omni_image_task(self, task_id: str, **kwargs) -> dict[str, Any]: """查询 Omni-Image 任务状态 GET /v1/images/omni-image/{task_id} """ import aiohttp url = f"{self.base_url}/v1/images/omni-image/{task_id}" async with ( aiohttp.ClientSession() as session, session.get(url, headers=await self._get_headers()) as resp, ): data = await resp.json() if data.get("code") != 0: raise Exception(f"KlingAI API error: {data.get('message')}") return data.get("data", {}) # ==================== 文生图 ==================== async def generate_image( self, prompt: str, model: str = "kling-v3", aspect_ratio: str = "9:16", negative_prompt: str | None = None, callback_url: str | None = None, **kwargs, ) -> dict[str, Any]: """文生图 POST /v1/images/generations Args: prompt: 正向提示词 model: 模型名称,kling-v3 aspect_ratio: 宽高比,可选 16:9/9:16/1:1/4:3/3:4 negative_prompt: 负向提示词 callback_url: 回调地址 """ import aiohttp url = f"{self.base_url}/v1/images/generations" payload = { "model_name": model, "prompt": prompt, "aspect_ratio": aspect_ratio, } if negative_prompt: payload["negative_prompt"] = negative_prompt if callback_url: payload["callback_url"] = callback_url # 记录请求参数(调试用) logger.info( f"[KlingAI] generate_image 请求: {json.dumps(payload, ensure_ascii=False, indent=2)}" ) async with ( aiohttp.ClientSession() as session, session.post(url, json=payload, headers=await self._get_headers()) as resp, ): data = await resp.json() logger.info(f"[KlingAI] generate_image 响应: {json.dumps(data, ensure_ascii=False)}") if data.get("code") != 0: raise Exception(f"KlingAI API error: {data.get('message')}") result_data = data.get("data", {}) if isinstance(result_data, list) and len(result_data) > 0: return result_data[0] if isinstance(result_data[0], dict) else {} return result_data if isinstance(result_data, dict) else {} async def get_image_task(self, task_id: str, **kwargs) -> dict[str, Any]: """查询文生图任务状态 GET /v1/images/generations/{task_id} Args: task_id: 任务ID """ import aiohttp url = f"{self.base_url}/v1/images/generations/{task_id}" async with ( aiohttp.ClientSession() as session, session.get(url, headers=await self._get_headers()) as resp, ): data = await resp.json() if data.get("code") != 0: raise Exception(f"KlingAI API error: {data.get('message')}") return data.get("data", {}) # 导出 Provider 类 __all__ = ["KlingAIProvider", "KlingAIConfig"]