feat(monitor): 抖音 Web 接口客户端 —— 绕开爬虫子进程,直接发 HTTP
Deploy VitePress site to Pages / build (push) Canceled after 0s
Deploy VitePress site to Pages / Deploy (push) Canceled after 0s

移植自 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:
2026-10-10 17:12:25 +08:00
parent 21ff01b894
commit 2112c1a870
1601 changed files with 367425 additions and 0 deletions
@@ -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