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,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)
|
||||
Reference in New Issue
Block a user