移植自 mac-agent-os 的 mediacrawler_adapter:不起子进程、不开页面,用浏览器里那份
登录态直接调抖音 Web 接口。产物键名照抄 store/douyin,所以 ingest 那条链路一个字不用改。
**目前能用的(真环境实测,非推断)**:
profile/other : 200, 7075 字节 —— 博主主页指标(粉丝/获赞/作品数/昵称)
aweme/detail : 200, 45425 字节 —— 单条作品详情(含点赞/评论/收藏/分享)
**目前不能用的:作品列表 `aweme/post`。** 两个互相独立的原因:
1. 这个接口被抖音单独升级成了真校验:不带 x-tt-argus 回 403「Uifid Not Found」,
带上 dummy 值回 200 + **空 body**。也就是说「头在不在」骗得过,「真校验」过不了。
同一套头打 profile/other 和 aweme/detail 都是通的 —— 抖音是挑着接口加保护的,
挑中的恰好是「批量拉作品列表」这个最敏感的动作。
2. 改走页面截获也不行:CDP 浏览器打开博主主页会落到「验证码中间页」(当天大量探测的
代价,过几小时要重测)。
所以现在的边界是:**已知作品的指标刷新能做,自动发现新作品做不了**。
**排查中控住变量后得到的两条事实**(都写进注释了):
· `Accept` / `Accept-Language` / `Referer` 才是主页接口能返回真数据的原因 —— 只有
UA+client hints+Cookie 时是 200 但仅 121 字节的空壳,补上这三个头变 7074 字节。
(我先前猜的 sec-ch-ua 不是关键。)
· 因此 UA 与 client hints 必须**成套地取自同一个浏览器**,所以 BrowserIdentity 一次
从 CDP 取齐 cookie + UA + hints,而不是各自写死。
「200 + 空 body 必须当场报错」也是刻意写死的:放过去它会在下游变成「这个博主没作品」,
把一次失败伪装成一条正常结果 —— 爬虫那条路正是这么栽的,还被翻译成「账号被封」。
测试 +11:cookie 解析、请求头成套性(含 uifid 缺失/回退)、产物键名与 store 对齐、
以及 _get 的三条失败路径(空 body / 403 带网关原话 / 正常返回)。
605 lines
30 KiB
Python
605 lines
30 KiB
Python
"""
|
||
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
|