@remixmate/cli 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +166 -0
  3. package/dist/argv.d.ts +15 -0
  4. package/dist/argv.js +51 -0
  5. package/dist/cli.d.ts +13 -0
  6. package/dist/cli.js +83 -0
  7. package/dist/handlers/gen-image.d.ts +13 -0
  8. package/dist/handlers/gen-image.js +161 -0
  9. package/dist/handlers/gen-voice.d.ts +16 -0
  10. package/dist/handlers/gen-voice.js +110 -0
  11. package/dist/handlers/index.d.ts +13 -0
  12. package/dist/handlers/index.js +14 -0
  13. package/dist/handlers/template-bind.d.ts +15 -0
  14. package/dist/handlers/template-bind.js +149 -0
  15. package/dist/http.d.ts +52 -0
  16. package/dist/http.js +110 -0
  17. package/dist/index.d.ts +9 -0
  18. package/dist/index.js +8 -0
  19. package/dist/manifest.json +260 -0
  20. package/dist/progress.d.ts +23 -0
  21. package/dist/progress.js +15 -0
  22. package/dist/registry.d.ts +33 -0
  23. package/dist/registry.js +72 -0
  24. package/dist/runner.d.ts +17 -0
  25. package/dist/runner.js +63 -0
  26. package/package.json +54 -0
  27. package/skills/export-jianying/SKILL.md +251 -0
  28. package/skills/export-jianying/scripts/gen_jianying_draft.py +1099 -0
  29. package/skills/export-jianying/skill.json +24 -0
  30. package/skills/export-jianying/version.json +7 -0
  31. package/skills/gen-digital-human/SKILL.md +211 -0
  32. package/skills/gen-digital-human/scripts/gen_digital_human_video.py +574 -0
  33. package/skills/gen-digital-human/skill.json +23 -0
  34. package/skills/gen-digital-human/version.json +7 -0
  35. package/skills/gen-image/SKILL.md +130 -0
  36. package/skills/gen-image/skill.json +22 -0
  37. package/skills/gen-image/version.json +7 -0
  38. package/skills/gen-script/SKILL.md +283 -0
  39. package/skills/gen-script/scripts/gen_script.py +1208 -0
  40. package/skills/gen-script/skill.json +41 -0
  41. package/skills/gen-script/version.json +7 -0
  42. package/skills/gen-video/SKILL.md +170 -0
  43. package/skills/gen-video/scripts/gen_video.py +591 -0
  44. package/skills/gen-video/skill.json +24 -0
  45. package/skills/gen-video/version.json +7 -0
  46. package/skills/gen-voice/SKILL.md +104 -0
  47. package/skills/gen-voice/skill.json +21 -0
  48. package/skills/gen-voice/version.json +7 -0
  49. package/skills/prepare-video-assets/SKILL.md +192 -0
  50. package/skills/prepare-video-assets/scripts/prepare_video_assets.py +57 -0
  51. package/skills/prepare-video-assets/skill.json +23 -0
  52. package/skills/prepare-video-assets/version.json +7 -0
  53. package/skills/render-video/SKILL.md +187 -0
  54. package/skills/render-video/scripts/_chrome_vendor.py +305 -0
  55. package/skills/render-video/scripts/_video_probe.py +174 -0
  56. package/skills/render-video/scripts/_vod_polling.py +86 -0
  57. package/skills/render-video/scripts/remote_renderer_client.py +236 -0
  58. package/skills/render-video/scripts/render_video.py +2288 -0
  59. package/skills/render-video/scripts/upload_video.py +193 -0
  60. package/skills/render-video/skill.json +21 -0
  61. package/skills/render-video/version.json +7 -0
  62. package/skills/template-bind/README.md +63 -0
  63. package/skills/template-bind/SKILL.md +234 -0
  64. package/skills/template-bind/scripts/check_contracts.py +196 -0
  65. package/skills/template-bind/scripts/match_template.py +215 -0
  66. package/skills/template-bind/scripts/registry_loader.py +379 -0
  67. package/skills/template-bind/scripts/render_job_client.py +162 -0
  68. package/skills/template-bind/scripts/sync_registry.py +44 -0
  69. package/skills/template-bind/scripts/template_paths.py +72 -0
  70. package/skills/template-bind/skill.json +20 -0
  71. package/skills/template-bind/version.json +7 -0
  72. package/skills/template-bind/video_dsl/README.md +97 -0
  73. package/skills/template-bind/video_dsl/runtime/__init__.py +11 -0
  74. package/skills/template-bind/video_dsl/runtime/dsl_validator.py +495 -0
  75. package/skills/template-bind/video_dsl/runtime/prompt_enhancer.py +274 -0
  76. package/skills/template-bind/video_dsl/runtime/template_binder.py +87 -0
  77. package/skills/template-bind/video_dsl/runtime/timeline_compiler.py +312 -0
  78. package/skills/template-bind/video_dsl/schema/render-plan-v1alpha1.json +193 -0
  79. package/skills/template-bind/video_dsl/schema/template-binding-v1alpha1.json +122 -0
  80. package/skills/template-bind/video_dsl/schema/template-definition-v1alpha1.json +247 -0
  81. package/skills/template-bind/video_dsl/schema/video-dsl-v1alpha1.json +378 -0
  82. package/skills/video-parser/SKILL.md +238 -0
  83. package/skills/video-parser/scripts/analyze_video.py +1038 -0
  84. package/skills/video-parser/scripts/deconstruct_video.py +602 -0
  85. package/skills/video-parser/scripts/parse_via_render.py +148 -0
  86. package/skills/video-parser/skill.json +21 -0
  87. package/skills/video-parser/version.json +7 -0
  88. package/skills/web-capture/SKILL.md +183 -0
  89. package/skills/web-capture/scripts/web_capture.py +461 -0
  90. package/skills/web-capture/skill.json +29 -0
  91. package/skills/web-capture/version.json +7 -0
