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,237 @@
"""
AVE 05_material_producer/kling — 可灵 Kling AI 视频生成
功能:
文生视频、图生视频,对接 Kling API(官方或阿里云百炼)
API 文档:
https://klingai.com/document-api/apiReference/overview
认证: JWT (HS256), AccessKey + SecretKey → Bearer Token
用法:
from kling import text2video
path = text2video("一只橘猫在窗台上晒太阳", api_key="xxx", secret_key="xxx")
"""
import sys, os; sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import hashlib, json, os, time
from pathlib import Path
import jwt
import httpx
from lib.logger import get_logger
logger = get_logger("kling")
# ── 缓存 ──
CACHE_DIR = Path(os.environ.get("AVE_CACHE_DIR",
str(Path.home() / "workbuddy-agent-os/agent-local/tools/ave/cache/kling")))
CACHE_DIR.mkdir(parents=True, exist_ok=True)
# ── API 配置 ──
# 官方 Kling API (中国区)
KLING_BASE = "https://api-beijing.klingai.com"
# 也可通过阿里云百炼调用 (需开通服务)
DASHSCOPE_BASE = "https://dashscope.aliyuncs.com"
# ── 模型与成本 (最低→最高) ──
MODELS = {
"turbo": {"id": "kling-v2.5-turbo", "cost_per_s": 0.04, "desc": "最快, 最便宜"},
"std": {"id": "kling-v2.6-std", "cost_per_s": 0.05, "desc": "快速, 含音频"},
"pro": {"id": "kling-v2.6-pro", "cost_per_s": 0.07, "desc": "最佳质量, 含音频+运动控制"},
"o1": {"id": "kling-video-o1", "cost_per_s": 0.08, "desc": "统一多模态"},
}
def text2video(
prompt: str,
output_path: str = "",
model: str = "turbo",
duration: int = 5,
aspect_ratio: str = "9:16",
access_key: str = "",
secret_key: str = "",
use_dashscope: bool = False,
dashscope_api_key: str = "",
) -> str:
"""
文生视频
参数:
prompt: 提示词
output_path: 输出视频路径 (默认自动生成)
model: 模型名 (turbo/std/pro/o1)
duration: 视频时长 (5或10秒)
aspect_ratio: 比例 (9:16竖屏 / 16:9横屏 / 1:1)
access_key: Kling Access Key
secret_key: Kling Secret Key
use_dashscope: 是否走阿里云百炼 (替代直接调用 Kling API)
dashscope_api_key: 阿里云百炼 API Key
返回:
本地视频文件路径
"""
if use_dashscope:
return _text2video_dashscope(prompt, output_path, model, duration, aspect_ratio, dashscope_api_key)
else:
return _text2video_kling(prompt, output_path, model, duration, aspect_ratio, access_key, secret_key)
def _text2video_kling(prompt, output_path, model, duration, aspect_ratio, access_key, secret_key):
"""通过 Kling 官方 API 生成"""
if not output_path:
output_path = str(CACHE_DIR / f"kling_{hashlib.md5(prompt.encode()).hexdigest()[:12]}.mp4")
# 缓存检查
if os.path.exists(output_path):
logger.info(f"缓存命中: {output_path}")
return output_path
model_id = MODELS.get(model, MODELS["turbo"])["id"]
token = _generate_jwt(access_key, secret_key)
headers = {
"Authorization": f"Bearer {token}",
"Content-Type": "application/json",
}
payload = {
"model": model_id,
"prompt": prompt,
"duration": duration,
"aspect_ratio": aspect_ratio,
}
logger.info(f"Kling 文生视频: model={model_id}, prompt={prompt[:40]}..., duration={duration}s")
resp = httpx.post(f"{KLING_BASE}/v1/videos/text2video", headers=headers, json=payload, timeout=30, trust_env=False)
resp.raise_for_status()
data = resp.json()
task_id = data.get("data", {}).get("task_id", "")
if not task_id:
raise RuntimeError(f"Kling 提交失败: {data}")
logger.info(f" 任务提交成功: {task_id}")
logger.info(f" 轮询等待结果... (预计30-60s)")
# 轮询
for i in range(60):
time.sleep(5)
q = httpx.get(f"{KLING_BASE}/v1/videos/{task_id}", headers=headers, timeout=15, trust_env=False)
q.raise_for_status()
status_data = q.json().get("data", {})
task_status = status_data.get("task_status", "")
if task_status == "succeed":
video_url = status_data.get("video", {}).get("url", "")
if video_url:
logger.info(f" 生成成功! 下载中...")
_download(video_url, output_path)
logger.info(f" ✅ 已保存: {output_path}")
return output_path
elif task_status in ("failed",):
raise RuntimeError(f"Kling 生成失败: {status_data.get('fail_reason', '未知错误')}")
logger.debug(f" [{i*5}s] 状态: {task_status}")
raise TimeoutError("Kling 生成超时")
def _text2video_dashscope(prompt, output_path, model, duration, aspect_ratio, api_key):
"""通过阿里云百炼调用 Kling"""
if not output_path:
output_path = str(CACHE_DIR / f"dashscope_{hashlib.md5(prompt.encode()).hexdigest()[:12]}.mp4")
if os.path.exists(output_path):
logger.info(f"缓存命中: {output_path}")
return output_path
model_map = {"turbo": "kling/kling-v3-video-generation", "pro": "kling/kling-v3-omni-video-generation"}
model_id = model_map.get(model, model_map["turbo"])
size_map = {"9:16": "1080*1920", "16:9": "1920*1080", "1:1": "1080*1080"}
size = size_map.get(aspect_ratio, "1080*1920")
headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
"X-DashScope-Async": "enable",
}
payload = {
"model": model_id,
"input": {"prompt": prompt, "duration": duration},
"parameters": {"size": size, "n": 1},
}
resp = httpx.post(f"{DASHSCOPE_BASE}/api/v1/services/aigc/video-generation/video-synthesis",
headers=headers, json=payload, timeout=30, trust_env=False)
resp.raise_for_status()
task_id = resp.json()["output"]["task_id"]
# 轮询
for i in range(40):
time.sleep(15)
q = httpx.get(f"{DASHSCOPE_BASE}/api/v1/tasks/{task_id}",
headers={"Authorization": f"Bearer {api_key}"}, timeout=15, trust_env=False)
status = q.json().get("output", {}).get("task_status", "")
if status == "SUCCEEDED":
out = q.json().get("output", {})
video_url = out.get("video_url", "")
if not video_url:
# fallback: results array
results = out.get("results", [])
if isinstance(results, list) and len(results) > 0:
video_url = results[0].get("video_url", "")
elif isinstance(results, dict):
video_url = results.get("video_url", "")
if not video_url:
raise RuntimeError(f"百炼 Kling 返回成功但无 video_url: {q.json()}")
_download(video_url, output_path)
return output_path
elif status in ("FAILED",):
raise RuntimeError(f"百炼 Kling 失败: {q.json().get('output',{}).get('message','')}")
raise TimeoutError("百炼 Kling 超时")
def _generate_jwt(access_key: str, secret_key: str) -> str:
"""生成 Kling API 的 JWT Bearer Token (HS256)"""
now = int(time.time())
payload = {
"iss": access_key,
"exp": now + 1800, # 30分钟
"nbf": now - 5,
}
return jwt.encode(payload, secret_key, algorithm="HS256")
def _download(url: str, output_path: str):
"""下载文件"""
os.makedirs(os.path.dirname(output_path) or ".", exist_ok=True)
with httpx.stream("GET", url, timeout=600) as resp:
resp.raise_for_status()
with open(output_path, "wb") as f:
for chunk in resp.iter_bytes(chunk_size=8192):
f.write(chunk)
# ── CLI 测试 ──
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser(description="Kling 文生视频测试")
parser.add_argument("--prompt", default="一只橘猫在窗台上晒太阳")
parser.add_argument("--model", default="turbo", choices=list(MODELS.keys()))
parser.add_argument("--duration", type=int, default=5)
parser.add_argument("--output", default="")
parser.add_argument("--access-key", default=os.environ.get("KLING_ACCESS_KEY", ""))
parser.add_argument("--secret-key", default=os.environ.get("KLING_SECRET_KEY", ""))
args = parser.parse_args()
path = text2video(
prompt=args.prompt,
output_path=args.output,
model=args.model,
duration=args.duration,
access_key=args.access_key,
secret_key=args.secret_key,
)
print(f"\n✅ 视频: {path}")
@@ -0,0 +1,5 @@
"""AVE 素材搜索模块 — Pexels"""
from .search import search_videos
__all__ = ["search_videos"]
@@ -0,0 +1,337 @@
"""
AVE 05_material_producer/pexels — Pexels 素材搜索 v1.1
API 文档: https://www.pexels.com/api/documentation/
版本: v1.1 | 更新: 2026-05-06
改进:
- 中文→英文关键词自动映射 (70+ 常用词)
- 多关键词回落搜索 (最多 3 轮)
- portrait/landscape 自适应筛选
- 优先选取竖屏高质量视频
用法:
search_videos("sunset beach", count=3, api_key="xxx")
→ 返回下载后的本地文件路径列表
回落策略:
1. 精确搜索 (按 query)
2. 简化关键词搜索 (去掉形容词/场景词)
3. 核心词搜索 (保留名词/动词)
4. 返回空列表 (由 caller 用 fallback 兜底)
"""
import sys, os; sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import hashlib
import os
import time
from pathlib import Path
from typing import Optional
import httpx
from lib.logger import get_logger
logger = get_logger("pexels")
PEXELS_BASE = "https://api.pexels.com/videos"
# ⚠️ Pexels 强制要求 User-Agent:缺失会直接 403(error code 1010)
PEXELS_UA = "AgentOS-AVE/1.0 (macOS; +https://github.com/neowoodland-art/mac-agent-os)"
CACHE_DIR = (
Path(os.environ.get("AVE_CACHE_DIR",
str(Path.home() / "workbuddy-agent-os/agent-local/tools/ave/cache/materials")))
)
# ── 中文→英文关键词映射 ────────────────────────────────────
ZH_TO_EN = {
"猫": "cat", "狗狗": "dog", "狗": "dog", "猫咪": "cat",
"日出": "sunrise", "日落": "sunset",
"大海": "ocean", "沙滩": "beach", "海滩": "beach",
"森林": "forest", "树木": "trees", "绿叶": "green leaves",
"河流": "river", "湖泊": "lake", "山川": "mountains",
"星空": "night sky stars", "银河": "milky way",
"城市": "city", "夜景": "city night", "街道": "city street",
"雪山": "snow mountain", "云海": "clouds mountain",
"瀑布": "waterfall", "彩虹": "rainbow",
"花海": "flower field", "樱花": "cherry blossoms",
"草原": "meadow", "草地": "grass field",
"海浪": "ocean waves", "雨滴": "rain drops",
"美食": "food cooking", "咖啡": "coffee cafe",
"书本": "books reading", "写字": "writing pen",
"瑜伽": "yoga meditation", "冥想": "meditation calm",
"跑步": "running jogging", "健身": "fitness gym",
"舞蹈": "dancing", "弹琴": "piano playing",
"茶道": "tea ceremony", "书法": "calligraphy",
"太极": "tai chi", "武术": "martial arts",
"烟花": "fireworks", "灯笼": "lanterns",
"古镇": "traditional town", "寺庙": "temple",
"海岛": "tropical island", "椰子树": "palm tree beach",
"汽车": "driving car", "火车": "train railway",
"飞机": "airplane sky", "轮船": "sailing ship",
"建筑": "architecture building", "桥梁": "bridge",
"天空": "sky clouds", "白云": "white clouds",
"下雨": "rain falling", "下雪": "snow falling",
"春天": "spring nature", "夏天": "summer sunny",
"秋天": "autumn leaves", "冬天": "winter snow",
"工作": "office work", "开会": "meeting business",
"微笑": "smiling happy", "牵手": "couple holding hands",
"小孩": "child playing", "老人": "elderly portrait",
"情侣": "couple romantic", "家庭": "family together",
"宠物": "pet dog cat", "小鸟": "bird flying",
"金鱼": "fish aquarium", "蝴蝶": "butterfly",
"月亮": "moon night", "阳光": "sunlight",
}
def _translate_query(query: str) -> str:
"""将中文关键词翻译为英文(Pexels 英文搜索更精准)
策略:① 静态词典(快、零成本)→ ② 词典未完全覆盖时用 LLM 翻译(DeepSeek,成本极低)
"""
q = (query or "").strip()
if not q:
return ""
has_cjk = any("\u4e00" <= c <= "\u9fff" for c in q)
if not has_cjk:
return q # 已是英文,直接用
# ① 静态词典替换
result = q
for zh, en in ZH_TO_EN.items():
if zh in result:
result = result.replace(zh, en)
if not any("\u4e00" <= c <= "\u9fff" for c in result):
return result.strip() # 词典已完全覆盖
# ② 词典不够 → LLM 翻译(任意中文都能翻)
try:
import sys as _sys
from pathlib import Path as _P
_scripts = _P(__file__).resolve().parents[2]
if str(_scripts) not in _sys.path:
_sys.path.insert(0, str(_scripts))
from directors.llm import DirectorLLM
llm = DirectorLLM(timeout=25)
if llm.available:
en = llm.chat(
f"把这个中文搜索关键词翻译成最简短的英文(只输出英文,2~4 个词,不要解释):{q}",
temperature=0.2, max_tokens=30)
en = (en or "").strip().strip('"\'').replace("\n", " ").strip()
if en and not any("\u4e00" <= c <= "\u9fff" for c in en):
logger.info(f" 🌐 LLM 翻译: {q!r} → {en!r}")
return en
except Exception as e:
logger.debug(f"LLM 翻译失败(回退词典结果): {e}")
logger.warning(f" ⚠️ 关键词可能仍是中文({result!r}),Pexels 命中率会偏低")
return result.strip()
def search_videos(
query: str,
count: int = 3,
api_key: str = "",
min_duration: int = 5,
orientation: str = "portrait",
) -> list[dict]:
"""
搜索并下载 Pexels 视频素材 v1.1
改进:
- 自动将中文关键词翻译为英文再搜索
- 3轮回落搜索策略,提升命中率
- portrait 模式优先竖屏高质量视频
参数:
query: 搜索关键词 (支持中文,自动翻译)
count: 需要的视频数量
api_key: Pexels API Key
min_duration: 最短视频时长(秒)
orientation: portrait(竖屏) | landscape(横屏) | square
返回:
[{"path": "/local/file.mp4", "duration": 15.0, "width": 1080, "height": 1920, "url": "原始链接"}, ...]
"""
if not api_key:
logger.error("Pexels API Key 未配置")
return []
CACHE_DIR.mkdir(parents=True, exist_ok=True)
# 翻译关键词
en_query = _translate_query(query)
logger.info(f"搜索关键词: {query!r} → {en_query!r}")
# 多轮回落搜索
keywords_to_try = _generate_fallback_keywords(en_query)[:3] # 最多 3 轮(避免长时间卡住)
for kw in keywords_to_try:
videos = _search_api(kw, count * 2, api_key, orientation)
if videos:
logger.info(f" ✅ 命中关键词: {kw} ({len(videos)} 个候选)")
break
logger.warning(f" ⏳ 关键词无结果,回落: {kw}")
else:
logger.warning(f"Pexels 所有关键词均无结果: {query}")
return []
# 过滤 + 选取(先选出候选,再**并行下载** —— 串行会慢 N 倍,Pexels 大文件尤其明显)
picks = []
for vid in videos:
if len(picks) >= count:
break
duration = vid.get("duration", 0)
if duration < min_duration:
continue
# 取最合适的视频文件
video_files = vid.get("video_files", [])
best = _pick_best_file(video_files, orientation)
if not best:
continue
picks.append((vid, best, duration))
results = []
if picks:
from concurrent.futures import ThreadPoolExecutor, as_completed
with ThreadPoolExecutor(max_workers=min(4, len(picks))) as ex:
futs = {ex.submit(_download, p[1]["link"], api_key, kw): p for p in picks}
for fut in as_completed(futs):
vid, best, duration = futs[fut]
try:
local_path = fut.result()
except Exception as e:
logger.warning("下载异常: %s", e)
local_path = None
if local_path:
results.append({
"path": local_path,
"duration": duration,
"width": best.get("width", 0),
"height": best.get("height", 0),
"url": vid.get("url", ""),
})
logger.info(f"Pexels 下载完成: {len(results)}/{count} 个 (原始:{query})")
return results
def _generate_fallback_keywords(query: str) -> list[str]:
"""生成回落关键词列表,最多3轮"""
keywords = [query]
# 第1轮回落: 去掉形容词和场景词,保留核心名词
# 例如 "sunset beach calm" → "beach sunset" / "beach"
words = query.split()
# 去掉常见形容词
adj_strip = [w for w in words if w.lower() not in (
"calm", "peaceful", "beautiful", "lovely", "nice", "serene",
"tranquil", "golden", "warm", "cool", "soft", "bright",
"dramatic", "mystic", "dreamy", "ethereal", "vibrant",
)]
if len(adj_strip) >= 2 and len(keywords) < 2:
keywords.append(" ".join(adj_strip))
elif len(adj_strip) >= 1 and len(keywords) < 2:
keywords.append(adj_strip[0])
# 第2轮回落: 只保留核心名词
core = [w for w in words if w.lower() not in (
"calm", "peaceful", "beautiful", "lovely", "nice", "serene",
"tranquil", "golden", "warm", "cool", "soft", "bright",
"dramatic", "mystic", "dreamy", "ethereal", "vibrant",
"sky", "view", "scene", "footage", "video", "clip",
)]
if core and len(keywords) < 3:
keywords.append(core[0])
return keywords[:3]
def _search_api(
query: str, per_page: int, api_key: str, orientation: str
) -> list[dict]:
"""调用 Pexels 搜索 API"""
params = {
"query": query,
"per_page": min(per_page, 80),
"orientation": orientation,
"size": "large",
}
try:
resp = httpx.get(
f"{PEXELS_BASE}/search",
params=params,
headers={"Authorization": api_key, "User-Agent": PEXELS_UA},
timeout=15,
# 绕过系统代理(SOCKS5 可能导致 SSL 连接失败)
trust_env=False,
)
resp.raise_for_status()
data = resp.json()
return data.get("videos", [])
except httpx.HTTPStatusError as e:
if e.response.status_code == 429:
logger.warning("Pexels 速率限制,等待 1 秒重试")
time.sleep(1)
return _search_api(query, per_page, api_key, orientation)
logger.error(f"Pexels 搜索失败 [{e.response.status_code}]: {query}")
return []
except Exception as e:
logger.error(f"Pexels 搜索异常: {e}")
return []
def _pick_best_file(video_files: list[dict], orientation: str) -> Optional[dict]:
"""选择最佳视频文件 v1.1
优先策略:
portrait: 竖屏(9:16) > 接近竖屏 > 横屏中最高质量
竖屏判定: height > width 且 height >= 720
"""
if not video_files:
return None
def quality_score(f):
w = f.get("width", 0)
h = f.get("height", 0)
file_size = f.get("file_size", 0)
fps = f.get("fps", 30)
# 竖屏优先 (height > width 且 h >= 720)
if orientation == "portrait" and h > w and h >= 720:
# **优选 ~1920 高(1080x1920 标准竖屏)**:够用且下载快;
# 4K(2160x3840) 文件 13MB+ 下载慢,扣分
return 100000 - abs(h - 1920) * 10 + min(fps, 30) - (file_size // 1024 // 1024) // 10
elif orientation == "landscape" and w > h and w >= 1280:
return 100000 - abs(w - 1920) * 10 + min(fps, 30)
elif orientation == "square" and abs(w - h) < max(w, h) * 0.2:
return 100000 - abs(min(w, h) - 1080) * 10
else:
# 无匹配时选最接近 1080p 的
return 50000 - abs(max(w, h) - 1920) * 5
video_files.sort(key=quality_score, reverse=True)
chosen = video_files[0]
logger.debug(f" 选片: {chosen.get('width')}x{chosen.get('height')} @ {chosen.get('fps')}fps")
return chosen
def _download(url: str, api_key: str, query: str) -> Optional[str]:
"""下载视频文件到本地缓存"""
# 缓存键: URL 的 hash
cache_key = hashlib.md5(url.encode()).hexdigest()[:16]
ext = ".mp4"
local_path = str(CACHE_DIR / f"{cache_key}{ext}")
if os.path.exists(local_path):
logger.debug(f"缓存命中: {local_path}")
return local_path
try:
resp = httpx.get(url, headers={"Authorization": api_key, "User-Agent": PEXELS_UA}, timeout=60, follow_redirects=True, trust_env=False)
resp.raise_for_status()
with open(local_path, "wb") as f:
f.write(resp.content)
logger.info(f"下载完成: {local_path} ({len(resp.content)//1024}KB)")
return local_path
except Exception as e:
logger.warning(f"下载失败: {e}")
return None
@@ -0,0 +1,6 @@
"""
AVE 05_material_producer/wan2_2 — 阿里云 Wan2.2-S2V 数字人
"""
from .wan2_2 import generate_digital_human, generate_opening, generate_closing
__all__ = ["generate_digital_human", "generate_opening", "generate_closing"]
@@ -0,0 +1,25 @@
"""
AVE 数字人配置
avatar 配置存储在 agent-local, 不提交到 Git
"""
import os
from pathlib import Path
LOCAL_DIR = Path(os.environ.get("AVE_LOCAL_DIR",
str(Path.home() / "workbuddy-agent-os/agent-local")))
# 头像图片: 用户通过微信发送到这里
AVATAR_DIR = LOCAL_DIR / "tools" / "ave" / "avatar"
AVATAR_DIR.mkdir(parents=True, exist_ok=True)
# 数字人视频缓存
DIGITAL_HUMAN_CACHE = LOCAL_DIR / "tools" / "ave" / "cache" / "wan2_2"
DIGITAL_HUMAN_CACHE.mkdir(parents=True, exist_ok=True)
# 片尾默认文案
CLOSING_TEXT = "关注我,一起聆听世界"
# 默认参数
DEFAULT_RESOLUTION = "480P"
MAX_AUDIO_SEC = 20
@@ -0,0 +1,340 @@
"""
AVE 05_material_producer/wan2_2 — 阿里云 Wan2.2-S2V 数字人
功能:
基于单张图片 + 音频 → 生成对口型数字人视频
API 文档:
https://help.aliyun.com/zh/model-studio/wan-s2v-api
定价: 480P ¥0.5/s, 720P ¥0.9/s (免费100s)
用法:
from wan2_2 import generate_digital_human
video_path = generate_digital_human(
image_path="/path/to/avatar.jpg",
audio_path="/tmp/voice.wav",
text="关注我,一起聆听世界",
api_key="sk-xxx",
)
"""
import sys, os; sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import hashlib
import json
import os
import time
from pathlib import Path
from typing import Optional
import httpx
from lib.logger import get_logger
logger = get_logger("wan2_2")
# ── 常量 ──
CACHE_DIR = Path(os.environ.get("AVE_CACHE_DIR",
str(Path.home() / "workbuddy-agent-os/agent-local/tools/ave/cache/wan2_2")))
CACHE_DIR.mkdir(parents=True, exist_ok=True)
TASK_API = "https://dashscope.aliyuncs.com/api/v1/services/aigc/image2video/video-synthesis/"
TASK_POLL_API = "https://dashscope.aliyuncs.com/api/v1/tasks/{task_id}"
UPLOAD_API = "https://dashscope.aliyuncs.com/api/v1/uploads"
# Wan2.2 限制: 音频 ≤20s, <15MB
MAX_AUDIO_SEC = 20
MAX_AUDIO_BYTES = 15 * 1024 * 1024
# ═══════════════════════════════════════════════════════════
# 公共 API
# ═══════════════════════════════════════════════════════════
def generate_digital_human(
image_path: str,
audio_path: str,
api_key: str,
output_path: str = "digital_human.mp4",
resolution: str = "480P",
model: str = "wan2.2-s2v",
force: bool = False,
text: str = "",
) -> str:
"""
生成数字人视频
参数:
image_path: 人物头像图片路径 (jpg/png)
audio_path: 人声音频路径 (wav/mp3, ≤20s, <15MB)
api_key: 阿里云百炼 API Key
output_path: 输出视频路径
resolution: 480P 或 720P
model: 模型名
force: 是否忽略缓存强制重新生成
text: 文案文本 (用于缓存键, 避免不同文案用同一音频名导致误命中)
返回:
数字人视频文件路径
"""
# 缓存检查 (相同图片+文案+分辨率)
cache_key = _make_cache_key(image_path, text, resolution)
cached = CACHE_DIR / f"{cache_key}.mp4"
if cached.exists() and not force:
logger.info(f"缓存命中: {cached}")
# 复制到目标路径
import shutil
shutil.copy2(str(cached), output_path)
return output_path
if not os.path.exists(image_path):
raise FileNotFoundError(f"头像图片不存在: {image_path}")
if not os.path.exists(audio_path):
raise FileNotFoundError(f"音频文件不存在: {audio_path}")
_validate_audio(audio_path)
# 1. 上传图片到临时存储
logger.info("上传图片...")
image_url = _upload_file(api_key, model, image_path)
# 2. 上传音频到临时存储
logger.info("上传音频...")
audio_url = _upload_file(api_key, model, audio_path)
# 3. 提交 Wan2.2 任务
logger.info(f"提交 Wan2.2 任务 (resolution={resolution})...")
task_id = _submit_task(api_key, model, image_url, audio_url, resolution)
logger.info(f" task_id: {task_id}")
# 4. 轮询等待完成 (5-10分钟)
logger.info("等待生成 (约5-10分钟)...")
result_url = _poll_task(api_key, task_id)
logger.info(f" 生成完成: {result_url}")
# 5. 下载结果
os.makedirs(os.path.dirname(output_path) or ".", exist_ok=True)
_download_result(result_url, output_path)
# 缓存
import shutil
shutil.copy2(output_path, str(cached))
size_mb = os.path.getsize(output_path) / 1024 / 1024
logger.info(f"✅ 数字人视频: {output_path} ({size_mb:.1f}MB)")
return output_path
def generate_opening(
image_path: str,
api_key: str,
voice_synthesizer: callable,
output_path: str = "opening.mp4",
text: str = "今天我要给你讲一个有趣的故事。",
resolution: str = "480P",
force: bool = False,
) -> str:
"""
生成片头数字人 (3-5s)
- 先调用 voice_synthesizer 合成片头文案的音频
- 再调用 Wan2.2 生成数字人视频
- 结果缓存,重复使用
"""
cache_key = _make_cache_key(image_path, text, resolution)
cached = CACHE_DIR / f"opening_{cache_key}.mp4"
if cached.exists() and not force:
import shutil
shutil.copy2(str(cached), output_path)
logger.info(f"片头缓存命中: {output_path}")
return output_path
audio_path = f"/tmp/ave_opening_{cache_key}.wav"
voice_synthesizer(text, audio_path)
result = generate_digital_human(
image_path, audio_path, api_key, output_path,
resolution=resolution, force=force,
)
return result
def generate_closing(
image_path: str,
api_key: str,
voice_synthesizer: callable,
output_path: str = "closing.mp4",
text: str = "关注我,一起聆听世界",
resolution: str = "480P",
force: bool = False,
) -> str:
"""
生成片尾数字人 (3-5s, 固定文案)
片尾文案固定,结果可永久缓存。
- 首次: 生成音频 + 提交 Wan2.2 → 缓存
- 后续: 直接加载缓存,0 成本 0 等待
"""
return generate_opening(
image_path, api_key, voice_synthesizer, output_path,
text=text, resolution=resolution, force=force,
)
# ═══════════════════════════════════════════════════════════
# 内部方法
# ═══════════════════════════════════════════════════════════
def _make_cache_key(*args) -> str:
"""生成缓存键"""
raw = "|".join(str(a) for a in args)
return hashlib.md5(raw.encode()).hexdigest()[:16]
def _validate_audio(audio_path: str):
"""验证音频符合 Wan2.2 要求"""
# 时长检查
from pathlib import Path as _P
import sys as _s
_scripts = _P(__file__).resolve().parents[2]
if str(_scripts) not in _s.path:
_s.path.insert(0, str(_scripts))
from lib.ffmpeg import probe_info as _pi
_info = _pi(audio_path)
duration = _info.get("duration") or 0
file_size = _info.get("size_bytes") or 0
if duration > MAX_AUDIO_SEC:
raise ValueError(f"音频时长 {duration:.1f}s 超过限制 {MAX_AUDIO_SEC}s")
if file_size > MAX_AUDIO_BYTES:
raise ValueError(f"音频大小 {file_size/1024/1024:.1f}MB 超过限制 15MB")
logger.debug(f"音频验证通过: {duration:.1f}s, {file_size/1024:.0f}KB")
def _upload_file(api_key: str, model: str, file_path: str) -> str:
"""上传文件到阿里云百炼临时存储,返回 oss:// URL
流程:
1. GET /api/v1/uploads?action=getPolicy → 获取上传凭证
2. POST OSS → 上传文件 (multipart)
3. 拼接 oss://upload_dir/filename
"""
# 步骤1: 获取上传凭证
headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
}
resp = httpx.get(
UPLOAD_API,
params={"action": "getPolicy", "model": model},
headers=headers,
timeout=15, trust_env=False
)
resp.raise_for_status()
data = resp.json()["data"]
upload_host = data["upload_host"]
upload_dir = data["upload_dir"]
policy = data["policy"]
signature = data["signature"]
oss_key_id = data["oss_access_key_id"]
obj_acl = data["x_oss_object_acl"]
forbid_overwrite = data["x_oss_forbid_overwrite"]
# 步骤2: 上传文件到 OSS
filename = os.path.basename(file_path)
key = f"{upload_dir}/{filename}"
with open(file_path, "rb") as f:
files = {
"OSSAccessKeyId": (None, oss_key_id),
"policy": (None, policy),
"Signature": (None, signature),
"key": (None, key),
"x-oss-object-acl": (None, obj_acl),
"x-oss-forbid-overwrite": (None, forbid_overwrite),
"success_action_status": (None, "200"),
"file": (filename, f, _get_mime(file_path)),
}
upload_resp = httpx.post(upload_host, files=files, timeout=120, trust_env=False)
upload_resp.raise_for_status()
# 步骤3: 返回 oss:// URL
oss_url = f"oss://{key}"
logger.debug(f" OSS URL: {oss_url}")
return oss_url
def _get_mime(path: str) -> str:
ext = os.path.splitext(path)[1].lower()
return {
".jpg": "image/jpeg", ".jpeg": "image/jpeg",
".png": "image/png", ".bmp": "image/bmp",
".webp": "image/webp",
".wav": "audio/wav", ".mp3": "audio/mpeg",
}.get(ext, "application/octet-stream")
def _submit_task(
api_key: str, model: str, image_url: str, audio_url: str, resolution: str
) -> str:
"""提交 Wan2.2 异步任务"""
headers = {
"Authorization": f"Bearer {api_key}",
"X-DashScope-Async": "enable",
"X-DashScope-OssResourceResolve": "enable",
"Content-Type": "application/json",
}
body = {
"model": model,
"input": {
"image_url": image_url,
"audio_url": audio_url,
},
"parameters": {"resolution": resolution},
}
resp = httpx.post(TASK_API, headers=headers, json=body, timeout=30, trust_env=False)
resp.raise_for_status()
return resp.json()["output"]["task_id"]
def _poll_task(api_key: str, task_id: str, interval: int = 15, timeout: int = 600) -> str:
"""轮询 Wan2.2 任务直到完成"""
headers = {
"Authorization": f"Bearer {api_key}",
"X-DashScope-OssResourceResolve": "enable",
}
start = time.time()
while time.time() - start < timeout:
resp = httpx.get(
TASK_POLL_API.format(task_id=task_id),
headers=headers,
timeout=30, trust_env=False
)
resp.raise_for_status()
data = resp.json()
status = data["output"]["task_status"]
elapsed = int(time.time() - start)
if status == "SUCCEEDED":
video_url = data["output"]["results"]["video_url"]
duration_sec = data.get("usage", {}).get("duration", 0)
logger.info(f" 耗时: {elapsed}s, 视频时长: {duration_sec}s")
return video_url
elif status in ("FAILED", "CANCELED"):
code = data["output"].get("code", "?")
msg = data["output"].get("message", "?")
raise RuntimeError(f"Wan2.2 任务失败 [{code}]: {msg}")
logger.debug(f" 轮询 {elapsed}s... 状态: {status}")
time.sleep(interval)
raise TimeoutError(f"Wan2.2 任务超时 (>{timeout}s)")
def _download_result(url: str, output_path: str):
"""下载 Wan2.2 生成的视频"""
with httpx.stream("GET", url, timeout=600) as resp:
resp.raise_for_status()
with open(output_path, "wb") as f:
for chunk in resp.iter_bytes(chunk_size=8192):
f.write(chunk)