feat(monitor): 抖音 Web 接口客户端 —— 绕开爬虫子进程,直接发 HTTP
移植自 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 带网关原话 / 正常返回)。
This commit is contained in:
@@ -0,0 +1,232 @@
|
||||
"""
|
||||
capabilities — 视频工厂能力注册表加载器
|
||||
==========================================
|
||||
|
||||
「能力真相源」的读写接口。全项目通过这里查询能力/供应商/参数默认值。
|
||||
|
||||
用法:
|
||||
from capabilities import CapabilityRegistry
|
||||
reg = CapabilityRegistry()
|
||||
caps = reg.all() # 全部能力
|
||||
cap = reg.get("tts_generate") # 单个能力
|
||||
reg.set_default("tts_generate", provider="aliyun") # 改默认供应商(写回 yaml)
|
||||
reg.set_param_default("tts_generate", "speed", 1.2) # 改参数默认值
|
||||
|
||||
配置状态:provider 的 api_key_ref 指向「🔑 API 配置」管理的字段,
|
||||
本模块只**检查是否已配置**,不读取密钥内容。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
|
||||
log = logging.getLogger("capabilities")
|
||||
|
||||
SCRIPTS_DIR = Path(__file__).resolve().parent.parent # 09_ave/scripts/
|
||||
AVE_DIR = SCRIPTS_DIR.parent # 09_ave/
|
||||
REGISTRY_PATH = AVE_DIR / "config" / "capabilities.yaml"
|
||||
|
||||
# agent-local 配置中心(密钥状态查询用)
|
||||
_AGENT_LOCAL = Path(os.environ.get("AGENT_LOCAL") or (AVE_DIR.parents[2] / "agent-local"))
|
||||
_LOCAL_CFG = _AGENT_LOCAL / "tools" / "ave" / "config" / "local.yaml"
|
||||
|
||||
|
||||
class CapabilityRegistry:
|
||||
"""能力注册表(yaml 读写 + 查询 + 密钥状态)"""
|
||||
|
||||
def __init__(self, path: Path | None = None):
|
||||
self.path = Path(path) if path else REGISTRY_PATH
|
||||
self._data: dict = {}
|
||||
self.load()
|
||||
|
||||
# ── 读写 ──────────────────────────────────────────
|
||||
|
||||
def load(self) -> dict:
|
||||
if not self.path.exists():
|
||||
log.warning(" ⚠️ 能力注册表不存在: %s", self.path)
|
||||
self._data = {"version": "0", "categories": {}, "capabilities": []}
|
||||
return self._data
|
||||
try:
|
||||
self._data = yaml.safe_load(self.path.read_text(encoding="utf-8")) or {}
|
||||
except Exception as exc:
|
||||
log.error(" ❌ 能力注册表解析失败: %s", exc)
|
||||
self._data = {"version": "0", "categories": {}, "capabilities": []}
|
||||
return self._data
|
||||
|
||||
def save(self) -> bool:
|
||||
"""写回 yaml(保留可读格式)"""
|
||||
try:
|
||||
self._data["updated"] = _today()
|
||||
with self.path.open("w", encoding="utf-8") as f:
|
||||
yaml.safe_dump(
|
||||
self._data, f, allow_unicode=True, sort_keys=False,
|
||||
default_flow_style=False, width=120,
|
||||
)
|
||||
log.info(" 💾 能力注册表已保存: %s", self.path.name)
|
||||
return True
|
||||
except Exception as exc:
|
||||
log.error(" ❌ 能力注册表保存失败: %s", exc)
|
||||
return False
|
||||
|
||||
# ── 查询 ──────────────────────────────────────────
|
||||
|
||||
def all(self) -> list[dict]:
|
||||
return list(self._data.get("capabilities") or [])
|
||||
|
||||
def categories(self) -> dict:
|
||||
return dict(self._data.get("categories") or {})
|
||||
|
||||
def get(self, cap_id: str) -> dict | None:
|
||||
for c in self.all():
|
||||
if c.get("id") == cap_id:
|
||||
return c
|
||||
return None
|
||||
|
||||
def providers_of(self, cap_id: str) -> list[dict]:
|
||||
cap = self.get(cap_id)
|
||||
return list(cap.get("providers") or []) if cap else []
|
||||
|
||||
def by_category(self) -> dict[str, list[dict]]:
|
||||
out: dict[str, list[dict]] = {}
|
||||
for c in self.all():
|
||||
out.setdefault(c.get("category", "other"), []).append(c)
|
||||
return out
|
||||
|
||||
# ── 修改(S1 的编辑能力)──────────────────────────
|
||||
|
||||
def set_default(self, cap_id: str, provider: str | None = None,
|
||||
model: str | None = None) -> bool:
|
||||
"""改默认供应商 / 模型"""
|
||||
cap = self.get(cap_id)
|
||||
if not cap:
|
||||
return False
|
||||
defaults = cap.setdefault("defaults", {})
|
||||
if provider:
|
||||
valid = [p.get("id") for p in (cap.get("providers") or [])]
|
||||
if provider not in valid:
|
||||
log.warning(" ⚠️ 供应商不在列表中: %s(可用: %s)", provider, valid)
|
||||
return False
|
||||
defaults["provider"] = provider
|
||||
# 同步 providers 的 default 标记
|
||||
for p in cap.get("providers") or []:
|
||||
p["default"] = (p.get("id") == provider)
|
||||
if model:
|
||||
defaults["model"] = model
|
||||
return self.save()
|
||||
|
||||
def set_param_default(self, cap_id: str, param: str, value: Any) -> bool:
|
||||
"""改参数默认值(option 级默认 + defaults 同步)"""
|
||||
cap = self.get(cap_id)
|
||||
if not cap:
|
||||
return False
|
||||
schema = cap.get("params_schema") or {}
|
||||
if param not in schema:
|
||||
log.warning(" ⚠️ 参数不存在: %s.%s", cap_id, param)
|
||||
return False
|
||||
spec = schema[param]
|
||||
# 范围校验
|
||||
if spec.get("type") == "number":
|
||||
lo, hi = spec.get("min"), spec.get("max")
|
||||
try:
|
||||
value = float(value)
|
||||
if lo is not None and value < lo:
|
||||
value = lo
|
||||
if hi is not None and value > hi:
|
||||
value = hi
|
||||
if float(value).is_integer():
|
||||
value = int(value)
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
elif spec.get("type") == "boolean":
|
||||
value = bool(value)
|
||||
elif spec.get("type") == "enum":
|
||||
if value not in (spec.get("options") or []):
|
||||
log.warning(" ⚠️ 枚举值非法: %s", value)
|
||||
return False
|
||||
schema[param]["default"] = value
|
||||
cap.setdefault("defaults", {})[param] = value
|
||||
return self.save()
|
||||
|
||||
# ── 密钥配置状态(不读密钥内容)────────────────────
|
||||
|
||||
def key_configured(self, api_key_ref: str) -> bool | None:
|
||||
"""检查 api_key_ref 指向的密钥是否已配置。None = 本地能力(无需密钥)"""
|
||||
if not api_key_ref:
|
||||
return None
|
||||
if not _LOCAL_CFG.exists():
|
||||
return False
|
||||
try:
|
||||
cfg = yaml.safe_load(_LOCAL_CFG.read_text(encoding="utf-8")) or {}
|
||||
except Exception:
|
||||
return False
|
||||
node: Any = cfg
|
||||
for part in api_key_ref.split("."):
|
||||
if isinstance(node, dict) and part in node:
|
||||
node = node[part]
|
||||
else:
|
||||
return False
|
||||
return bool(str(node or "").strip())
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""供前端消费的完整结构(含密钥配置状态)"""
|
||||
cats = self.categories()
|
||||
caps = []
|
||||
for c in self.all():
|
||||
item = dict(c)
|
||||
# 供应商密钥状态(兼容 api_key_ref / key_ref 两种字段名)
|
||||
for p in item.get("providers") or []:
|
||||
p["key_configured"] = self.key_configured(
|
||||
p.get("api_key_ref") or p.get("key_ref") or "")
|
||||
caps.append(item)
|
||||
return {
|
||||
"version": self._data.get("version", ""),
|
||||
"updated": self._data.get("updated", ""),
|
||||
"categories": [
|
||||
{"id": k, **v} for k, v in sorted(
|
||||
cats.items(), key=lambda kv: kv[1].get("order", 99))
|
||||
],
|
||||
"capabilities": caps,
|
||||
"total": len(caps),
|
||||
"by_status": _count_by(caps, "status"),
|
||||
"by_category": _count_by(caps, "category"),
|
||||
}
|
||||
|
||||
# ── 修复/维护 ─────────────────────────────────────
|
||||
|
||||
def remove_legacy_dirs(self) -> list[str]:
|
||||
"""列出可归档的编号空壳目录(01-07)—— 不自动删除,仅报告"""
|
||||
dead = []
|
||||
for d in sorted(SCRIPTS_DIR.iterdir()):
|
||||
if d.is_dir() and d.name[:2].isdigit() and d.name[2:3] == "_":
|
||||
init = d / "__init__.py"
|
||||
if init.exists() and init.stat().st_size == 0:
|
||||
dead.append(d.name)
|
||||
return dead
|
||||
|
||||
|
||||
def _count_by(items: list[dict], field: str) -> dict:
|
||||
out: dict[str, int] = {}
|
||||
for it in items:
|
||||
k = str(it.get(field) or "unknown")
|
||||
out[k] = out.get(k, 0) + 1
|
||||
return out
|
||||
|
||||
|
||||
def _today() -> str:
|
||||
from datetime import date
|
||||
return date.today().isoformat()
|
||||
|
||||
|
||||
# 便捷单例
|
||||
_registry: CapabilityRegistry | None = None
|
||||
|
||||
|
||||
def get_registry(reload: bool = False) -> CapabilityRegistry:
|
||||
global _registry
|
||||
if _registry is None or reload:
|
||||
_registry = CapabilityRegistry()
|
||||
return _registry
|
||||
@@ -0,0 +1,350 @@
|
||||
"""百炼(DashScope)视频生成 —— 首尾帧 KF2V / 多图参考 R2V
|
||||
|
||||
为什么单独一个模块:可灵走 `kling_video.py`(channel=kling),
|
||||
百炼走这里(channel=dashscope)—— 两家接口完全不同,不能混。
|
||||
|
||||
价格对比(`config/video_models.yaml`):
|
||||
wan2.2-kf2v-flash ¥0.1/秒(480P) ← 首尾帧专用,最省
|
||||
wan2.6-r2v-flash ¥0.3/秒(720P) ← 多图参考(角色+场景+首帧)
|
||||
kling-v3 ¥0.6/秒(720P) ← 现行默认
|
||||
→ 同样 4 镜 5 秒:可灵 ¥12,kf2v ¥2。
|
||||
|
||||
调用范式(与 `material_producer/wan2_2/wan2_2.py` 一致,四步):
|
||||
① 上传图片 → oss:// URL(每张都要)
|
||||
② POST video-synthesis(X-DashScope-Async: enable)
|
||||
③ GET /api/v1/tasks/{task_id} 轮询
|
||||
④ 下载 video_url
|
||||
|
||||
字段(来自千问AI平台 OpenAPI spec,见 docs/pipeline_research/03):
|
||||
KF2V: input.first_frame_url + input.last_frame_url + input.prompt
|
||||
R2V : input.media[] = [{type: first_frame|reference_image|reference_video, url}]
|
||||
+ input.prompt
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
import yaml
|
||||
|
||||
log = logging.getLogger("ave.dashscope_video")
|
||||
|
||||
# ── 端点 ────────────────────────────────────────────────────
|
||||
# ⚠️ 两个模型走**不同端点**(据官方 OpenAPI 规格,见 docs/pipeline_research/03):
|
||||
# KF2V(首尾帧) → /services/aigc/image2video/video-synthesis
|
||||
# R2V (多图参考)→ /services/aigc/video-generation/video-synthesis
|
||||
BASE = "https://dashscope.aliyuncs.com"
|
||||
UPLOAD_API = f"{BASE}/api/v1/uploads"
|
||||
SUBMIT_API_KF2V = f"{BASE}/api/v1/services/aigc/image2video/video-synthesis"
|
||||
SUBMIT_API_R2V = f"{BASE}/api/v1/services/aigc/video-generation/video-synthesis"
|
||||
SUBMIT_API = SUBMIT_API_R2V # 兼容旧引用
|
||||
TASK_API = f"{BASE}/api/v1/tasks/{{task_id}}"
|
||||
|
||||
# ⚠️ 所有 httpx 调用都要 trust_env=False —— macOS 系统代理会让
|
||||
# 国内 API 时通时不通(本项目踩过多次,见 docs/参数速查.md)
|
||||
|
||||
_LOCAL_YAML = Path.home() / "workbuddy-agent-os" / "agent-local" / "tools" / "ave" / "config" / "local.yaml"
|
||||
|
||||
|
||||
def api_key() -> str:
|
||||
"""读百炼 api_key(agent-local,不进 git)"""
|
||||
env = os.environ.get("DASHSCOPE_API_KEY")
|
||||
if env:
|
||||
return env
|
||||
try:
|
||||
d = yaml.safe_load(_LOCAL_YAML.read_text(encoding="utf-8")) or {}
|
||||
return str((d.get("aliyun") or {}).get("api_key") or "")
|
||||
except Exception as e:
|
||||
log.warning("读 local.yaml 失败: %s", e)
|
||||
return ""
|
||||
|
||||
|
||||
def available() -> bool:
|
||||
return bool(api_key())
|
||||
|
||||
|
||||
# ── ① 上传 ──────────────────────────────────────────────────
|
||||
def _mime(p: str) -> str:
|
||||
return {"png": "image/png", "jpg": "image/jpeg", "jpeg": "image/jpeg",
|
||||
"webp": "image/webp", "bmp": "image/bmp",
|
||||
"mp4": "video/mp4",
|
||||
"mp3": "audio/mpeg", "wav": "audio/wav", "m4a": "audio/mp4"}.get(
|
||||
Path(p).suffix.lower().lstrip("."), "application/octet-stream")
|
||||
|
||||
|
||||
def upload_file(path: str, model: str, key: str = "") -> str:
|
||||
"""上传到百炼临时存储 → oss:// URL
|
||||
|
||||
已上传过(同名 + 同模型)会复用,避免重复上传。
|
||||
"""
|
||||
key = key or api_key()
|
||||
p = Path(path)
|
||||
if not p.exists():
|
||||
raise FileNotFoundError(f"文件不存在: {path}")
|
||||
|
||||
r = httpx.get(UPLOAD_API, params={"action": "getPolicy", "model": model},
|
||||
headers={"Authorization": f"Bearer {key}"}, timeout=20, trust_env=False)
|
||||
r.raise_for_status()
|
||||
d = r.json()["data"]
|
||||
|
||||
oss_key = f"{d['upload_dir']}/{p.name}"
|
||||
with open(p, "rb") as f:
|
||||
files = {
|
||||
"OSSAccessKeyId": (None, d["oss_access_key_id"]),
|
||||
"policy": (None, d["policy"]),
|
||||
"Signature": (None, d["signature"]),
|
||||
"key": (None, oss_key),
|
||||
"x-oss-object-acl": (None, d["x_oss_object_acl"]),
|
||||
"x-oss-forbid-overwrite": (None, d["x_oss_forbid_overwrite"]),
|
||||
"success_action_status": (None, "200"),
|
||||
"file": (p.name, f, _mime(str(p))),
|
||||
}
|
||||
up = httpx.post(d["upload_host"], files=files, timeout=180, trust_env=False)
|
||||
up.raise_for_status()
|
||||
return f"oss://{oss_key}"
|
||||
|
||||
|
||||
def _as_url(v: str, model: str, key: str) -> str:
|
||||
"""本地路径 → oss:// ;已是 http(s)/oss:// /data: 则原样返回"""
|
||||
if not v:
|
||||
return ""
|
||||
s = str(v)
|
||||
if s.startswith(("http://", "https://", "oss://", "data:")):
|
||||
return s
|
||||
return upload_file(s, model, key)
|
||||
|
||||
|
||||
# ── ② 提交 ──────────────────────────────────────────────────
|
||||
def _submit(payload: dict, key: str, endpoint: str = "") -> str:
|
||||
headers = {
|
||||
"Authorization": f"Bearer {key}",
|
||||
"X-DashScope-Async": "enable",
|
||||
"X-DashScope-OssResourceResolve": "enable",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
r = httpx.post(endpoint or SUBMIT_API_R2V, headers=headers, json=payload,
|
||||
timeout=40, trust_env=False)
|
||||
if r.status_code >= 400:
|
||||
raise RuntimeError(f"百炼提交失败 HTTP {r.status_code}: {r.text[:300]}")
|
||||
out = (r.json() or {}).get("output") or {}
|
||||
tid = out.get("task_id")
|
||||
if not tid:
|
||||
raise RuntimeError(f"百炼未返回 task_id: {json.dumps(r.json(), ensure_ascii=False)[:300]}")
|
||||
return tid
|
||||
|
||||
|
||||
def submit_kf2v(first_frame: str, last_frame: str, prompt: str = "", *,
|
||||
model: str = "wan2.2-kf2v-flash", resolution: str = "480P",
|
||||
negative_prompt: str = "", prompt_extend: bool = True,
|
||||
seed: int | None = None, template: str = "",
|
||||
key: str = "") -> str:
|
||||
"""首尾帧生视频 —— 给「首帧 + 尾帧」,模型在两帧之间插值
|
||||
|
||||
⚠️ 官方规格(docs/pipeline_research/03 §二):
|
||||
· 端点 = /services/aigc/image2video/video-synthesis(**不是** video-generation)
|
||||
· duration **固定 5 秒,不可修改** → 不传该参数
|
||||
· 输出宽高比跟随 first_frame_url
|
||||
· 用了 template(特效模板)时只需传 first_frame_url
|
||||
"""
|
||||
key = key or api_key()
|
||||
if not key:
|
||||
raise RuntimeError("未配置百炼 key(agent-local/tools/ave/config/local.yaml → aliyun.api_key)")
|
||||
if not last_frame:
|
||||
raise ValueError("KF2V 必须同时给首帧和尾帧")
|
||||
inp = {"first_frame_url": _as_url(first_frame, model, key)}
|
||||
if not template:
|
||||
inp["last_frame_url"] = _as_url(last_frame, model, key)
|
||||
if prompt:
|
||||
inp["prompt"] = prompt
|
||||
if negative_prompt:
|
||||
inp["negative_prompt"] = negative_prompt[:500]
|
||||
params: dict = {"resolution": resolution, "prompt_extend": bool(prompt_extend)}
|
||||
if template:
|
||||
params["template"] = template
|
||||
if seed is not None:
|
||||
params["seed"] = int(seed)
|
||||
log.info("KF2V 提交:%s · %s · 固定5s · %s", model, resolution,
|
||||
"模板" if template else "首尾帧")
|
||||
return _submit({"model": model, "input": inp, "parameters": params}, key, SUBMIT_API_KF2V)
|
||||
|
||||
|
||||
def submit_r2v(media: list[dict], prompt: str = "", *,
|
||||
model: str = "wan2.6-r2v-flash", resolution: str = "720P",
|
||||
duration: int = 5, key: str = "") -> str:
|
||||
"""多图参考生视频
|
||||
|
||||
media: [{"type": "first_frame"|"reference_image"|"reference_video", "url": 本地路径或URL}]
|
||||
最多 5 个(config 里的 limits.max_reference_images)
|
||||
"""
|
||||
key = key or api_key()
|
||||
if not key:
|
||||
raise RuntimeError("未配置百炼 key")
|
||||
if not media:
|
||||
raise ValueError("R2V 至少要给 1 个 media 元素")
|
||||
if not (prompt or "").strip():
|
||||
# 官方规格:prompt 为 ★必填(「期望视频内容」)—— 不传会直接失败
|
||||
raise ValueError("R2V 的 prompt 是必填项(描述期望的视频内容),不能为空")
|
||||
items = []
|
||||
for m in media[:5]:
|
||||
u = _as_url(m.get("url") or "", model, key)
|
||||
if not u:
|
||||
continue
|
||||
it = {"type": m.get("type") or "reference_image", "url": u}
|
||||
if m.get("reference_voice"):
|
||||
it["reference_voice"] = m["reference_voice"]
|
||||
items.append(it)
|
||||
if not items:
|
||||
raise ValueError("media 里没有可用的 url")
|
||||
payload = {
|
||||
"model": model,
|
||||
"input": {"media": items},
|
||||
"parameters": {"resolution": resolution, "duration": duration},
|
||||
}
|
||||
if prompt:
|
||||
payload["input"]["prompt"] = prompt
|
||||
log.info("R2V 提交:%s · %s · %d 个参考", model, resolution, len(items))
|
||||
return _submit(payload, key)
|
||||
|
||||
|
||||
# ── ③ 轮询 ──────────────────────────────────────────────────
|
||||
def poll(task_id: str, *, interval: int = 10, timeout: int = 900,
|
||||
key: str = "", progress_cb=None) -> str:
|
||||
"""轮询直到完成 → 返回视频 URL
|
||||
|
||||
兼容两种结果结构:
|
||||
output.video_url (KF2V / R2V 新结构)
|
||||
output.results.video_url (wan2.2-s2v 旧结构)
|
||||
"""
|
||||
key = key or api_key()
|
||||
headers = {"Authorization": f"Bearer {key}", "X-DashScope-OssResourceResolve": "enable"}
|
||||
start = time.time()
|
||||
while time.time() - start < timeout:
|
||||
r = httpx.get(TASK_API.format(task_id=task_id), headers=headers,
|
||||
timeout=40, trust_env=False)
|
||||
r.raise_for_status()
|
||||
out = (r.json() or {}).get("output") or {}
|
||||
st = out.get("task_status")
|
||||
el = int(time.time() - start)
|
||||
|
||||
if st == "SUCCEEDED":
|
||||
url = (out.get("video_url")
|
||||
or (out.get("results") or {}).get("video_url")
|
||||
or ((out.get("results") or {}).get("videos") or [{}])[0].get("url"))
|
||||
if not url:
|
||||
raise RuntimeError(f"任务成功但没有视频 URL:{json.dumps(out, ensure_ascii=False)[:300]}")
|
||||
if progress_cb:
|
||||
progress_cb(f"✅ 生成完成({el}s)")
|
||||
return url
|
||||
if st in ("FAILED", "CANCELED", "UNKNOWN"):
|
||||
code = out.get("code") or "?"
|
||||
msg = out.get("message") or "?"
|
||||
raise RuntimeError(f"百炼任务失败 [{code}]: {msg}")
|
||||
|
||||
if progress_cb:
|
||||
progress_cb(f"⏳ 百炼生成中… {el}s({st})")
|
||||
time.sleep(interval)
|
||||
raise TimeoutError(f"百炼任务超时(>{timeout}s)")
|
||||
|
||||
|
||||
# ── ④ 下载 ──────────────────────────────────────────────────
|
||||
def download(url: str, dest: str) -> str:
|
||||
Path(dest).parent.mkdir(parents=True, exist_ok=True)
|
||||
with httpx.stream("GET", url, timeout=300, trust_env=False,
|
||||
follow_redirects=True) as r:
|
||||
r.raise_for_status()
|
||||
with open(dest, "wb") as f:
|
||||
for chunk in r.iter_bytes(1 << 16):
|
||||
f.write(chunk)
|
||||
return dest
|
||||
|
||||
|
||||
# ── 一步到位 ────────────────────────────────────────────────
|
||||
def generate_kf2v(first_frame: str, last_frame: str, dest: str, prompt: str = "",
|
||||
*, model: str = "wan2.2-kf2v-flash", resolution: str = "480P",
|
||||
progress_cb=None) -> str:
|
||||
"""首尾帧一步到位(提交 → 轮询 → 下载)
|
||||
|
||||
⚠️ duration 固定 5 秒(官方规格),所以这里不提供 duration 参数。
|
||||
"""
|
||||
tid = submit_kf2v(first_frame, last_frame, prompt, model=model, resolution=resolution)
|
||||
if progress_cb:
|
||||
progress_cb(f"📤 已提交(task {tid})")
|
||||
url = poll(tid, progress_cb=progress_cb)
|
||||
download(url, dest)
|
||||
return dest
|
||||
|
||||
|
||||
def generate_r2v(media: list[dict], dest: str, prompt: str = "",
|
||||
*, model: str = "wan2.6-r2v-flash", resolution: str = "720P",
|
||||
duration: int = 5, progress_cb=None) -> str:
|
||||
tid = submit_r2v(media, prompt, model=model, resolution=resolution, duration=duration)
|
||||
if progress_cb:
|
||||
progress_cb(f"📤 已提交(task {tid})")
|
||||
url = poll(tid, progress_cb=progress_cb)
|
||||
download(url, dest)
|
||||
return dest
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════
|
||||
# LivePortrait(灵动人像)—— 数字人播报,¥0.02/秒
|
||||
#
|
||||
# 人物肖像图 + 语音音频 → 口型同步的播报视频
|
||||
# ★ 输入正好是我们的现有资产:角色定妆照 + TTS 配音
|
||||
# ⚠️ 只适合「口播/播报」类镜头(talking_head),有动作的镜头不适用
|
||||
# ══════════════════════════════════════════════════════════
|
||||
LIVEPORTRAIT_MODEL = "liveportrait"
|
||||
|
||||
|
||||
def submit_liveportrait(image: str, audio: str, *, template_id: str = "calm",
|
||||
eye_move_freq: float | None = None,
|
||||
model: str = LIVEPORTRAIT_MODEL, key: str = "") -> str:
|
||||
"""数字人播报任务提交
|
||||
|
||||
image : 人物肖像图(本地路径或 URL)—— 建议用角色定妆照
|
||||
audio : 驱动音频(本地路径或 URL)—— TTS 配音;1 秒 ~ 3 分钟
|
||||
template_id : normal(中等幅度)| calm(小幅度·推荐播报)| active(大幅度·演唱)
|
||||
"""
|
||||
key = key or api_key()
|
||||
if not key:
|
||||
raise RuntimeError("未配置百炼 key")
|
||||
if not (image and audio):
|
||||
raise ValueError("liveportrait 必须同时给 image 和 audio")
|
||||
inp = {
|
||||
"image_url": _as_url(image, model, key),
|
||||
"audio_url": _as_url(audio, model, key),
|
||||
}
|
||||
params: dict = {"template_id": template_id if template_id in
|
||||
("normal", "calm", "active") else "calm"}
|
||||
if eye_move_freq is not None:
|
||||
params["eye_move_freq"] = float(eye_move_freq)
|
||||
log.info("LivePortrait 提交:模板=%s", params["template_id"])
|
||||
# 与 KF2V 同端点(官方文档:image2video/video-synthesis)
|
||||
return _submit({"model": model, "input": inp, "parameters": params},
|
||||
key, SUBMIT_API_KF2V)
|
||||
|
||||
|
||||
def generate_liveportrait(image: str, audio: str, dest: str, *,
|
||||
template_id: str = "calm", progress_cb=None) -> str:
|
||||
"""数字人播报一步到位(提交 → 轮询 → 下载)
|
||||
|
||||
⚠️ 输出时长由**音频时长**决定(不是固定 5 秒)
|
||||
"""
|
||||
tid = submit_liveportrait(image, audio, template_id=template_id)
|
||||
if progress_cb:
|
||||
progress_cb(f"📤 已提交(task {tid})")
|
||||
url = poll(tid, progress_cb=progress_cb)
|
||||
download(url, dest)
|
||||
return dest
|
||||
|
||||
|
||||
def estimate_cost(seconds: float, model: str = "wan2.2-kf2v-flash") -> float:
|
||||
if model == LIVEPORTRAIT_MODEL:
|
||||
return round(seconds * 0.02, 3)
|
||||
rate = 0.1 if "kf2v" in model else 0.3
|
||||
return round(seconds * rate, 2)
|
||||
@@ -0,0 +1,604 @@
|
||||
"""
|
||||
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
|
||||
Reference in New Issue
Block a user