@@ -0,0 +1,305 @@
1
+ """Chrome Headless vendor sync — separated from render_video.py.
2
+
3
+ 把 Chrome 下载/解压/共享缓存这块"部署/运维相关"的逻辑收拢到一个文件,让
4
+ render_video.py 只剩"调度 + 渲染"。日后如果要把这块搬到 ab-render Dockerfile
5
+ 或独立 prepare 脚本,只需要换掉这个模块的实现,render_video 不用动。
6
+
7
+ 公共入口只有一个:sync_chrome_headless_vendor()。其它都是辅助函数,给 sync 用。
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import builtins
12
+ import json
13
+ import os
14
+ import platform
15
+ import shutil
16
+ import subprocess # noqa: F401 (kept for parity with original imports)
17
+ import sys
18
+ import tempfile
19
+ import urllib.error
20
+ import urllib.request
21
+ import zipfile
22
+ from datetime import datetime
23
+ from typing import Optional
24
+
25
+
26
+ def _now_iso() -> str:
27
+ from datetime import timezone
28
+ return datetime.now(timezone.utc).isoformat()
29
+
30
+
31
+ def _log(msg: str) -> None:
32
+ """Tiny stderr-with-timestamp helper, mirrors render_video.LogPrint format."""
33
+ now = datetime.now()
34
+ stamp = now.strftime("%Y%m%d %H%M%S") + f":{now.microsecond // 1000:03d}"
35
+ builtins.print(f"[{stamp}] {msg}", file=sys.stderr)
36
+
37
+
38
+ def _detect_remotion_chrome_platform() -> Optional[str]:
39
+ """与 Remotion BrowserFetcher 一致的平台标识;不支持时返回 None。"""
40
+ system = platform.system()
41
+ machine = platform.machine().lower()
42
+ if system == "Darwin":
43
+ return "mac-arm64" if machine == "arm64" else "mac-x64"
44
+ if system == "Linux":
45
+ if machine in ("aarch64", "arm64"):
46
+ return "linux-arm64"
47
+ return "linux64"
48
+ if system == "Windows":
49
+ return "win64"
50
+ return None
51
+
52
+
53
+ def _load_chrome_vendor_manifest(renderer_dir: str) -> Optional[dict]:
54
+ override = os.environ.get("REMOTION_CHROME_VENDOR_JSON", "").strip()
55
+ path = override if override else os.path.join(
56
+ renderer_dir,
57
+ "chrome-headless-vendor-template",
58
+ "vendor-zip-urls.json",
59
+ )
60
+ if not os.path.isfile(path):
61
+ return None
62
+ try:
63
+ with open(path, "r", encoding="utf-8") as f:
64
+ data = json.load(f)
65
+ return data if isinstance(data, dict) else None
66
+ except (OSError, json.JSONDecodeError):
67
+ return None
68
+
69
+
70
+ def _download_chrome_vendor_zip(url: str, dest_path: str) -> None:
71
+ timeout = int(os.environ.get("REMOTION_CHROME_VENDOR_DOWNLOAD_TIMEOUT", "3600"))
72
+ req = urllib.request.Request(
73
+ url,
74
+ headers={"User-Agent": "Mozilla/5.0 (compatible; render-video/1.0)"},
75
+ )
76
+ try:
77
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
78
+ with open(dest_path, "wb") as out:
79
+ shutil.copyfileobj(resp, out, 1024 * 256)
80
+ except urllib.error.HTTPError as e:
81
+ raise RuntimeError(f"HTTP {e.code}: {e.reason}") from e
82
+ except urllib.error.URLError as e:
83
+ raise RuntimeError(f"Download failed: {e.reason}") from e
84
+
85
+
86
+ def _vendor_chrome_headless_ready(chrome_root: str, rem_platform: str, expected_version: str) -> bool:
87
+ version_path = os.path.join(chrome_root, "VERSION")
88
+ if not os.path.isfile(version_path):
89
+ return False
90
+ try:
91
+ with open(version_path, "r", encoding="utf-8") as f:
92
+ if f.read().strip() != expected_version:
93
+ return False
94
+ except OSError:
95
+ return False
96
+ inner = os.path.join(chrome_root, rem_platform, f"chrome-headless-shell-{rem_platform}")
97
+ if rem_platform == "win64":
98
+ return os.path.isfile(os.path.join(inner, "chrome-headless-shell.exe"))
99
+ return os.path.isfile(os.path.join(inner, "chrome-headless-shell"))
100
+
101
+
102
+ def _ensure_chrome_headless_shell_executable(chrome_root: str, rem_platform: str) -> None:
103
+ if rem_platform == "win64":
104
+ return
105
+ inner = os.path.join(chrome_root, rem_platform, f"chrome-headless-shell-{rem_platform}")
106
+ exe = os.path.join(inner, "chrome-headless-shell")
107
+ if os.path.isfile(exe) and hasattr(os, "chmod"):
108
+ os.chmod(exe, os.stat(exe).st_mode | 0o111)
109
+
110
+
111
+ def _resolve_chrome_share_root() -> str:
112
+ root = os.environ.get("REMOTION_CHROME_SHARE_DIR", "/tmp/agent-share/chrome-headless").strip()
113
+ return root if root else "/tmp/agent-share/chrome-headless"
114
+
115
+
116
+ def _chrome_headless_share_zip_path(share_root: str, rem_platform: str) -> str:
117
+ return os.path.join(share_root, f"chrome-headless-shell-{rem_platform}.zip")
118
+
119
+
120
+ def _share_chrome_zip_ready(share_root: str, rem_platform: str, expected_version: str) -> bool:
121
+ """共享目录仅保留 VERSION + 各平台 zip 时,判断是否可用。"""
122
+ version_path = os.path.join(share_root, "VERSION")
123
+ if not os.path.isfile(version_path):
124
+ return False
125
+ try:
126
+ with open(version_path, "r", encoding="utf-8") as f:
127
+ if f.read().strip() != expected_version:
128
+ return False
129
+ except OSError:
130
+ return False
131
+ zpath = _chrome_headless_share_zip_path(share_root, rem_platform)
132
+ try:
133
+ return os.path.isfile(zpath) and os.path.getsize(zpath) > 0
134
+ except OSError:
135
+ return False
136
+
137
+
138
+ def _extract_chrome_headless_zip_to_dest(zip_path: str, dest_root: str, rem_platform: str) -> None:
139
+ """将官方 headless shell zip 解压到 Remotion 缓存根目录下的平台子目录。"""
140
+ platform_dest = os.path.join(dest_root, rem_platform)
141
+ if os.path.isdir(platform_dest):
142
+ shutil.rmtree(platform_dest)
143
+ os.makedirs(dest_root, exist_ok=True)
144
+ with zipfile.ZipFile(zip_path, "r") as zf:
145
+ zf.extractall(platform_dest)
146
+
147
+
148
+ def sync_chrome_headless_vendor(renderer_dir: str, render_plan: dict) -> None:
149
+ """
150
+ 共享目录(默认 /tmp/agent-share/chrome-headless)只存 VERSION + chrome-headless-shell-<平台>.zip;
151
+ 若 zip 与版本匹配则本地解压到 node_modules/.remotion/chrome-headless-shell/,否则从 OSS 下载,
152
+ 成功后把 zip 写入共享目录(不存解压后的目录树)。
153
+
154
+ 所有 progress / warning 经 stderr 打印;render_plan["logs"] 上记录关键事件。
155
+ """
156
+ if os.environ.get("REMOTION_SKIP_VENDOR_CHROME", "").strip().lower() in ("1", "true", "yes"):
157
+ render_plan["logs"].append({
158
+ "phase": "render",
159
+ "message": "Skipped Chrome Headless vendor sync (REMOTION_SKIP_VENDOR_CHROME)",
160
+ "timestamp": _now_iso(),
161
+ })
162
+ return
163
+
164
+ rem_platform = _detect_remotion_chrome_platform()
165
+ if not rem_platform:
166
+ _log("⚠️ 无法识别本机以下载 Chrome Headless,Remotion 可能会尝试在线下载")
167
+ return
168
+
169
+ manifest = _load_chrome_vendor_manifest(renderer_dir)
170
+ if not manifest:
171
+ _log(
172
+ "⚠️ 未找到 vendor-zip-urls.json(可用 REMOTION_CHROME_VENDOR_JSON 指定路径),"
173
+ "Remotion 可能会尝试在线下载"
174
+ )
175
+ return
176
+
177
+ zip_urls = manifest.get("zipUrls")
178
+ if not isinstance(zip_urls, dict):
179
+ zip_urls = {}
180
+ url = zip_urls.get(rem_platform)
181
+ if not url or not isinstance(url, str):
182
+ _log(f"⚠️ vendor-zip-urls.json 中未配置 {rem_platform} 的下载地址,Remotion 可能会尝试在线下载")
183
+ render_plan["logs"].append({
184
+ "phase": "render",
185
+ "message": f"No OSS zip URL for platform {rem_platform} in vendor manifest",
186
+ "timestamp": _now_iso(),
187
+ })
188
+ return
189
+
190
+ expected_version = (manifest.get("chromeVersion") or "").strip()
191
+ if not expected_version:
192
+ version_path = os.path.join(renderer_dir, "chrome-headless-vendor-template", "VERSION")
193
+ try:
194
+ with open(version_path, "r", encoding="utf-8") as f:
195
+ expected_version = f.read().strip()
196
+ except OSError:
197
+ expected_version = ""
198
+
199
+ if not expected_version:
200
+ _log("⚠️ 无法确定 Chrome 版本(chromeVersion 或 VERSION),跳过 vendor 同步")
201
+ return
202
+
203
+ dest_root = os.path.join(renderer_dir, "node_modules", ".remotion", "chrome-headless-shell")
204
+ force = os.environ.get("REMOTION_FORCE_VENDOR_CHROME", "").strip().lower() in ("1", "true", "yes")
205
+ force = force or os.environ.get("REMOTION_FORCE_VENDOR_ZIP", "").strip().lower() in ("1", "true", "yes")
206
+
207
+ if not force and _vendor_chrome_headless_ready(dest_root, rem_platform, expected_version):
208
+ _log(f"ℹ️ 已存在匹配的 Chrome Headless Shell({expected_version}),跳过下载")
209
+ return
210
+
211
+ share_root = _resolve_chrome_share_root()
212
+ platform_dest = os.path.join(dest_root, rem_platform)
213
+ share_zip = _chrome_headless_share_zip_path(share_root, rem_platform)
214
+
215
+ if not force and _share_chrome_zip_ready(share_root, rem_platform, expected_version):
216
+ _log(f"ℹ️ 共享目录已有 zip({rem_platform} {expected_version}),跳过网络下载 → {share_zip}")
217
+ sys.stderr.flush()
218
+ try:
219
+ _extract_chrome_headless_zip_to_dest(share_zip, dest_root, rem_platform)
220
+ except (OSError, zipfile.BadZipFile) as e:
221
+ _log(f"❌ 从共享 zip 解压浏览器失败: {e}")
222
+ render_plan["logs"].append({
223
+ "phase": "render",
224
+ "message": f"Chrome Headless unzip from share zip failed: {e}",
225
+ "timestamp": _now_iso(),
226
+ })
227
+ return
228
+ try:
229
+ with open(os.path.join(dest_root, "VERSION"), "w", encoding="utf-8") as vf:
230
+ vf.write(expected_version + "\n")
231
+ except OSError as e:
232
+ _log(f"⚠️ 写入 VERSION 失败: {e}")
233
+ _ensure_chrome_headless_shell_executable(dest_root, rem_platform)
234
+ render_plan["logs"].append({
235
+ "phase": "render",
236
+ "message": f"Installed Chrome Headless from share zip ({share_zip}, {expected_version})",
237
+ "timestamp": _now_iso(),
238
+ })
239
+ _log(f"✅ Chrome Headless Shell 已就绪: {dest_root}")
240
+ return
241
+
242
+ if os.path.isdir(platform_dest):
243
+ shutil.rmtree(platform_dest)
244
+
245
+ os.makedirs(dest_root, exist_ok=True)
246
+ _log(f"📦 正在从 OSS 下载 Chrome Headless Shell({rem_platform})…")
247
+ sys.stderr.flush()
248
+
249
+ fd, tmp_zip = tempfile.mkstemp(suffix=".zip", prefix="chrome-headless-vendor-")
250
+ os.close(fd)
251
+ download_ok = False
252
+ try:
253
+ try:
254
+ _log(f" ⬇️ 开始拉取浏览器包: {url.strip()}")
255
+ sys.stderr.flush()
256
+ _download_chrome_vendor_zip(url.strip(), tmp_zip)
257
+ except RuntimeError as e:
258
+ _log(f"❌ 下载内置浏览器失败: {e}")
259
+ render_plan["logs"].append({
260
+ "phase": "render",
261
+ "message": f"Chrome Headless vendor download failed: {e}",
262
+ "timestamp": _now_iso(),
263
+ })
264
+ return
265
+
266
+ try:
267
+ _extract_chrome_headless_zip_to_dest(tmp_zip, dest_root, rem_platform)
268
+ download_ok = True
269
+ except (OSError, zipfile.BadZipFile) as e:
270
+ _log(f"❌ 解压内置浏览器失败: {e}")
271
+ render_plan["logs"].append({
272
+ "phase": "render",
273
+ "message": f"Chrome Headless vendor unzip failed: {e}",
274
+ "timestamp": _now_iso(),
275
+ })
276
+ return
277
+ finally:
278
+ if download_ok:
279
+ try:
280
+ os.makedirs(share_root, exist_ok=True)
281
+ shutil.copy2(tmp_zip, share_zip)
282
+ with open(os.path.join(share_root, "VERSION"), "w", encoding="utf-8") as vf:
283
+ vf.write(expected_version + "\n")
284
+ _log(f" 📁 已缓存 zip 到共享目录: {share_zip}")
285
+ except OSError as e:
286
+ _log(f"⚠️ 写入共享 zip 失败(不影响渲染): {e}")
287
+ try:
288
+ os.unlink(tmp_zip)
289
+ except OSError:
290
+ pass
291
+
292
+ try:
293
+ with open(os.path.join(dest_root, "VERSION"), "w", encoding="utf-8") as vf:
294
+ vf.write(expected_version + "\n")
295
+ except OSError as e:
296
+ _log(f"⚠️ 写入 VERSION 失败: {e}")
297
+
298
+ _ensure_chrome_headless_shell_executable(dest_root, rem_platform)
299
+
300
+ render_plan["logs"].append({
301
+ "phase": "render",
302
+ "message": f"Synced Chrome Headless Shell from OSS ({rem_platform}, {expected_version})",
303
+ "timestamp": _now_iso(),
304
+ })
305
+ _log(f"✅ Chrome Headless Shell 已就绪: {dest_root}")
@@ -0,0 +1,174 @@
1
+ """Video duration probe — separated from render_video.py.
2
+
3
+ screen-walkthrough 这种"按 TTS 时间轴 + 视频素材池"的模板需要知道远端视频的
4
+ 真实时长才能选出正确的 adaptStrategy(fit-exact / trim-and-speed / freeze-tail
5
+ / static-fallback)。这里给出三条策略,按速度排序:
6
+
7
+ 1. 通过 mediaId(从 CDN URL 中切出)查 ab-api /file/get
8
+ 2. HEAD 请求按 Content-Length 估算(粗略)
9
+ 3. 下载前 2 MB 用 ffprobe 解析(精确但贵)
10
+
11
+ 公共入口只有一个:probe_video_duration(url) -> Optional[float]。
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import builtins
16
+ import json
17
+ import os
18
+ import subprocess
19
+ import sys
20
+ import tempfile
21
+ import urllib.error
22
+ import urllib.request
23
+ from datetime import datetime
24
+ from typing import Optional
25
+
26
+
27
+ def _log(msg: str) -> None:
28
+ now = datetime.now()
29
+ stamp = now.strftime("%Y%m%d %H%M%S") + f":{now.microsecond // 1000:03d}"
30
+ builtins.print(f"[{stamp}] {msg}", file=sys.stderr)
31
+
32
+
33
+ def _extract_media_id_from_url(url: str) -> Optional[str]:
34
+ """Extract mediaId (vodVideoId) from CDN URL.
35
+
36
+ Supported formats:
37
+ - http://cdn.aibearer.com/{mediaId}/{hash}-ld.mp4
38
+ - https://cdn.aibearer.com/{mediaId}/...
39
+ """
40
+ try:
41
+ from urllib.parse import urlparse
42
+ parsed = urlparse(url)
43
+ parts = [p for p in parsed.path.split("/") if p]
44
+ if len(parts) >= 2:
45
+ candidate = parts[0]
46
+ if len(candidate) >= 16 and all(c in "0123456789abcdef" for c in candidate):
47
+ return candidate
48
+ except Exception:
49
+ pass
50
+ return None
51
+
52
+
53
+ def _query_duration_from_api(url: str) -> Optional[float]:
54
+ """Query ab-api /file/get by mediaId to get the precise duration from database.
55
+
56
+ Returns duration in seconds, or None if query fails or duration not available.
57
+ """
58
+ media_id = _extract_media_id_from_url(url)
59
+ if not media_id:
60
+ return None
61
+
62
+ api_base = os.environ.get("MM_API_BASE_URL", os.environ.get("MM_BACKEND_API_URL", "")).rstrip("/")
63
+ if not api_base:
64
+ return None
65
+
66
+ priv_token = os.environ.get("PRIV_TOKEN", "").strip()
67
+ if not priv_token:
68
+ return None
69
+
70
+ try:
71
+ payload = json.dumps({"mediaId": media_id}).encode("utf-8")
72
+ req = urllib.request.Request(
73
+ f"{api_base}/file/get",
74
+ data=payload,
75
+ headers={
76
+ "Content-Type": "application/json",
77
+ "X-Priv-Token": priv_token,
78
+ },
79
+ method="POST",
80
+ )
81
+ with urllib.request.urlopen(req, timeout=5) as resp:
82
+ result = json.loads(resp.read().decode("utf-8"))
83
+
84
+ if result.get("code") != 0:
85
+ return None
86
+
87
+ data = result.get("data") or {}
88
+ duration = data.get("duration")
89
+ if duration is not None and duration > 0:
90
+ _log(f" 📡 视频时长(API查询): {duration:.1f}s (mediaId={media_id[:12]}...)")
91
+ return float(duration)
92
+ except Exception:
93
+ pass
94
+
95
+ return None
96
+
97
+
98
+ def probe_video_duration(url: str) -> Optional[float]:
99
+ """Probe the duration (in seconds) of a remote video URL.
100
+
101
+ Strategy (ordered by priority):
102
+ 1. Query ab-api /file/get by mediaId extracted from CDN URL (fastest, most accurate).
103
+ 2. HEAD request to estimate from Content-Length.
104
+ 3. Download first 2MB + ffprobe (if available).
105
+
106
+ Returns duration in seconds, or None on failure.
107
+ """
108
+ api_duration = _query_duration_from_api(url)
109
+ if api_duration is not None:
110
+ return api_duration
111
+
112
+ content_length_estimate: Optional[float] = None
113
+ try:
114
+ req_head = urllib.request.Request(url, method="HEAD", headers={
115
+ "User-Agent": "Mozilla/5.0 (compatible; render-video/1.0)",
116
+ })
117
+ with urllib.request.urlopen(req_head, timeout=10) as resp:
118
+ content_length = int(resp.headers.get("Content-Length", 0))
119
+ if content_length > 0:
120
+ content_length_estimate = content_length / 62500.0
121
+ except Exception:
122
+ pass
123
+
124
+ tmp_path = None
125
+ try:
126
+ req = urllib.request.Request(url, headers={
127
+ "User-Agent": "Mozilla/5.0 (compatible; render-video/1.0)",
128
+ "Range": "bytes=0-2097151",
129
+ })
130
+ try:
131
+ with urllib.request.urlopen(req, timeout=15) as resp:
132
+ data = resp.read()
133
+ except urllib.error.HTTPError as e:
134
+ if e.code == 416:
135
+ req2 = urllib.request.Request(url, headers={
136
+ "User-Agent": "Mozilla/5.0 (compatible; render-video/1.0)",
137
+ })
138
+ with urllib.request.urlopen(req2, timeout=15) as resp:
139
+ data = resp.read()
140
+ else:
141
+ raise
142
+
143
+ fd, tmp_path = tempfile.mkstemp(suffix=".mp4", prefix="probe-")
144
+ os.close(fd)
145
+ with open(tmp_path, "wb") as f:
146
+ f.write(data)
147
+
148
+ try:
149
+ result = subprocess.run(
150
+ ["ffprobe", "-v", "quiet", "-print_format", "json", "-show_format", tmp_path],
151
+ capture_output=True, text=True, timeout=10,
152
+ )
153
+ if result.returncode == 0:
154
+ info = json.loads(result.stdout)
155
+ dur = info.get("format", {}).get("duration")
156
+ if dur:
157
+ return float(dur)
158
+ except (FileNotFoundError, OSError, subprocess.TimeoutExpired, json.JSONDecodeError, ValueError):
159
+ pass
160
+
161
+ except Exception as e:
162
+ _log(f" ⚠️ 视频时长探测失败: {e}")
163
+ finally:
164
+ if tmp_path and os.path.exists(tmp_path):
165
+ try:
166
+ os.unlink(tmp_path)
167
+ except OSError:
168
+ pass
169
+
170
+ if content_length_estimate is not None:
171
+ _log(f" 📐 视频时长估算(基于文件大小): {content_length_estimate:.1f}s")
172
+ return content_length_estimate
173
+
174
+ return None
@@ -0,0 +1,86 @@
1
+ """VOD playback URL polling — separated from render_video.py.
2
+
3
+ ab-render 通过远程渲染服务把视频上传到阿里云 VOD 后,会立即返回一个
4
+ `vod://` 协议地址 + fileId。但前端需要的是 CDN 加速地址(`http://cdn.aibearer.com/...`),
5
+ 这要等阿里云转码回调到达 ab-api、回写 `file.fileUrl` 之后才能拿到。
6
+
7
+ 这个模块负责轮询 ab-api /file/get 直到拿到 CDN URL(或超时返回 OSS 原始地址)。
8
+ 公共入口只有一个:poll_vod_playback_url(file_id, priv_token, ...) -> Optional[str]。
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import builtins
13
+ import json
14
+ import os
15
+ import sys
16
+ import time
17
+ import urllib.error
18
+ import urllib.request
19
+ from datetime import datetime
20
+ from typing import Optional
21
+
22
+
23
+ def _log(msg: str) -> None:
24
+ now = datetime.now()
25
+ stamp = now.strftime("%Y%m%d %H%M%S") + f":{now.microsecond // 1000:03d}"
26
+ builtins.print(f"[{stamp}] {msg}", file=sys.stderr)
27
+
28
+
29
+ def poll_vod_playback_url(
30
+ file_id: int,
31
+ private_token: str,
32
+ *,
33
+ max_wait: float = 60.0,
34
+ interval: float = 3.0,
35
+ ) -> Optional[str]:
36
+ """轮询 ab-api /file/get,等待 VOD CDN URL 就绪。
37
+
38
+ VOD 上传后通常 10-30 秒完成转码。此函数策略:
39
+ - 一旦看到非 `vod://` 且非阿里云 OSS 域名的 URL(说明 CDN 转码完成),立即返回
40
+ - 看到阿里云 OSS 直链时记为 fallback,继续等 CDN
41
+ - 超时仍未拿到 CDN 时退回到 OSS 原始地址
42
+ """
43
+ api_base = os.environ.get("MM_BACKEND_API_URL", "http://localhost:2999/api").rstrip("/")
44
+ url = f"{api_base}/file/get"
45
+ headers = {
46
+ "Content-Type": "application/json",
47
+ "X-Priv-Token": private_token,
48
+ "x-invoke-skill": "render-video",
49
+ }
50
+ agent_name = os.environ.get("AGENT_NAME", "")
51
+ if agent_name:
52
+ headers["x-invoke-agent"] = agent_name
53
+
54
+ payload = json.dumps({"fileId": int(file_id)}).encode("utf-8")
55
+
56
+ _log(f" ⏳ 等待 VOD 播放地址就绪(fileId={file_id},最长等待 {max_wait:.0f}s)...")
57
+ start = time.time()
58
+ oss_fallback: Optional[str] = None
59
+
60
+ while time.time() - start < max_wait:
61
+ try:
62
+ req = urllib.request.Request(url, data=payload, headers=headers, method="POST")
63
+ with urllib.request.urlopen(req, timeout=10) as resp:
64
+ result = json.loads(resp.read().decode("utf-8"))
65
+
66
+ if result.get("code") == 0:
67
+ data = result.get("data") or {}
68
+ file_url = data.get("fileUrl")
69
+ vod_status = data.get("vodStatus", "")
70
+ if file_url and not file_url.startswith("vod://"):
71
+ if "aliyuncs.com" not in file_url:
72
+ return file_url
73
+ oss_fallback = file_url
74
+ elapsed = time.time() - start
75
+ _log(f" ⏳ VOD 状态: {vod_status}(等待 CDN 转码完成,{elapsed:.0f}s)")
76
+ else:
77
+ elapsed = time.time() - start
78
+ _log(f" ⏳ VOD 状态: {vod_status}({elapsed:.0f}s)")
79
+ except Exception:
80
+ pass
81
+
82
+ time.sleep(interval)
83
+
84
+ if oss_fallback:
85
+ _log(f" ⚠️ 等待 CDN 转码超时,返回原始 OSS 地址")
86
+ return oss_fallback