@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.
- package/LICENSE +21 -0
- package/README.md +166 -0
- package/dist/argv.d.ts +15 -0
- package/dist/argv.js +51 -0
- package/dist/cli.d.ts +13 -0
- package/dist/cli.js +83 -0
- package/dist/handlers/gen-image.d.ts +13 -0
- package/dist/handlers/gen-image.js +161 -0
- package/dist/handlers/gen-voice.d.ts +16 -0
- package/dist/handlers/gen-voice.js +110 -0
- package/dist/handlers/index.d.ts +13 -0
- package/dist/handlers/index.js +14 -0
- package/dist/handlers/template-bind.d.ts +15 -0
- package/dist/handlers/template-bind.js +149 -0
- package/dist/http.d.ts +52 -0
- package/dist/http.js +110 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +8 -0
- package/dist/manifest.json +260 -0
- package/dist/progress.d.ts +23 -0
- package/dist/progress.js +15 -0
- package/dist/registry.d.ts +33 -0
- package/dist/registry.js +72 -0
- package/dist/runner.d.ts +17 -0
- package/dist/runner.js +63 -0
- package/package.json +54 -0
- package/skills/export-jianying/SKILL.md +251 -0
- package/skills/export-jianying/scripts/gen_jianying_draft.py +1099 -0
- package/skills/export-jianying/skill.json +24 -0
- package/skills/export-jianying/version.json +7 -0
- package/skills/gen-digital-human/SKILL.md +211 -0
- package/skills/gen-digital-human/scripts/gen_digital_human_video.py +574 -0
- package/skills/gen-digital-human/skill.json +23 -0
- package/skills/gen-digital-human/version.json +7 -0
- package/skills/gen-image/SKILL.md +130 -0
- package/skills/gen-image/skill.json +22 -0
- package/skills/gen-image/version.json +7 -0
- package/skills/gen-script/SKILL.md +283 -0
- package/skills/gen-script/scripts/gen_script.py +1208 -0
- package/skills/gen-script/skill.json +41 -0
- package/skills/gen-script/version.json +7 -0
- package/skills/gen-video/SKILL.md +170 -0
- package/skills/gen-video/scripts/gen_video.py +591 -0
- package/skills/gen-video/skill.json +24 -0
- package/skills/gen-video/version.json +7 -0
- package/skills/gen-voice/SKILL.md +104 -0
- package/skills/gen-voice/skill.json +21 -0
- package/skills/gen-voice/version.json +7 -0
- package/skills/prepare-video-assets/SKILL.md +192 -0
- package/skills/prepare-video-assets/scripts/prepare_video_assets.py +57 -0
- package/skills/prepare-video-assets/skill.json +23 -0
- package/skills/prepare-video-assets/version.json +7 -0
- package/skills/render-video/SKILL.md +187 -0
- package/skills/render-video/scripts/_chrome_vendor.py +305 -0
- package/skills/render-video/scripts/_video_probe.py +174 -0
- package/skills/render-video/scripts/_vod_polling.py +86 -0
- package/skills/render-video/scripts/remote_renderer_client.py +236 -0
- package/skills/render-video/scripts/render_video.py +2288 -0
- package/skills/render-video/scripts/upload_video.py +193 -0
- package/skills/render-video/skill.json +21 -0
- package/skills/render-video/version.json +7 -0
- package/skills/template-bind/README.md +63 -0
- package/skills/template-bind/SKILL.md +234 -0
- package/skills/template-bind/scripts/check_contracts.py +196 -0
- package/skills/template-bind/scripts/match_template.py +215 -0
- package/skills/template-bind/scripts/registry_loader.py +379 -0
- package/skills/template-bind/scripts/render_job_client.py +162 -0
- package/skills/template-bind/scripts/sync_registry.py +44 -0
- package/skills/template-bind/scripts/template_paths.py +72 -0
- package/skills/template-bind/skill.json +20 -0
- package/skills/template-bind/version.json +7 -0
- package/skills/template-bind/video_dsl/README.md +97 -0
- package/skills/template-bind/video_dsl/runtime/__init__.py +11 -0
- package/skills/template-bind/video_dsl/runtime/dsl_validator.py +495 -0
- package/skills/template-bind/video_dsl/runtime/prompt_enhancer.py +274 -0
- package/skills/template-bind/video_dsl/runtime/template_binder.py +87 -0
- package/skills/template-bind/video_dsl/runtime/timeline_compiler.py +312 -0
- package/skills/template-bind/video_dsl/schema/render-plan-v1alpha1.json +193 -0
- package/skills/template-bind/video_dsl/schema/template-binding-v1alpha1.json +122 -0
- package/skills/template-bind/video_dsl/schema/template-definition-v1alpha1.json +247 -0
- package/skills/template-bind/video_dsl/schema/video-dsl-v1alpha1.json +378 -0
- package/skills/video-parser/SKILL.md +238 -0
- package/skills/video-parser/scripts/analyze_video.py +1038 -0
- package/skills/video-parser/scripts/deconstruct_video.py +602 -0
- package/skills/video-parser/scripts/parse_via_render.py +148 -0
- package/skills/video-parser/skill.json +21 -0
- package/skills/video-parser/version.json +7 -0
- package/skills/web-capture/SKILL.md +183 -0
- package/skills/web-capture/scripts/web_capture.py +461 -0
- package/skills/web-capture/skill.json +29 -0
- 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
|