"""百炼(DashScope)视频生成 —— 首尾帧 KF2V / 多图参考 R2V 为什么单独一个模块:可灵走 `kling_video.py`(channel=kling), 百炼走这里(channel=dashscope)—— 两家接口完全不同,不能混。 价格对比(`config/video_models.yaml`): wan2.2-kf2v-flash ¥0.1/秒(480P) ← 首尾帧专用,最省 wan2.6-r2v-flash ¥0.3/秒(720P) ← 多图参考(角色+场景+首帧) kling-v3 ¥0.6/秒(720P) ← 现行默认 → 同样 4 镜 5 秒:可灵 ¥12,kf2v ¥2。 调用范式(与 `material_producer/wan2_2/wan2_2.py` 一致,四步): ① 上传图片 → oss:// URL(每张都要) ② POST video-synthesis(X-DashScope-Async: enable) ③ GET /api/v1/tasks/{task_id} 轮询 ④ 下载 video_url 字段(来自千问AI平台 OpenAPI spec,见 docs/pipeline_research/03): KF2V: input.first_frame_url + input.last_frame_url + input.prompt R2V : input.media[] = [{type: first_frame|reference_image|reference_video, url}] + input.prompt """ from __future__ import annotations import base64 import json import logging import os import time from pathlib import Path import httpx import yaml log = logging.getLogger("ave.dashscope_video") # ── 端点 ──────────────────────────────────────────────────── # ⚠️ 两个模型走**不同端点**(据官方 OpenAPI 规格,见 docs/pipeline_research/03): # KF2V(首尾帧) → /services/aigc/image2video/video-synthesis # R2V (多图参考)→ /services/aigc/video-generation/video-synthesis BASE = "https://dashscope.aliyuncs.com" UPLOAD_API = f"{BASE}/api/v1/uploads" SUBMIT_API_KF2V = f"{BASE}/api/v1/services/aigc/image2video/video-synthesis" SUBMIT_API_R2V = f"{BASE}/api/v1/services/aigc/video-generation/video-synthesis" SUBMIT_API = SUBMIT_API_R2V # 兼容旧引用 TASK_API = f"{BASE}/api/v1/tasks/{{task_id}}" # ⚠️ 所有 httpx 调用都要 trust_env=False —— macOS 系统代理会让 # 国内 API 时通时不通(本项目踩过多次,见 docs/参数速查.md) _LOCAL_YAML = Path.home() / "workbuddy-agent-os" / "agent-local" / "tools" / "ave" / "config" / "local.yaml" def api_key() -> str: """读百炼 api_key(agent-local,不进 git)""" env = os.environ.get("DASHSCOPE_API_KEY") if env: return env try: d = yaml.safe_load(_LOCAL_YAML.read_text(encoding="utf-8")) or {} return str((d.get("aliyun") or {}).get("api_key") or "") except Exception as e: log.warning("读 local.yaml 失败: %s", e) return "" def available() -> bool: return bool(api_key()) # ── ① 上传 ────────────────────────────────────────────────── def _mime(p: str) -> str: return {"png": "image/png", "jpg": "image/jpeg", "jpeg": "image/jpeg", "webp": "image/webp", "bmp": "image/bmp", "mp4": "video/mp4", "mp3": "audio/mpeg", "wav": "audio/wav", "m4a": "audio/mp4"}.get( Path(p).suffix.lower().lstrip("."), "application/octet-stream") def upload_file(path: str, model: str, key: str = "") -> str: """上传到百炼临时存储 → oss:// URL 已上传过(同名 + 同模型)会复用,避免重复上传。 """ key = key or api_key() p = Path(path) if not p.exists(): raise FileNotFoundError(f"文件不存在: {path}") r = httpx.get(UPLOAD_API, params={"action": "getPolicy", "model": model}, headers={"Authorization": f"Bearer {key}"}, timeout=20, trust_env=False) r.raise_for_status() d = r.json()["data"] oss_key = f"{d['upload_dir']}/{p.name}" with open(p, "rb") as f: files = { "OSSAccessKeyId": (None, d["oss_access_key_id"]), "policy": (None, d["policy"]), "Signature": (None, d["signature"]), "key": (None, oss_key), "x-oss-object-acl": (None, d["x_oss_object_acl"]), "x-oss-forbid-overwrite": (None, d["x_oss_forbid_overwrite"]), "success_action_status": (None, "200"), "file": (p.name, f, _mime(str(p))), } up = httpx.post(d["upload_host"], files=files, timeout=180, trust_env=False) up.raise_for_status() return f"oss://{oss_key}" def _as_url(v: str, model: str, key: str) -> str: """本地路径 → oss:// ;已是 http(s)/oss:// /data: 则原样返回""" if not v: return "" s = str(v) if s.startswith(("http://", "https://", "oss://", "data:")): return s return upload_file(s, model, key) # ── ② 提交 ────────────────────────────────────────────────── def _submit(payload: dict, key: str, endpoint: str = "") -> str: headers = { "Authorization": f"Bearer {key}", "X-DashScope-Async": "enable", "X-DashScope-OssResourceResolve": "enable", "Content-Type": "application/json", } r = httpx.post(endpoint or SUBMIT_API_R2V, headers=headers, json=payload, timeout=40, trust_env=False) if r.status_code >= 400: raise RuntimeError(f"百炼提交失败 HTTP {r.status_code}: {r.text[:300]}") out = (r.json() or {}).get("output") or {} tid = out.get("task_id") if not tid: raise RuntimeError(f"百炼未返回 task_id: {json.dumps(r.json(), ensure_ascii=False)[:300]}") return tid def submit_kf2v(first_frame: str, last_frame: str, prompt: str = "", *, model: str = "wan2.2-kf2v-flash", resolution: str = "480P", negative_prompt: str = "", prompt_extend: bool = True, seed: int | None = None, template: str = "", key: str = "") -> str: """首尾帧生视频 —— 给「首帧 + 尾帧」,模型在两帧之间插值 ⚠️ 官方规格(docs/pipeline_research/03 §二): · 端点 = /services/aigc/image2video/video-synthesis(**不是** video-generation) · duration **固定 5 秒,不可修改** → 不传该参数 · 输出宽高比跟随 first_frame_url · 用了 template(特效模板)时只需传 first_frame_url """ key = key or api_key() if not key: raise RuntimeError("未配置百炼 key(agent-local/tools/ave/config/local.yaml → aliyun.api_key)") if not last_frame: raise ValueError("KF2V 必须同时给首帧和尾帧") inp = {"first_frame_url": _as_url(first_frame, model, key)} if not template: inp["last_frame_url"] = _as_url(last_frame, model, key) if prompt: inp["prompt"] = prompt if negative_prompt: inp["negative_prompt"] = negative_prompt[:500] params: dict = {"resolution": resolution, "prompt_extend": bool(prompt_extend)} if template: params["template"] = template if seed is not None: params["seed"] = int(seed) log.info("KF2V 提交:%s · %s · 固定5s · %s", model, resolution, "模板" if template else "首尾帧") return _submit({"model": model, "input": inp, "parameters": params}, key, SUBMIT_API_KF2V) def submit_r2v(media: list[dict], prompt: str = "", *, model: str = "wan2.6-r2v-flash", resolution: str = "720P", duration: int = 5, key: str = "") -> str: """多图参考生视频 media: [{"type": "first_frame"|"reference_image"|"reference_video", "url": 本地路径或URL}] 最多 5 个(config 里的 limits.max_reference_images) """ key = key or api_key() if not key: raise RuntimeError("未配置百炼 key") if not media: raise ValueError("R2V 至少要给 1 个 media 元素") if not (prompt or "").strip(): # 官方规格:prompt 为 ★必填(「期望视频内容」)—— 不传会直接失败 raise ValueError("R2V 的 prompt 是必填项(描述期望的视频内容),不能为空") items = [] for m in media[:5]: u = _as_url(m.get("url") or "", model, key) if not u: continue it = {"type": m.get("type") or "reference_image", "url": u} if m.get("reference_voice"): it["reference_voice"] = m["reference_voice"] items.append(it) if not items: raise ValueError("media 里没有可用的 url") payload = { "model": model, "input": {"media": items}, "parameters": {"resolution": resolution, "duration": duration}, } if prompt: payload["input"]["prompt"] = prompt log.info("R2V 提交:%s · %s · %d 个参考", model, resolution, len(items)) return _submit(payload, key) # ── ③ 轮询 ────────────────────────────────────────────────── def poll(task_id: str, *, interval: int = 10, timeout: int = 900, key: str = "", progress_cb=None) -> str: """轮询直到完成 → 返回视频 URL 兼容两种结果结构: output.video_url (KF2V / R2V 新结构) output.results.video_url (wan2.2-s2v 旧结构) """ key = key or api_key() headers = {"Authorization": f"Bearer {key}", "X-DashScope-OssResourceResolve": "enable"} start = time.time() while time.time() - start < timeout: r = httpx.get(TASK_API.format(task_id=task_id), headers=headers, timeout=40, trust_env=False) r.raise_for_status() out = (r.json() or {}).get("output") or {} st = out.get("task_status") el = int(time.time() - start) if st == "SUCCEEDED": url = (out.get("video_url") or (out.get("results") or {}).get("video_url") or ((out.get("results") or {}).get("videos") or [{}])[0].get("url")) if not url: raise RuntimeError(f"任务成功但没有视频 URL:{json.dumps(out, ensure_ascii=False)[:300]}") if progress_cb: progress_cb(f"✅ 生成完成({el}s)") return url if st in ("FAILED", "CANCELED", "UNKNOWN"): code = out.get("code") or "?" msg = out.get("message") or "?" raise RuntimeError(f"百炼任务失败 [{code}]: {msg}") if progress_cb: progress_cb(f"⏳ 百炼生成中… {el}s({st})") time.sleep(interval) raise TimeoutError(f"百炼任务超时(>{timeout}s)") # ── ④ 下载 ────────────────────────────────────────────────── def download(url: str, dest: str) -> str: Path(dest).parent.mkdir(parents=True, exist_ok=True) with httpx.stream("GET", url, timeout=300, trust_env=False, follow_redirects=True) as r: r.raise_for_status() with open(dest, "wb") as f: for chunk in r.iter_bytes(1 << 16): f.write(chunk) return dest # ── 一步到位 ──────────────────────────────────────────────── def generate_kf2v(first_frame: str, last_frame: str, dest: str, prompt: str = "", *, model: str = "wan2.2-kf2v-flash", resolution: str = "480P", progress_cb=None) -> str: """首尾帧一步到位(提交 → 轮询 → 下载) ⚠️ duration 固定 5 秒(官方规格),所以这里不提供 duration 参数。 """ tid = submit_kf2v(first_frame, last_frame, prompt, model=model, resolution=resolution) if progress_cb: progress_cb(f"📤 已提交(task {tid})") url = poll(tid, progress_cb=progress_cb) download(url, dest) return dest def generate_r2v(media: list[dict], dest: str, prompt: str = "", *, model: str = "wan2.6-r2v-flash", resolution: str = "720P", duration: int = 5, progress_cb=None) -> str: tid = submit_r2v(media, prompt, model=model, resolution=resolution, duration=duration) if progress_cb: progress_cb(f"📤 已提交(task {tid})") url = poll(tid, progress_cb=progress_cb) download(url, dest) return dest # ══════════════════════════════════════════════════════════ # LivePortrait(灵动人像)—— 数字人播报,¥0.02/秒 # # 人物肖像图 + 语音音频 → 口型同步的播报视频 # ★ 输入正好是我们的现有资产:角色定妆照 + TTS 配音 # ⚠️ 只适合「口播/播报」类镜头(talking_head),有动作的镜头不适用 # ══════════════════════════════════════════════════════════ LIVEPORTRAIT_MODEL = "liveportrait" def submit_liveportrait(image: str, audio: str, *, template_id: str = "calm", eye_move_freq: float | None = None, model: str = LIVEPORTRAIT_MODEL, key: str = "") -> str: """数字人播报任务提交 image : 人物肖像图(本地路径或 URL)—— 建议用角色定妆照 audio : 驱动音频(本地路径或 URL)—— TTS 配音;1 秒 ~ 3 分钟 template_id : normal(中等幅度)| calm(小幅度·推荐播报)| active(大幅度·演唱) """ key = key or api_key() if not key: raise RuntimeError("未配置百炼 key") if not (image and audio): raise ValueError("liveportrait 必须同时给 image 和 audio") inp = { "image_url": _as_url(image, model, key), "audio_url": _as_url(audio, model, key), } params: dict = {"template_id": template_id if template_id in ("normal", "calm", "active") else "calm"} if eye_move_freq is not None: params["eye_move_freq"] = float(eye_move_freq) log.info("LivePortrait 提交:模板=%s", params["template_id"]) # 与 KF2V 同端点(官方文档:image2video/video-synthesis) return _submit({"model": model, "input": inp, "parameters": params}, key, SUBMIT_API_KF2V) def generate_liveportrait(image: str, audio: str, dest: str, *, template_id: str = "calm", progress_cb=None) -> str: """数字人播报一步到位(提交 → 轮询 → 下载) ⚠️ 输出时长由**音频时长**决定(不是固定 5 秒) """ tid = submit_liveportrait(image, audio, template_id=template_id) if progress_cb: progress_cb(f"📤 已提交(task {tid})") url = poll(tid, progress_cb=progress_cb) download(url, dest) return dest def estimate_cost(seconds: float, model: str = "wan2.2-kf2v-flash") -> float: if model == LIVEPORTRAIT_MODEL: return round(seconds * 0.02, 3) rate = 0.1 if "kf2v" in model else 0.3 return round(seconds * rate, 2)