""" capabilities.kling_video — 可灵(Kling)视频生成能力实现 ========================================================== 支持:文生视频(text2video)/ 图生视频(image2video)+ 任务轮询 + 下载 鉴权:api_key(Bearer)优先 → access_key + secret_key(JWT HS256)回退 配置来源:agent-local/tools/ave/config/local.yaml → kling 节(或环境变量) 用途:视频工厂 S2-6「逐镜头生成」的默认引擎实现。 余额不足(code 1102)等错误会带明确提示,不会静默失败。 """ from __future__ import annotations import base64 import json import logging import os import ssl import time import urllib.error import urllib.request from pathlib import Path from typing import Any, Optional log = logging.getLogger("capabilities.kling") DEFAULT_BASE = "https://api-beijing.klingai.com" # ════════════════════════════════════════════════════════════ # ★ 网络统一入口:**禁用系统代理** + 自动重试 # ════════════════════════════════════════════════════════════ # 踩坑记录(2026-10-04 定位): # macOS 开了系统代理(127.0.0.1:7892)时,urllib 会**自动走代理**, # 而代理对可灵 API 时通时不通 → `SSL: UNEXPECTED_EOF_WHILE_READING` # → 表现为"生成随机失败"(用户遇到的角色生成失败即此因)。 # 直连实测:3/3 次 HTTP 200、0.2~0.3 秒。 # # 解决:所有 API 调用走**禁代理 opener** + 失败自动重试 3 次(指数退避)。 _NO_PROXY_OPENER = urllib.request.build_opener(urllib.request.ProxyHandler({})) def _urlopen(req, timeout: int = 60): """API 调用统一入口:禁代理 + 重试 3 次(应对网络抖动)""" last_exc = None for attempt in range(3): try: return _NO_PROXY_OPENER.open(req, timeout=timeout) except Exception as e: # URLError / SSLError / OSError / timeout last_exc = e if attempt < 2: time.sleep(1.5 * (attempt + 1)) raise last_exc # ── 配置 ─────────────────────────────────────────────── def _load_config() -> dict: """读 kling 配置(local.yaml 优先,环境变量覆盖)""" cfg: dict = {} try: import yaml _agent_local = Path(os.environ.get("AGENT_LOCAL") or (Path.home() / "workbuddy-agent-os" / "agent-local")) p = _agent_local / "tools" / "ave" / "config" / "local.yaml" if p.exists(): raw = yaml.safe_load(p.read_text(encoding="utf-8")) or {} cfg.update(raw.get("kling") or {}) except Exception as e: log.debug("读取 kling 配置失败: %s", e) if os.environ.get("KLING_API_KEY"): cfg["api_key"] = os.environ["KLING_API_KEY"] cfg.setdefault("base_url", DEFAULT_BASE) return cfg class KlingError(RuntimeError): """可灵调用错误(含 code / message,便于按余额不足等场景提示)""" def __init__(self, message: str, code: Any = None): super().__init__(message) self.code = code # ── 防人物漂移负向词(研究来源:madebysaira/CharacterConsistency 实测词表) # 作者反馈:加上这些后多镜一致性问题显著减少(重生成率 60% → 15%) # 用法:图生视频时随 prompt 一起传;最强的漂移词放最前 DRIFT_NEGATIVE = ( "face change, different person, another woman, another man, " "clothing shift, outfit change, hair style change, hair length change, " "body proportion change, extra limbs, deformed face, inconsistent lighting, " "skin tone shift, eye color change, blurry face, morphing, identity shift, " "reference image ignored, low face similarity" ) # 图生视频时的参考图权重(研究:Kling 推荐 0.75-0.85;0.5 会导致身份不稳) DEFAULT_IMAGE_CFG_SCALE = 0.8 class KlingVideoClient: """可灵视频生成客户端(文生视频 / 图生视频)""" def __init__(self, config: Optional[dict] = None): self.cfg = config or _load_config() self.base = (self.cfg.get("base_url") or DEFAULT_BASE).rstrip("/") self.api_key = (self.cfg.get("api_key") or "").strip() self.access_key = (self.cfg.get("access_key") or "").strip() self.secret_key = (self.cfg.get("secret_key") or "").strip() @property def available(self) -> bool: return bool(self.api_key or (self.access_key and self.secret_key)) def _headers(self) -> dict: if self.api_key: return {"Authorization": f"Bearer {self.api_key}", "Content-Type": "application/json"} # AK/SK → JWT HS256(官方方式) import hashlib import hmac header = base64.urlsafe_b64encode(json.dumps( {"alg": "HS256", "typ": "JWT"}).encode()).rstrip(b"=") now = int(time.time()) payload = base64.urlsafe_b64encode(json.dumps( {"iss": self.access_key, "exp": now + 1800, "nbf": now - 5}).encode()).rstrip(b"=") signing = header + b"." + payload sig = base64.urlsafe_b64encode( hmac.new(self.secret_key.encode(), signing, hashlib.sha256).digest()).rstrip(b"=") return {"Authorization": f"Bearer {(signing + b'.' + sig).decode()}", "Content-Type": "application/json"} def _post(self, path: str, payload: dict, _retries: int = 3) -> dict: """POST 请求 内置两类处理: · **并发超限(code 1303)→ 退避重试**(官方建议:初始延迟 ≥1s) 账号并发:视频 5 / 图片 9(图片按参数 n 计),超限即触发 · 余额不足(1102)/ 鉴权失败(1101)→ 直接抛明确错误,不重试 """ req = urllib.request.Request(self.base + path, data=json.dumps(payload).encode("utf-8"), headers=self._headers()) for attempt in range(max(1, _retries)): try: with _urlopen(req, timeout=60) as r: return json.loads(r.read().decode("utf-8")) except urllib.error.HTTPError as e: body = e.read().decode("utf-8", errors="ignore") try: d = json.loads(body) code, msg = d.get("code"), d.get("message", "") except Exception: code, msg = e.code, body[:200] # ★ 并发超限:官方明确建议退避重试(非参数错误) if code == 1303 or "parallel task over" in str(msg).lower(): if attempt < _retries - 1: delay = 2.0 * (attempt + 1) log.warning(" ⏳ 可灵并发超限(1303),%.0fs 后重试(%d/%d)", delay, attempt + 1, _retries - 1) time.sleep(delay) continue raise KlingError( f"可灵并发超限(code 1303)—— 已重试 {_retries} 次仍失败。" f"账号并发上限:视频 5 / 图片 9(图片按参数 n 计);" f"请稍后再试或降低并发生成数", code) if code == 1102 or "balance" in str(msg).lower(): raise KlingError("可灵账户余额不足(code 1102)—— 请到可灵开放平台充值后重试", code) if code == 1101 or "auth" in str(msg).lower(): raise KlingError(f"可灵鉴权失败(code {code})—— 检查 API Key 是否正确/过期", code) raise KlingError(f"可灵接口错误 HTTP {e.code} code={code}: {msg}", code) except KlingError: raise except Exception as e: raise KlingError(f"可灵请求失败: {e}") def _get(self, path: str) -> dict: req = urllib.request.Request(self.base + path, headers=self._headers()) try: with _urlopen(req, timeout=60) as r: return json.loads(r.read().decode("utf-8")) except urllib.error.HTTPError as e: body = e.read().decode("utf-8", errors="ignore") raise KlingError(f"可灵查询失败 HTTP {e.code}: {body[:150]}") except Exception as e: raise KlingError(f"可灵查询失败: {e}") # ── 任务提交 ─────────────────────────────────────── def submit_text2video(self, prompt: str, model: str = "kling-v3", duration: str = "5", aspect_ratio: str = "9:16", negative_prompt: str = "", cfg_scale: float = 0.5, camera_control: Optional[dict] = None, sound: str = "off") -> str: """文生视频 → task_id(camera_control:运镜控制,见 build_camera_control) sound:**原生音频开关**("on" / "off")。实测(2026-10-04): · off(默认)→ 输出只有视频流 · on → 输出含音频流(aac 44.1k stereo), 提示词里写对话即生成对应语音 + 环境音 """ payload = {"model_name": model, "prompt": prompt, "duration": duration, "aspect_ratio": aspect_ratio, "cfg_scale": cfg_scale} if negative_prompt: payload["negative_prompt"] = negative_prompt if camera_control: payload["camera_control"] = camera_control if sound == "on": payload["sound"] = "on" d = self._post("/v1/videos/text2video", payload) return self._extract_task_id(d) def submit_image2video(self, image_path: str, prompt: str = "", model: str = "kling-v3", duration: str = "5", tail_image_path: str = "", cfg_scale: float = DEFAULT_IMAGE_CFG_SCALE, camera_control: Optional[dict] = None, negative_prompt: str = DRIFT_NEGATIVE, sound: str = "off") -> str: """图生视频 → task_id(image:本地文件路径 → base64 或 URL) ⚠️ 研究结论(2026-10-03): · **cfg_scale(参考图权重)0.75-0.85** —— 原来用 0.5 会导致人物身份不稳、 跨镜换脸。默认已提到 0.8。 · **必须传防漂移负向词** —— 否则模型容易改变脸/服装/发型。 """ payload: dict = {"model_name": model, "duration": duration, "cfg_scale": cfg_scale, "image": self._to_image_value(image_path)} if prompt: payload["prompt"] = prompt if negative_prompt: payload["negative_prompt"] = negative_prompt if tail_image_path: payload["image_tail"] = self._to_image_value(tail_image_path) if camera_control: payload["camera_control"] = camera_control if sound == "on": payload["sound"] = "on" # ★ 原生音频(见 submit_text2video 注释) d = self._post("/v1/videos/image2video", payload) return self._extract_task_id(d) @staticmethod def _to_image_value(image: str) -> str: """本地路径 → base64(不带前缀);http(s) 或已是 base64 则原样返回""" if image.startswith(("http://", "https://")): return image p = Path(image) if p.exists(): return base64.b64encode(p.read_bytes()).decode("utf-8") return image @staticmethod def _extract_task_id(d: dict) -> str: if d.get("code") != 0: raise KlingError(f"可灵提交失败 code={d.get('code')}: {d.get('message')}", d.get("code")) tid = (d.get("data") or {}).get("task_id") if not tid: raise KlingError(f"可灵未返回 task_id: {str(d)[:200]}") return tid # ── 轮询 + 下载 ──────────────────────────────────── def wait_for_video(self, task_id: str, kind: str = "text2video", timeout: int = 600, interval: int = 10, progress_cb=None) -> str: """轮询任务直至完成 → 返回视频 URL""" path = f"/v1/videos/{kind}/{task_id}" deadline = time.time() + timeout while time.time() < deadline: d = self._get(path) data = d.get("data") or {} status = data.get("task_status") if status == "succeed": videos = (data.get("task_result") or {}).get("videos") or [] if not videos: raise KlingError("可灵任务成功但无视频输出") return videos[0].get("url", "") if status == "failed": raise KlingError(f"可灵生成失败: {data.get('task_status_msg') or '未知原因'}") if progress_cb: progress_cb(status or "processing") time.sleep(interval) raise KlingError(f"可灵任务超时({timeout}s)未完成") def download(self, url: str, dest: str | Path) -> Path: """下载视频到本地 可灵返回的是**带签名的完整 URL**(含 cacheKey 参数),通常**无需额外鉴权**; 若首次返回 401/403,再带鉴权头重试一次(兼容不同资源域名)。 """ p = Path(dest) p.parent.mkdir(parents=True, exist_ok=True) attempts: list[dict] = [{}] try: headers = self._headers() headers.pop("Content-Type", None) attempts.append(headers) except Exception: pass last_err: Exception | None = None for hdrs in attempts: try: req = urllib.request.Request(url, headers=hdrs) with _urlopen(req, timeout=300) as r, open(p, "wb") as f: f.write(r.read()) return p except urllib.error.HTTPError as e: last_err = e if e.code not in (401, 403): break except Exception as e: last_err = e break code = getattr(last_err, "code", "?") raise KlingError( f"下载生成视频失败(HTTP {code})—— 请确认任务返回的 URL 完整" "(含 ?cacheKey=... 签名参数;可灵资源有效期 30 天)" ) # ── 一站式 ───────────────────────────────────────── # ════════════════════════════════════════════════════════ # 账号与费用(官方接口,**免费**) # ════════════════════════════════════════════════════════ def get_account_costs(self, days: int = 30) -> dict: """查询账号**资源包余量**(官方免费接口,QPS ≤ 1) ⚠️ 官方说明:余量统计有 **12 小时延迟**,非实时。 ⚠️ 只返回资源包(含平台赠送的试用包);**现金余额需在可灵控制台查看**。 实测字段(2026-10-04):resource_pack_name / total_quantity / remaining_quantity / resource_pack_type / status / invalid_time """ import time as _t now_ms = int(_t.time() * 1000) start_ms = now_ms - int(days) * 24 * 3600 * 1000 try: j = self._get(f"/account/costs?start_time={start_ms}&end_time={now_ms}") except Exception as e: return {"packs": [], "summary": {}, "error": str(e)[:200]} if j.get("code") != 0: return {"packs": [], "summary": {}, "error": str(j.get("message", ""))[:200]} packs = [] for p in (j.get("data") or {}).get("resource_pack_subscribe_infos") or []: total = float(p.get("total_quantity") or 0) left = float(p.get("remaining_quantity") or 0) packs.append({ "name": p.get("resource_pack_name", ""), "type": p.get("resource_pack_type", ""), "total": total, "remaining": left, "used": round(total - left, 2), "used_pct": round((total - left) / total * 100, 1) if total else 0, "status": p.get("status", ""), "expire_at": p.get("invalid_time"), }) summary: dict = {} for p in packs: nm = p["name"] key = "video" if "视频" in nm else ("image" if ("图像" in nm or "图片" in nm) else "other") s = summary.setdefault(key, {"total": 0.0, "remaining": 0.0, "used": 0.0, "packs": 0}) s["total"] += p["total"] s["remaining"] += p["remaining"] s["used"] += p["used"] s["packs"] += 1 return {"packs": packs, "summary": summary, "days": days, "note": "官方余量有 12 小时延迟;现金余额需在可灵控制台查看"} # ════════════════════════════════════════════════════════ # 视频生音效(给已有视频自动配音效 / BGM)—— ¥0.25/次 # ════════════════════════════════════════════════════════ def submit_video_to_audio(self, video_id: str = "", video_url: str = "", sound_effect_prompt: str = "", bgm_prompt: str = "", asmr_mode: bool = False) -> str: """给视频生成音效/BGM(POST /v1/audio/video-to-audio) · 成本 ¥0.25/次 · video_id 与 video_url **二选一**(不能都填) · video_id 仅支持 30 天内的可灵生成视频,时长 3~20 秒 · prompt ≤ 200 字符;asmr_mode=True 增强细节音效 用途:我们的成片默认**完全静音**("AI 感"的重要来源), 用此接口为镜头补真实环境音 → 显著提升真实感。 """ if bool(video_id) == bool(video_url): raise ValueError("video_id 与 video_url 必须二选一") payload: dict = {} if video_id: payload["video_id"] = video_id else: payload["video_url"] = video_url if sound_effect_prompt: payload["sound_effect_prompt"] = sound_effect_prompt[:200] if bgm_prompt: payload["bgm_prompt"] = bgm_prompt[:200] if asmr_mode: payload["asmr_mode"] = True d = self._post("/v1/audio/video-to-audio", payload) return self._extract_task_id(d) # ════════════════════════════════════════════════════════ # 多图参考生图(主体 + 场景 + 风格 **分离**)—— ¥0.4/张 # ════════════════════════════════════════════════════════ def submit_multi_image2image(self, subject_images: list, prompt: str = "", scene_image: str = "", style_image: str = "", aspect_ratio: str = "9:16", n: int = 1, model: str = "kling-v2-1") -> str: """多图参考生图(POST /v1/images/multi-image2image) · 成本 ¥0.4/张 · subject_images: **1~4 张主体参考图**(URL 或 base64 字符串,不加前缀) 我们的「5 视角套装」正适合喂进来(正面 + 45° + 侧面) · scene_image: **场景参考图** ← 解决「画面没有环境」的正解 · style_image: 风格参考图 · prompt ≤ 2500 字符 · aspect_ratio: 16:9 / 9:16 / 1:1 / 4:3 / 3:4 / 3:2 / 2:3 / 21:9 ⚠️ **实测约束(2026-10-04 探测)**: ① **只支持 model=kling-v2-1**(kling-v3 / kling-image-o1 报 model is not supported) ② **主体 + 场景 + 风格三组图「合计」至少 2 张** solo 1 张会报错;最小组合 = 1 主体 + 1 场景,或 2 张主体(正面+侧面) ⚠️ 并发按 n 计(账号图片并发 9)→ 建议 n=1 串行调用,避免 1303 用途(回答「参考图该用空白背景还是场景」): 官方接口把主体与场景**分开** —— 角色图只管人物,场景图只管环境, 两者不互相污染。这印证了业界结论。 """ subs = [s for s in (subject_images or []) if s][:4] if not subs: raise ValueError("subject_images 至少需要 1 张主体参考图") if len(subs) + (1 if scene_image else 0) + (1 if style_image else 0) < 2: raise ValueError("三组图(主体/场景/风格)**合计至少 2 张**:" "请补一张场景图,或提供 2 张以上主体图(如正面+侧面)") payload: dict = { "model_name": model, "subject_image_list": [{"subject_image": s} for s in subs], "aspect_ratio": aspect_ratio, "n": int(n), } if prompt: payload["prompt"] = prompt[:2500] if scene_image: payload["scene_image"] = scene_image if style_image: payload["style_image"] = style_image d = self._post("/v1/images/multi-image2image", payload) return self._extract_task_id(d) # ── 通用:把可灵 task_id 记到 sidecar 文件(供「视频生音效」用)── @staticmethod def save_task_id(dest, task_id: str) -> None: """把 task_id 写到 `{dest}.taskid`(video-to-audio 只认可灵 video_id)""" try: p = Path(dest) p.with_suffix(p.suffix + ".taskid").write_text(str(task_id), encoding="utf-8") except Exception as e: log.debug("记录 task_id 失败: %s", e) @staticmethod def load_task_id(video_path) -> str: """读取镜头对应的可灵 task_id(无则返回空串)""" try: p = Path(video_path) f = p.with_suffix(p.suffix + ".taskid") return f.read_text(encoding="utf-8").strip() if f.exists() else "" except Exception: return "" def wait_for_audio(self, task_id: str, timeout: int = 600) -> str: """等待「视频生音效」任务完成 → 返回音频 URL(通常 30~90 秒)""" import time as _t deadline = _t.time() + timeout last = "" while _t.time() < deadline: try: d = self._get(f"/v1/audio/video-to-audio/{task_id}") except Exception as e: log.debug("音效任务查询失败(重试): %s", str(e)[:80]) _t.sleep(8) continue data = d.get("data") or {} st = data.get("task_status", "") if st == "succeed": r = data.get("task_result") or {} urls = [] for key in ("sound_effects", "bgm", "audios"): v = r.get(key) if isinstance(v, list): urls += [x.get("url") for x in v if isinstance(x, dict) and x.get("url")] elif isinstance(v, dict) and v.get("url"): urls.append(v["url"]) if not urls: for key in ("video_url", "url"): if r.get(key): urls.append(r[key]) if urls: return urls[0] raise KlingError(f"音效任务成功但无音频 URL: {str(r)[:200]}") if st in ("failed", "canceled"): raise KlingError(f"音效任务 {st}: {str(data.get('task_status_msg') or '')[:200]}") if st != last: last = st log.info(" 🎧 音效任务 %s: %s", task_id[:8], st) _t.sleep(8) raise KlingError(f"音效任务超时(>{timeout}s)") def generate_sfx_for_video(self, video_id: str, dest: str | Path, sound_effect_prompt: str = "", bgm_prompt: str = "", asmr_mode: bool = False, timeout: int = 600) -> Path: """给指定镜头生成音效并下载(**¥0.25/次**) 用途:我们的成片默认**完全静音**("AI 感"的主要来源之一)。 给镜头补真实环境音/BGM → 混进最终音轨后真实感显著提升。 ⚠️ video_id 必须是**可灵平台的任务 ID**(生成镜头时记下的 task_id) """ tid = self.submit_video_to_audio(video_id=video_id, sound_effect_prompt=sound_effect_prompt, bgm_prompt=bgm_prompt, asmr_mode=asmr_mode) log.info(" 🎧 音效任务已提交: %s(¥0.25)", tid[:12]) url = self.wait_for_audio(tid, timeout=timeout) self.download(url, dest) log.info(" ✅ 音效已下载: %s", Path(dest).name) return Path(dest) def generate_video(self, prompt: str, dest: str | Path, image_path: str = "", duration: str = "5", aspect_ratio: str = "9:16", model: str = "kling-v3", timeout: int = 600, progress_cb=None, vfx_type: str = "", vfx_params: Optional[dict] = None, tail_image_path: str = "", sound: str = "off") -> Path: """生成一个镜头片段(有参考图走图生视频,否则文生视频) vfx_type:特效类型(orbit / bullet_time / slow_motion / focus_lock …) → 自动转成可灵 `camera_control` 运镜参数 tail_image_path:尾帧图 —— **首尾帧衔接**用(可灵 image_tail): 本镜接上一镜时,把上一镜的尾帧传进来,生成结果会从该尾帧收束 sound:**原生音频**("on"/"off",实测可用)。开着它时提示词里写对话, 会一次生成「带语音的视频」→ 音画天然同步,无需后期 TTS / 对口型。 """ cam = self.build_camera_control(vfx_type, vfx_params) if cam: log.info(" 🎥 运镜控制: %s", cam) if sound == "on": log.info(" 🔊 原生有声模式(sound=on)—— 提示词里的对话将被念出来") if image_path and Path(image_path).exists(): tid = self.submit_image2video(image_path, prompt, model=model, duration=duration, camera_control=cam, tail_image_path=tail_image_path, sound=sound) kind = "image2video" log.info(" 🎬 可灵图生视频任务: %s", tid) else: tid = self.submit_text2video(prompt, model=model, duration=duration, aspect_ratio=aspect_ratio, camera_control=cam, sound=sound) kind = "text2video" log.info(" 🎬 可灵文生视频任务: %s", tid) # ★ 记录 task_id(供后续「视频生音效」—— video-to-audio 只认可灵 video_id) self.save_task_id(dest, tid) url = self.wait_for_video(tid, kind=kind, timeout=timeout, progress_cb=progress_cb) return self.download(url, dest) # ── 特效 → 运镜控制映射 ────────────────────────── @staticmethod def build_camera_control(vfx_type: str, vfx_params: Optional[dict] = None) -> Optional[dict]: """把「特效类型」映射成可灵 `camera_control` 运镜参数 可灵规范: type = simple(配合 config 六选一)| down_back | forward_up | right_turn_forward | left_turn_forward config(type=simple 时):horizontal / vertical / pan / tilt / roll / zoom 取值 [-10, 10],**只能填一个且不能为 0** 映射: orbit 环绕运镜 → horizontal 6 bullet_time 子弹时间 → roll 5(配合提示词升格 + 后处理变速) slow_motion 慢动作 → vertical 3(缓慢推进) focus_lock 焦点锁定 → zoom 4(变焦) down_back / forward_up / right_turn_forward / left_turn_forward(官方整段运镜预设) """ vt = (vfx_type or "").strip().lower() if not vt: return None presets: dict = { "orbit": {"type": "simple", "config": {"horizontal": 6}}, "bullet_time": {"type": "simple", "config": {"roll": 5}}, "slow_motion": {"type": "simple", "config": {"vertical": 3}}, "focus_lock": {"type": "simple", "config": {"zoom": 4}}, "down_back": {"type": "down_back", "config": {}}, "forward_up": {"type": "forward_up", "config": {}}, "right_turn_forward": {"type": "right_turn_forward", "config": {}}, "left_turn_forward": {"type": "left_turn_forward", "config": {}}, } if vt in presets: return presets[vt] if vfx_params: # 自定义运镜(直接给 config) return {"type": "simple", "config": vfx_params} return None