Files
MediaCrawler/mac-agent-os-main/05_tools/09_ave/scripts/capabilities/kling_video.py
T
butubb 2112c1a870
Deploy VitePress site to Pages / build (push) Canceled after 0s
Deploy VitePress site to Pages / Deploy (push) Canceled after 0s
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 带网关原话 / 正常返回)。
2026-10-10 17:12:25 +08:00

605 lines
30 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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