generate_video.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308
  1. #!/usr/bin/env python3
  2. # -*- coding: utf-8 -*-
  3. """
  4. 百炼 wan3(万相3.0)视频生成调用脚本
  5. ====================================
  6. 仅供 beauty-talk-science-video-script 技能在「用户确认脚本后」调用。
  7. 流程:
  8. 1. 解析参考素材(人物完整形象照 / 门店内景照 / 插画参考图 / 音色文件)。
  9. - 公网 URL(--media 'https://...:类型'):默认直接透传,wan3 原生支持公网 URL。
  10. 若 config 的 reupload_external_urls=true(wan3 拒收站外 URL 时),先下载再编码 base64 直传。
  11. - 本地文件(生成的人物形象照 / 门店内景 / 插画):读取后编码为
  12. data:{mime};base64,{b64} 直接放入 media[].url(wan3 原生支持 base64 图像,免去文件上传端点)。
  13. 2. 组装 video-synthesis 异步请求(model / prompt / media / parameters),提交任务拿 task_id。
  14. 3. 轮询任务状态,直到 SUCCEEDED。
  15. 4. 下载生成的视频到本地 output_dir。
  16. 端点(与用户所给 LLM base_url 不同,视频服务走同 workspace、同地域、但**不带 llm- 前缀**的主机):
  17. POST https://{WorkspaceId}.{region}.maas.aliyuncs.com/api/v1/services/aigc/video-generation/video-synthesis
  18. GET https://{WorkspaceId}.{region}.maas.aliyuncs.com/api/v1/tasks/{task_id}
  19. 用户所给 "https://llm-xxxx.cn-beijing.maas.aliyuncs.com/compatible-mode/v1" 是 **LLM 对话网关**
  20. (OpenAI 兼容模式),视频生成不使用它;脚本按 workspace_id + region 自动拼视频主机,
  21. 也可在 config.api_base 显式覆盖。
  22. 凭证:
  23. - DASHSCOPE_API_KEY 必须存在于环境变量(不写死在代码/配置里)。
  24. - region / workspace_id / model / resolution / ratio / output_dir 写在同目录 config.json。
  25. - 模型 / Endpoint / Key 必须同地域,跨地域调用会失败。
  26. 注意:
  27. - 本脚本只做「整段重新生成」,不使用 wan3 的视频编辑 / 延长模式(对应技能第 8 条:不支持二次创作)。
  28. - 单次生成时长上限 30 秒(wan3.0 限制)。超过 30 秒的脚本请在技能侧先拆分/精简,本脚本强制 duration≤30。
  29. """
  30. import os
  31. import sys
  32. import json
  33. import time
  34. import base64
  35. import argparse
  36. import mimetypes
  37. import requests
  38. # --------------------------------------------------------------------------- #
  39. # 配置与端点
  40. # --------------------------------------------------------------------------- #
  41. def load_config(path):
  42. with open(path, "r", encoding="utf-8") as f:
  43. return json.load(f)
  44. def api_base(cfg):
  45. """视频服务主机:优先用 config.api_base;否则按 workspace_id + region 拼(不带 llm- 前缀)。"""
  46. if cfg.get("api_base"):
  47. return cfg["api_base"].rstrip("/")
  48. ws = cfg["workspace_id"]
  49. region = cfg["region"]
  50. return f"https://{ws}.{region}.maas.aliyuncs.com"
  51. def auth_headers(key):
  52. return {
  53. "Authorization": f"Bearer {key}",
  54. "Content-Type": "application/json",
  55. }
  56. def resolve_key(here):
  57. """解析百炼 / DashScope API Key。
  58. 优先级:环境变量 DASHSCOPE_API_KEY > 脚本同目录 .env(DASHSCOPE_API_KEY=...)。
  59. 注意:.env 含密钥,禁止随技能分发;重新打包技能前请删除本地 .env。
  60. """
  61. env = os.environ.get("DASHSCOPE_API_KEY")
  62. if env and env.strip():
  63. return env.strip()
  64. env_path = os.path.join(here, ".env")
  65. if os.path.exists(env_path):
  66. try:
  67. with open(env_path, "r", encoding="utf-8") as f:
  68. for line in f:
  69. line = line.strip()
  70. if not line or line.startswith("#"):
  71. continue
  72. if line.startswith("DASHSCOPE_API_KEY="):
  73. v = line.split("=", 1)[1].strip().strip('"').strip("'")
  74. if v:
  75. return v
  76. except Exception:
  77. pass
  78. sys.exit(
  79. "未找到 DASHSCOPE_API_KEY:\n"
  80. " (1) 运行前 export DASHSCOPE_API_KEY=sk-真实key(当前 shell 生效);或\n"
  81. " (2) 在脚本同目录创建 .env,写入一行 DASHSCOPE_API_KEY=sk-真实key(详见 .env.example)。\n"
  82. "注意:北京地域创建、sk- 开头;密钥严禁提交或分发。"
  83. )
  84. # --------------------------------------------------------------------------- #
  85. # 素材 -> URL(公网URL直传 / 本地文件 base64 直传)
  86. # --------------------------------------------------------------------------- #
  87. def local_file_to_data_uri(filepath):
  88. """读取本地文件,编码为 data:{mime};base64,{b64},wan3 原生支持图像 base64 直传。"""
  89. mime = mimetypes.guess_type(filepath)[0] or "application/octet-stream"
  90. with open(filepath, "rb") as f:
  91. b64 = base64.b64encode(f.read()).decode("ascii")
  92. return f"data:{mime};base64,{b64}"
  93. def download_to_temp(url, suffix=".tmp"):
  94. """把公网 URL 下载到临时文件,返回本地路径(用于 reupload_external_urls)。"""
  95. import tempfile
  96. fd, tmppath = tempfile.mkstemp(suffix=suffix)
  97. os.close(fd)
  98. try:
  99. resp = requests.get(url, timeout=300, stream=True)
  100. resp.raise_for_status()
  101. with open(tmppath, "wb") as f:
  102. for chunk in resp.iter_content(8192):
  103. f.write(chunk)
  104. except Exception:
  105. if os.path.exists(tmppath):
  106. os.remove(tmppath)
  107. raise
  108. return tmppath
  109. # --------------------------------------------------------------------------- #
  110. # 任务创建与轮询
  111. # --------------------------------------------------------------------------- #
  112. def create_task(base, key, payload):
  113. url = base + "/api/v1/services/aigc/video-generation/video-synthesis"
  114. headers = auth_headers(key)
  115. headers["X-DashScope-Async"] = "enable"
  116. resp = requests.post(url, headers=headers, json=payload, timeout=120)
  117. if resp.status_code not in (200, 202):
  118. raise RuntimeError(f"建任务失败:HTTP {resp.status_code} {resp.text[:500]}")
  119. return resp.json()
  120. def poll_task(base, key, task_id, interval, timeout):
  121. url = base + f"/api/v1/tasks/{task_id}"
  122. headers = {"Authorization": f"Bearer {key}"}
  123. waited = 0
  124. while waited < timeout:
  125. try:
  126. resp = requests.get(url, headers=headers, timeout=60)
  127. if resp.status_code == 200:
  128. data = resp.json()
  129. status = (
  130. (data.get("output", {}) or {}).get("task_status")
  131. or data.get("task_status")
  132. )
  133. if status == "SUCCEEDED":
  134. return data
  135. if status in ("FAILED", "UNKNOWN"):
  136. raise RuntimeError(f"任务失败:{json.dumps(data, ensure_ascii=False)[:800]}")
  137. except requests.RequestException:
  138. pass
  139. time.sleep(interval)
  140. waited += interval
  141. raise RuntimeError("轮询超时(视频生成可能仍在后台,请用 task_id 手动查询)")
  142. def extract_video_url(data):
  143. out = data.get("output", {}) or {}
  144. for k in ("video_url", "videoUrl", "url"):
  145. if out.get(k):
  146. return out[k]
  147. for r in out.get("results") or []:
  148. if r.get("url"):
  149. return r["url"]
  150. raise RuntimeError("任务成功但未找到视频 URL")
  151. def download(url, out_path):
  152. resp = requests.get(url, timeout=300, stream=True)
  153. resp.raise_for_status()
  154. with open(out_path, "wb") as f:
  155. for chunk in resp.iter_content(8192):
  156. f.write(chunk)
  157. return out_path
  158. # --------------------------------------------------------------------------- #
  159. # 入口
  160. # --------------------------------------------------------------------------- #
  161. def main():
  162. here = os.path.dirname(os.path.abspath(__file__))
  163. ap = argparse.ArgumentParser(description="百炼 wan3 视频生成(单段,≤30s)")
  164. ap.add_argument("--config", default=os.path.join(here, "config.json"))
  165. ap.add_argument("--prompt", required=True, help="单段视频提示词(含口播对白与 Image 1 / Audio 1 引用)")
  166. ap.add_argument(
  167. "--media",
  168. nargs="*",
  169. default=[],
  170. help="参考素材,格式 '路径或URL:类型'。类型:reference_image / reference_audio。"
  171. "如 /abs/char.jpg:reference_image 或 https://cdn.x/char.jpg:reference_image;"
  172. "公网 URL 默认直接透传;本地文件编码 base64 直传;详见 config.reupload_external_urls。",
  173. )
  174. ap.add_argument("--duration", type=int, default=30, help="时长(秒),≤30")
  175. ap.add_argument("--ratio", default=None, help="画幅,如 9:16")
  176. ap.add_argument("--resolution", default=None, help="分辨率,如 720P")
  177. ap.add_argument("--model", default=None)
  178. ap.add_argument("--output", default=None, help="输出视频本地路径")
  179. ap.add_argument("--poll-interval", type=int, default=10)
  180. ap.add_argument("--timeout", type=int, default=1200)
  181. args = ap.parse_args()
  182. if args.duration > 30:
  183. sys.exit("wan3 单次生成上限 30 秒,duration 必须 ≤30。请先在技能侧精简口播。")
  184. if args.duration < 2:
  185. sys.exit("duration 必须 ≥2 秒。")
  186. key = resolve_key(here)
  187. cfg = load_config(args.config)
  188. base = api_base(cfg)
  189. model = args.model or cfg.get("model", "wan3.0-video")
  190. resolution = args.resolution or cfg.get("resolution", "720P")
  191. ratio = args.ratio or cfg.get("ratio", "9:16")
  192. out_dir = os.path.join(here, cfg.get("output_dir", "outputs"))
  193. os.makedirs(out_dir, exist_ok=True)
  194. # 1) 解析素材 -> URL(公网URL直传 / 本地文件base64直传)
  195. reupload = bool(cfg.get("reupload_external_urls"))
  196. media = []
  197. for m in args.media:
  198. raw = m.strip()
  199. if "://" in raw:
  200. # 公网 URL 形式:'https://...:reference_image|reference_audio'(类型可选)
  201. head, sep, tail = raw.rpartition(":")
  202. if sep and tail in ("reference_image", "reference_audio"):
  203. raw, mtype = head, tail
  204. else:
  205. mtype = "reference_image"
  206. else:
  207. raw, _, mtype = raw.partition(":")
  208. mtype = (mtype or "reference_image").strip()
  209. raw = raw.strip()
  210. if "://" in raw:
  211. if reupload:
  212. print(f"[下载并base64] {raw} ({mtype}) ...", file=sys.stderr)
  213. tmp = download_to_temp(raw)
  214. u = local_file_to_data_uri(tmp)
  215. try:
  216. os.remove(tmp)
  217. except OSError:
  218. pass
  219. else:
  220. print(f"[透传URL] {raw} ({mtype})", file=sys.stderr)
  221. u = raw
  222. media.append({"type": mtype, "url": u})
  223. continue
  224. fpath = os.path.expanduser(raw)
  225. if not os.path.exists(fpath):
  226. sys.exit(f"素材不存在:{fpath}")
  227. print(f"[base64直传] {fpath} ({mtype}) ...", file=sys.stderr)
  228. u = local_file_to_data_uri(fpath)
  229. media.append({"type": mtype, "url": u})
  230. # 2) 组装请求
  231. payload = {
  232. "model": model,
  233. "input": {"prompt": args.prompt},
  234. "parameters": {
  235. "resolution": resolution,
  236. "ratio": ratio,
  237. "duration": args.duration,
  238. "audio": True,
  239. "prompt_extend": False,
  240. "watermark": False,
  241. },
  242. }
  243. if media:
  244. payload["input"]["media"] = media
  245. print("[提交] 创建视频生成任务 ...", file=sys.stderr)
  246. created = create_task(base, key, payload)
  247. task_id = (created.get("output", {}) or {}).get("task_id") or created.get("task_id")
  248. if not task_id:
  249. sys.exit(f"未返回 task_id:{json.dumps(created, ensure_ascii=False)[:500]}")
  250. print(f"[轮询] task_id={task_id}", file=sys.stderr)
  251. # 3) 轮询
  252. result = poll_task(base, key, task_id, args.poll_interval, args.timeout)
  253. # 4) 下载
  254. vurl = extract_video_url(result)
  255. out_path = args.output or os.path.join(out_dir, f"video_{int(time.time())}.mp4")
  256. out_path = os.path.abspath(out_path)
  257. print(f"[下载] {vurl} -> {out_path}", file=sys.stderr)
  258. download(vurl, out_path)
  259. # 仅供技能解析的结构化结果(走 stdout)
  260. print("VIDEO_PATH=" + out_path)
  261. print("RESULT_JSON=" + json.dumps(
  262. {"task_id": task_id, "video_path": out_path, "video_url": vurl},
  263. ensure_ascii=False,
  264. ))
  265. if __name__ == "__main__":
  266. main()