@remixmate/cli 0.9.8 → 0.9.10

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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "version": "0.9.8",
4
- "generatedAt": "2026-07-20T11:13:57.597Z",
3
+ "version": "0.9.10",
4
+ "generatedAt": "2026-07-26T14:43:29.935Z",
5
5
  "skills": [
6
6
  {
7
7
  "id": "export-jianying",
@@ -245,7 +245,7 @@
245
245
  "tier": "tool",
246
246
  "category": "consuming",
247
247
  "title": "Web Page Capture",
248
- "summary": "Drive a headless browser (Playwright Python) to capture any URL to local files: full-page / element / region screenshots, fixed-duration / condition-triggered / auto-scroll recordings, and storyboard videos (highlight / focus / zoom / scroll / virtual-cursor click / caption / title-card / arrow / numbered sequence / redact / code-line highlight). Two entry scripts: scripts/screenshot.py (stills) and scripts/record.py (webm video, storyboards, templates).",
248
+ "summary": "Drive a headless browser (Playwright Python) to capture any URL to a local STILL IMAGE (png/jpg): full-page / viewport / element / region screenshots, with device emulation, waiting, hide/mask/redact, and static annotations (highlight / arrow / caption / numbered sequence / redact). Images only for video / recording / scroll-through / storyboard clips (webm) use the web_record tool instead.",
249
249
  "triggers": [
250
250
  "网页截图、网页截屏、整页截图、长截图、full page screenshot",
251
251
  "截某个元素 / 区域、局部截屏、focus 某个区域",
@@ -269,6 +269,34 @@
269
269
  "WEB_CAPTURE_ALLOW_PRIVATE_HOSTS",
270
270
  "PLAYWRIGHT_BROWSERS_PATH"
271
271
  ]
272
+ },
273
+ {
274
+ "id": "web-record",
275
+ "tier": "tool",
276
+ "category": "consuming",
277
+ "title": "Web Page Recording",
278
+ "summary": "Drive a headless browser (Playwright Python) to RECORD any URL to a video, then (by default) transcode to mp4, grab a cover frame, upload to VOD and return a playable CDN URL. Modes: fixed-duration recording, condition-triggered stop (element appears / disappears), auto-scroll from top to bottom, custom storyboards, and parameterized templates. Storyboard scenes: highlight / focus / zoom / scroll / virtual-cursor click / type / hover / caption / title-card / arrow / numbered sequence / redact / code-line highlight. For still images (png/jpg) use the web_capture tool instead. Use this tool whenever the user wants a video / recording / screencast of a web page: record a page, scroll-through video, page-from-top-to-bottom clip, demo of clicks/typing/hover, storyboard / multi-scene intro video, or a template-based clip.",
279
+ "triggers": [
280
+ "录屏、网页录制、录制视频、录一段操作、生成 webm、screencast",
281
+ "滚动录屏、页面从头划到尾的视频",
282
+ "多场景视频、分镜视频、storyboard、按时间线编排",
283
+ "演示点击 / 输入 / 悬停(虚拟鼠标 + 涟漪 / 打字机 / 触发 tooltip)",
284
+ "模板视频一行出片、按顺序编号高亮多个区域并录制"
285
+ ],
286
+ "entry": {
287
+ "type": "python",
288
+ "scriptPath": "../web-capture/scripts/record.py"
289
+ },
290
+ "runtime": "python",
291
+ "envVars": [
292
+ "WEB_CAPTURE_BROWSER",
293
+ "WEB_CAPTURE_OUTPUT_BASE",
294
+ "WEB_CAPTURE_NO_SANDBOX",
295
+ "WEB_CAPTURE_ALLOW_PRIVATE_HOSTS",
296
+ "PLAYWRIGHT_BROWSERS_PATH",
297
+ "REMOTION_RENDER_API_URL",
298
+ "PRIV_TOKEN"
299
+ ]
272
300
  }
273
301
  ]
274
302
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remixmate/cli",
3
- "version": "0.9.8",
3
+ "version": "0.9.10",
4
4
  "description": "AI media generation skills for Claude Code / Codex — 11 skills covering image, video, voice, digital human, web capture, script, template registry, rendering, Jianying export, and video deconstruction.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -40,7 +40,10 @@ def poll_vod_playback_url(
40
40
  - 看到阿里云 OSS 直链时记为 fallback,继续等 CDN
41
41
  - 超时仍未拿到 CDN 时退回到 OSS 原始地址
42
42
  """
43
- api_base = os.environ.get("MM_BACKEND_API_URL", "https://api.remixmate.com/api").rstrip("/")
43
+ api_base = os.environ.get(
44
+ "MM_API_BASE_URL",
45
+ os.environ.get("MM_BACKEND_API_URL", "https://api.remixmate.com/api"),
46
+ ).rstrip("/")
44
47
  url = f"{api_base}/file/get"
45
48
  headers = {
46
49
  "Content-Type": "application/json",
@@ -6,7 +6,9 @@ render_job_client.py — ab-api renderJob 接口 Python 客户端
6
6
  gen_jianying_draft.py 使用。
7
7
 
8
8
  认证方式: X-Priv-Token header(与其他 skill 脚本一致)
9
- 接口地址: 由环境变量 MM_BACKEND_API_URL 控制(默认 https://api.remixmate.com/api)
9
+ 接口地址: 优先读 MM_API_BASE_URL(executor 显式转发、全链路标准名),
10
+ 回退 MM_BACKEND_API_URL,再回退默认 https://api.remixmate.com/api。
11
+ 两个变量语义相同,保留回退是为兼容只配了其中一个的部署环境。
10
12
  """
11
13
 
12
14
  import json
@@ -17,7 +19,10 @@ import urllib.request
17
19
 
18
20
 
19
21
  def _api_base() -> str:
20
- return os.environ.get("MM_BACKEND_API_URL", "https://api.remixmate.com/api").rstrip("/")
22
+ return os.environ.get(
23
+ "MM_API_BASE_URL",
24
+ os.environ.get("MM_BACKEND_API_URL", "https://api.remixmate.com/api"),
25
+ ).rstrip("/")
21
26
 
22
27
 
23
28
  def _make_headers(priv_token: str) -> dict:
@@ -11,6 +11,7 @@ from .browser import (
11
11
  apply_pre_action_waits,
12
12
  build_context_options,
13
13
  launch_with_browser_install,
14
+ wait_until_settled,
14
15
  )
15
16
 
16
17
 
@@ -57,8 +58,12 @@ def _wait_for_stop_condition(page, cfg: dict) -> str:
57
58
  return "duration" if duration else "cap"
58
59
 
59
60
 
60
- def do_record(cfg: dict) -> Path:
61
- """Plain recording — returns the absolute output path."""
61
+ def do_record(cfg: dict) -> tuple[Path, int, int]:
62
+ """Plain recording — returns (output_path, start_ms, end_ms).
63
+
64
+ start_ms/end_ms 是相对视频起点(context 创建)的内容窗口:start_ms=页面加载就绪时刻,
65
+ end_ms=内容动作结束时刻。调用方据此端锚裁掉开头加载段(skill 侧或委托 ab-render)。
66
+ """
62
67
  ensure_runtime()
63
68
  from playwright.sync_api import sync_playwright
64
69
 
@@ -69,19 +74,29 @@ def do_record(cfg: dict) -> Path:
69
74
  with sync_playwright() as p:
70
75
  browser = launch_with_browser_install(p, cfg.get("browser"))
71
76
  try:
77
+ # t0 ≈ 视频开始录制的时刻(context 创建即开录);用于测量加载头/内容窗口,
78
+ # 供 trim_video 端锚裁剪。
79
+ t0 = time.time()
72
80
  context = browser.new_context(**build_context_options(p, cfg, out_dir))
73
81
  if cfg.get("timeout"):
74
82
  context.set_default_timeout(cfg["timeout"])
75
83
  page = context.new_page()
76
84
  video_path: str | None = None
85
+ start_ms = 0
86
+ end_ms = 0
77
87
  try:
78
88
  page.goto(cfg["url"], wait_until="domcontentloaded")
79
89
  apply_pre_action_waits(page, cfg)
90
+ # 无条件等页面真正加载完成(networkidle + settled 检测 + settleMs)再开录内容——
91
+ # 对所有 URL 生效。加载/白屏段仍会被录进原始视频,随后由 trim_video 端锚裁掉。
92
+ wait_until_settled(page, cfg)
93
+ start_ms = int((time.time() - t0) * 1000)
80
94
  if cfg.get("scrollThrough"):
81
95
  _scroll_through(page, cfg)
82
96
  reason = "scrollThrough"
83
97
  else:
84
98
  reason = _wait_for_stop_condition(page, cfg)
99
+ end_ms = int((time.time() - t0) * 1000)
85
100
  print(f"[runner] stop reason: {reason}", file=sys.stderr)
86
101
  video = page.video
87
102
  context.close()
@@ -97,4 +112,6 @@ def do_record(cfg: dict) -> Path:
97
112
  src = Path(video_path)
98
113
  if src.resolve() != output:
99
114
  src.replace(output)
100
- return output
115
+ # 不在此处裁剪:加载头由调用方在收尾/转码那一步端锚裁掉(见 record.py),
116
+ # 避免 skill 侧依赖 ffmpeg 并省去一次重编码。
117
+ return output, start_ms, end_ms
@@ -96,6 +96,7 @@ def do_screenshot(cfg: dict) -> Path:
96
96
  """Take a screenshot per cfg. Returns the absolute output path."""
97
97
  ensure_runtime()
98
98
  from playwright.sync_api import sync_playwright
99
+ from playwright.sync_api import TimeoutError as PlaywrightTimeoutError
99
100
 
100
101
  output = Path(cfg["output"]).resolve()
101
102
  output.parent.mkdir(parents=True, exist_ok=True)
@@ -125,6 +126,23 @@ def do_screenshot(cfg: dict) -> Path:
125
126
 
126
127
  if cfg.get("selector"):
127
128
  locator = page.locator(cfg["selector"]).first
129
+ # 先用较短超时确认元素确实存在。否则选择器不匹配时,
130
+ # scroll_into_view_if_needed() 会干等全局超时(默认 30s)才抛出
131
+ # 一大段难懂的 Playwright traceback、以 exit 1 崩溃。这里快速失败,
132
+ # 返回一条模型可读、可据以换选择器的错误信息。
133
+ probe_timeout = cfg.get("selectorTimeout")
134
+ if probe_timeout is None:
135
+ # 页面 domcontentloaded 已触发,元素在则几乎立即命中;
136
+ # 不在则无需等满 30s。取全局 timeout 与 8s 的较小值。
137
+ probe_timeout = min(cfg.get("timeout") or 8000, 8000)
138
+ try:
139
+ locator.wait_for(state="attached", timeout=probe_timeout)
140
+ except PlaywrightTimeoutError:
141
+ raise SystemExit(
142
+ f'selector "{cfg["selector"]}" 在页面上未找到'
143
+ f'({cfg["url"]})。该元素可能不存在或页面结构已变化。'
144
+ f'请换用其它选择器,或去掉 --selector 改用整页/区域截屏。'
145
+ )
128
146
  if cfg.get("scrollIntoView", True):
129
147
  locator.scroll_into_view_if_needed()
130
148
  if isinstance(cfg.get("clip"), (list, tuple)) and len(cfg["clip"]) == 4:
@@ -0,0 +1,228 @@
1
+ #!/usr/bin/env python3
2
+ """录屏产物收尾客户端 —— 把本地视频推给 ab-render 的 /finalize 端点。
3
+
4
+ ab-render 侧(apps/ab-render/server/finalize.ts)负责:webm→mp4 转码 + 抽首帧封面
5
+ + 上传阿里云 VOD + 轮询拿到 CDN 播放地址。本模块只做 HTTP 层:POST 原始字节 +
6
+ 轮询 /finalizeStatus。纯标准库,不依赖 ffmpeg / OSS / 阿里云 SDK。
7
+
8
+ 与 render-video 的 remote_renderer_client 一样直连 ab-render:
9
+ Base URL 优先级:显式传入 > 环境变量 REMOTION_RENDER_API_URL > 默认生产地址。
10
+
11
+ 认证头(复用 X-Priv-Token 规范):
12
+ X-Priv-Token: <PRIV_TOKEN>
13
+ x-invoke-skill: web-record
14
+ x-invoke-agent: <AGENT_NAME> # optional
15
+ x-conversation-id: <CONVERSATION_ID> # optional
16
+ """
17
+ from __future__ import annotations
18
+
19
+ import json
20
+ import os
21
+ import sys
22
+ import time
23
+ import urllib.error
24
+ import urllib.parse
25
+ import urllib.request
26
+ from pathlib import Path
27
+ from typing import Callable, Optional
28
+
29
+ DEFAULT_API_BASE_URL = "https://api-render.remixmate.com"
30
+ SKILL_NAME = "web-record"
31
+
32
+ TERMINAL_SUCCESS = {"succeeded", "succeed", "completed"}
33
+ TERMINAL_FAILURE = {"failed", "cancelled"}
34
+
35
+
36
+ class FinalizeError(RuntimeError):
37
+ """上传/收尾失败,交由调用方决定是否降级为「仅本地文件」。"""
38
+
39
+
40
+ def _base_url(explicit: Optional[str]) -> str:
41
+ base = (explicit or os.environ.get("REMOTION_RENDER_API_URL") or DEFAULT_API_BASE_URL).strip()
42
+ return base.rstrip("/")
43
+
44
+
45
+ def _headers(private_token: str, *, content_type: Optional[str], conversation_id: Optional[str]) -> dict:
46
+ if not private_token:
47
+ raise FinalizeError("PrivToken 未设置(PRIV_TOKEN)")
48
+ h = {"X-Priv-Token": private_token, "x-invoke-skill": SKILL_NAME}
49
+ if content_type:
50
+ h["Content-Type"] = content_type
51
+ agent = os.environ.get("AGENT_NAME", "")
52
+ if agent:
53
+ h["x-invoke-agent"] = agent
54
+ if conversation_id:
55
+ h["x-conversation-id"] = conversation_id
56
+ return h
57
+
58
+
59
+ def _submit(
60
+ local_path: str,
61
+ *,
62
+ private_token: str,
63
+ base_url: str,
64
+ transcode: bool,
65
+ cover: bool,
66
+ cover_at_sec: float,
67
+ title: Optional[str],
68
+ resolve_cdn: bool,
69
+ keep_tail_sec: Optional[float],
70
+ conversation_id: Optional[str],
71
+ request_timeout: float,
72
+ ) -> str:
73
+ """POST /finalize(原始字节 + query options)→ 返回 taskId。"""
74
+ ext = Path(local_path).suffix.lstrip(".").lower() or "webm"
75
+ query = {
76
+ "transcode": "1" if transcode else "0",
77
+ "cover": "1" if cover else "0",
78
+ "coverAtSec": str(cover_at_sec),
79
+ "resolveCdn": "1" if resolve_cdn else "0",
80
+ "ext": ext,
81
+ }
82
+ if title:
83
+ query["title"] = title
84
+ # 端锚裁剪窗口:让 ab-render 在转码同一 pass 里切掉开头加载段(skill 侧不再裁)。
85
+ if keep_tail_sec and keep_tail_sec > 0:
86
+ query["keepTailSec"] = f"{keep_tail_sec:.3f}"
87
+ url = f"{base_url}/finalize?{urllib.parse.urlencode(query)}"
88
+
89
+ with open(local_path, "rb") as f:
90
+ body = f.read()
91
+
92
+ req = urllib.request.Request(
93
+ url,
94
+ data=body,
95
+ headers=_headers(private_token, content_type="application/octet-stream", conversation_id=conversation_id),
96
+ method="POST",
97
+ )
98
+ try:
99
+ with urllib.request.urlopen(req, timeout=request_timeout) as resp:
100
+ result = json.loads(resp.read().decode("utf-8"))
101
+ except urllib.error.HTTPError as exc:
102
+ detail = ""
103
+ try:
104
+ detail = exc.read().decode("utf-8")[:200]
105
+ except Exception:
106
+ pass
107
+ raise FinalizeError(f"/finalize HTTP {exc.code}: {detail}") from exc
108
+ except (urllib.error.URLError, OSError) as exc:
109
+ raise FinalizeError(f"/finalize 网络错误: {exc}") from exc
110
+
111
+ if result.get("code") != 0:
112
+ raise FinalizeError(f"/finalize code={result.get('code')}: {result.get('msg')}")
113
+ task_id = (result.get("data") or {}).get("taskId")
114
+ if not task_id:
115
+ raise FinalizeError(f"/finalize 未返回 taskId: {result}")
116
+ return str(task_id)
117
+
118
+
119
+ def _poll(
120
+ task_id: str,
121
+ *,
122
+ private_token: str,
123
+ base_url: str,
124
+ timeout: float,
125
+ interval: float,
126
+ request_timeout: float,
127
+ on_progress: Optional[Callable[[dict], None]],
128
+ ) -> dict:
129
+ """POST /finalizeStatus 轮询直到 succeeded/failed/超时。成功返回 data dict。"""
130
+ start = time.monotonic()
131
+ consecutive_errors = 0
132
+ while True:
133
+ if time.monotonic() - start > timeout:
134
+ raise FinalizeError(
135
+ f"/finalizeStatus 轮询超时({timeout:.0f}s, taskId={task_id});任务可能仍在后端运行"
136
+ )
137
+ try:
138
+ req = urllib.request.Request(
139
+ f"{base_url}/finalizeStatus",
140
+ data=json.dumps({"taskId": task_id}).encode("utf-8"),
141
+ headers=_headers(private_token, content_type="application/json", conversation_id=None),
142
+ method="POST",
143
+ )
144
+ with urllib.request.urlopen(req, timeout=request_timeout) as resp:
145
+ result = json.loads(resp.read().decode("utf-8"))
146
+ consecutive_errors = 0
147
+ except (urllib.error.URLError, OSError, ConnectionError) as exc:
148
+ consecutive_errors += 1
149
+ if consecutive_errors >= 5:
150
+ raise FinalizeError(f"/finalizeStatus 连续网络错误,放弃(taskId={task_id}): {exc}") from exc
151
+ time.sleep(interval)
152
+ continue
153
+
154
+ if result.get("code") != 0:
155
+ raise FinalizeError(f"/finalizeStatus code={result.get('code')}: {result.get('msg')}")
156
+ data = result.get("data") or {}
157
+ if on_progress is not None:
158
+ try:
159
+ on_progress(data)
160
+ except Exception:
161
+ pass
162
+
163
+ status = str(data.get("status") or "").lower()
164
+ if status in TERMINAL_SUCCESS:
165
+ return data
166
+ if status in TERMINAL_FAILURE:
167
+ raise FinalizeError(f"收尾失败({status}): {data.get('error') or 'unknown'}")
168
+ time.sleep(interval)
169
+
170
+
171
+ def finalize_recording(
172
+ local_path: str,
173
+ *,
174
+ private_token: str,
175
+ transcode: bool = True,
176
+ cover: bool = True,
177
+ cover_at_sec: float = 0.5,
178
+ title: Optional[str] = None,
179
+ resolve_cdn: bool = True,
180
+ keep_tail_sec: Optional[float] = None,
181
+ base_url: Optional[str] = None,
182
+ conversation_id: Optional[str] = None,
183
+ on_progress: Optional[Callable[[dict], None]] = None,
184
+ poll_timeout: float = 300.0,
185
+ poll_interval: float = 3.0,
186
+ request_timeout: float = 300.0,
187
+ ) -> dict:
188
+ """把本地视频推给 ab-render 收尾并轮询到终态。
189
+
190
+ keep_tail_sec:端锚裁剪窗口(保留视频尾部这么多秒,切掉开头加载段);由 skill 测量的
191
+ 内容窗口得出,None 表示不裁。裁剪与转码在 ab-render 合并成一次 pass。
192
+
193
+ 返回 finalizeStatus 的 data dict,关键字段:
194
+ fileUrl(CDN 或 vod://)、vodRef、fileId、coverUrl、durationSec。
195
+ 失败抛 FinalizeError,由调用方决定降级。
196
+ """
197
+ base = _base_url(base_url)
198
+ # 诊断:明确打出实际请求的 ab-render 地址 + 裁剪窗口,便于排查「发去哪/有没有裁」。
199
+ print(
200
+ f"[record] finalize target: {base} keepTailSec={keep_tail_sec}",
201
+ file=sys.stderr,
202
+ flush=True,
203
+ )
204
+ task_id = _submit(
205
+ local_path,
206
+ private_token=private_token,
207
+ base_url=base,
208
+ transcode=transcode,
209
+ cover=cover,
210
+ cover_at_sec=cover_at_sec,
211
+ title=title,
212
+ resolve_cdn=resolve_cdn,
213
+ keep_tail_sec=keep_tail_sec,
214
+ conversation_id=conversation_id,
215
+ request_timeout=request_timeout,
216
+ )
217
+ return _poll(
218
+ task_id,
219
+ private_token=private_token,
220
+ base_url=base,
221
+ timeout=poll_timeout,
222
+ interval=poll_interval,
223
+ request_timeout=60.0,
224
+ on_progress=on_progress,
225
+ )
226
+
227
+
228
+ __all__ = ["finalize_recording", "FinalizeError"]
@@ -23,6 +23,7 @@ from _media_screenshot import ( # noqa: E402
23
23
  template,
24
24
  trim,
25
25
  )
26
+ from _media_upload import finalize_recording # noqa: E402
26
27
 
27
28
 
28
29
  def main() -> None:
@@ -72,6 +73,21 @@ def main() -> None:
72
73
  help="列出可用模板并退出",
73
74
  )
74
75
 
76
+ # 收尾(transcode + 封面 + VOD 上传,默认开;委托 ab-render /finalize)
77
+ ap.add_argument(
78
+ "--no-upload", action="store_true",
79
+ help="录完不上传 VOD,只保留本地 webm(默认会转码+封面+上传并返回 CDN 地址)",
80
+ )
81
+ ap.add_argument("--vod-title", help="上传到 VOD 的标题(默认取输出文件名)")
82
+ ap.add_argument(
83
+ "--cover-at-sec", type=float, default=0.5,
84
+ help="封面抽帧时间点(秒,默认 0.5,避开首帧白屏)",
85
+ )
86
+ ap.add_argument(
87
+ "--keep-webm", action="store_true",
88
+ help="上传成功后保留本地 webm(默认删除,VOD 已持有)",
89
+ )
90
+
75
91
  cli_args.add_common_args(ap)
76
92
 
77
93
  args = ap.parse_args()
@@ -166,9 +182,8 @@ def main() -> None:
166
182
  try:
167
183
  if is_storyboard:
168
184
  out, start_ms, end_ms = do_storyboard(cfg)
169
- trim.trim_video(out, start_ms, end_ms)
170
185
  else:
171
- out = do_record(cfg)
186
+ out, start_ms, end_ms = do_record(cfg)
172
187
  finally:
173
188
  if is_temp and storage_path:
174
189
  try:
@@ -179,7 +194,88 @@ def main() -> None:
179
194
  if not out.exists():
180
195
  print(f"录屏未生成:{out}", file=sys.stderr)
181
196
  sys.exit(1)
182
- print(str(out))
197
+
198
+ local_path = str(out)
199
+
200
+ # 端锚裁剪窗口:保留内容窗口 + 边距(与 _media_screenshot/trim.py 的常量一致)。
201
+ # 上传路径把它交给 ab-render 在转码同一 pass 里裁;--no-upload 单机路径才本地裁。
202
+ keep_tail_sec = None
203
+ if end_ms > start_ms:
204
+ keep_tail_sec = ((end_ms - start_ms) + 80 + 200) / 1000.0
205
+
206
+ # ── 收尾:转码 + 封面 + 上传 VOD(默认开)───────────────────────────────
207
+ # ffmpeg / OSS 全部在 ab-render 侧完成,本 skill 只推文件 + 轮询状态。
208
+ # 环境缺失(如裸跑 CLI 无 PRIV_TOKEN)或收尾失败 → 优雅降级为「仅本地文件」。
209
+ upload_enabled = not args.no_upload
210
+ priv_token = os.environ.get("PRIV_TOKEN", "")
211
+ if upload_enabled and not priv_token:
212
+ print("[record] 未设置 PRIV_TOKEN,跳过 VOD 上传,仅保留本地文件。", file=sys.stderr)
213
+ upload_enabled = False
214
+
215
+ if not upload_enabled:
216
+ # 单机 / 不上传:没有 ab-render 帮忙裁,就在本地尽力裁一刀(需系统 ffmpeg,
217
+ # 缺失则 trim.trim_video 保留原片并提示)。
218
+ trim.trim_video(out, start_ms, end_ms)
219
+ print(local_path)
220
+ return
221
+
222
+ def _on_progress(data: dict) -> None:
223
+ # finalizeStatus 的 phase/progress → __progress__,喂 ab-agent SSE 时间线
224
+ line: dict = {"__progress__": True, "phase": data.get("phase")}
225
+ prog = data.get("progress")
226
+ if isinstance(prog, (int, float)):
227
+ line["progress"] = float(prog)
228
+ print(json.dumps(line), flush=True)
229
+
230
+ conversation_id = os.environ.get("CONVERSATION_ID") or None
231
+ title = args.vod_title or Path(local_path).stem
232
+ try:
233
+ result = finalize_recording(
234
+ local_path,
235
+ private_token=priv_token,
236
+ transcode=True,
237
+ cover=True,
238
+ cover_at_sec=args.cover_at_sec,
239
+ title=title,
240
+ resolve_cdn=True,
241
+ keep_tail_sec=keep_tail_sec,
242
+ conversation_id=conversation_id,
243
+ on_progress=_on_progress,
244
+ )
245
+ except Exception as e: # noqa: BLE001 — 收尾失败不应丢掉已录好的本地文件
246
+ print(f"[record] VOD 上传收尾失败,降级为仅本地文件:{e}", file=sys.stderr)
247
+ print(local_path)
248
+ return
249
+
250
+ cdn_url = result.get("fileUrl") or ""
251
+ cover_url = result.get("coverUrl") or ""
252
+ duration = result.get("durationSec")
253
+ vod_ref = result.get("vodRef") or ""
254
+ file_id = result.get("fileId")
255
+
256
+ # 上传成功,默认删本地 webm(VOD 已持有),除非 --keep-webm
257
+ if not args.keep_webm:
258
+ try:
259
+ os.unlink(local_path)
260
+ except OSError:
261
+ pass
262
+
263
+ print("✅ 录屏已生成并上传 VOD")
264
+ if cdn_url:
265
+ print(f"🔗 视频地址:{cdn_url}")
266
+ if cover_url:
267
+ print(f"🖼️ 封面:{cover_url}")
268
+ if isinstance(duration, (int, float)):
269
+ print(f"🕐 时长:{duration:.1f}s")
270
+ # 结构化 asset 标记:ab-agent postcall 解析这一行下发前端播放器
271
+ asset = {
272
+ "url": cdn_url,
273
+ "coverUrl": cover_url,
274
+ "durationSec": duration,
275
+ "vodRef": vod_ref,
276
+ "fileId": file_id,
277
+ }
278
+ print("__web_record_asset__ " + json.dumps(asset, ensure_ascii=False))
183
279
 
184
280
 
185
281
  if __name__ == "__main__":
@@ -50,6 +50,21 @@ def main() -> None:
50
50
  cli_args.add_common_args(ap)
51
51
 
52
52
  args = ap.parse_args()
53
+
54
+ # 截屏只出图片。若 --output 是视频后缀,Playwright 的 page.screenshot() 会按扩展名
55
+ # 推断出 video/webm 并抛一串晦涩的 mime 错误。这里提前拦截,返回面向模型的清晰指引,
56
+ # 把请求导向 web_record(录屏入口)。
57
+ video_exts = (".webm", ".mp4", ".mov", ".mkv", ".avi", ".gif")
58
+ if args.output.lower().endswith(video_exts):
59
+ ext = Path(args.output).suffix or "(video)"
60
+ print(
61
+ f"截屏入口只输出静态图片(.png/.jpg),不能生成 {ext} 视频。"
62
+ "请改用 web_record 工具录屏(固定时长 / 自动滚动 / storyboard),"
63
+ "或把 --output 换成 .png / .jpg 后缀。",
64
+ file=sys.stderr,
65
+ )
66
+ sys.exit(2)
67
+
53
68
  storage_path, is_temp = cli_args.build_storage(args)
54
69
 
55
70
  cfg: dict = {
@@ -4,14 +4,14 @@
4
4
  "tier": "tool",
5
5
  "category": "consuming",
6
6
  "title": "Web Page Capture",
7
- "description": "Drive a headless browser (Playwright Python) to capture any URL to local files: full-page / element / region screenshots, fixed-duration / condition-triggered / auto-scroll recordings, and storyboard videos (highlight / focus / zoom / scroll / virtual-cursor click / caption / title-card / arrow / numbered sequence / redact / code-line highlight). Two entry scripts: scripts/screenshot.py (stills) and scripts/record.py (webm video, storyboards, templates).",
7
+ "description": "Drive a headless browser (Playwright Python) to capture any URL to a local STILL IMAGE (png/jpg): full-page / viewport / element / region screenshots, with device emulation, waiting, hide/mask/redact, and static annotations (highlight / arrow / caption / numbered sequence / redact). Images only for video / recording / scroll-through / storyboard clips (webm) use the web_record tool instead.",
8
8
  "envVars": ["WEB_CAPTURE_BROWSER", "WEB_CAPTURE_OUTPUT_BASE", "WEB_CAPTURE_NO_SANDBOX", "WEB_CAPTURE_ALLOW_PRIVATE_HOSTS", "PLAYWRIGHT_BROWSERS_PATH"],
9
9
  "entry": { "type": "python", "scriptPath": "scripts/screenshot.py" },
10
10
  "parameters": {
11
11
  "type": "object",
12
12
  "properties": {
13
13
  "url": { "type": "string", "description": "Target page URL (http/https)" },
14
- "output": { "type": "string", "description": "Local output path (screenshot.py: *.png, default screenshot.png)" },
14
+ "output": { "type": "string", "description": "Local output path, must be an image (*.png / *.jpg, default screenshot.png). For .webm video use the web_record tool." },
15
15
  "browser": { "type": "string", "description": "chromium | firefox | webkit (default chromium)" },
16
16
  "device": { "type": "string", "description": "Device emulation name, e.g. 'iPhone 15 Pro'" },
17
17
  "viewport": { "type": "string", "description": "Viewport as 'width,height', e.g. '1280,800'" },
@@ -0,0 +1,61 @@
1
+ ---
2
+ name: web-record
3
+ description: |
4
+ 网页录屏 / 分镜视频技能(输出 webm),基于 Playwright Python,对任意 URL 落盘到本地。
5
+ 支持:固定时长 / 条件触发(元素出现 / 消失)停止、自动滚动录屏(从头划到尾)、自定义分镜(storyboard)、参数化模板。
6
+ 分镜场景:高亮 / 聚焦 / 放大 / 滚动 / 虚拟光标点击 / 打字 / 悬停 / 字幕 / 标题卡 / 箭头 / 编号序列 / 打码 / 代码行高亮。
7
+
8
+ 只出视频(.webm)。要静态截图(png/jpg)请用 web-capture / web_capture。
9
+
10
+ 当用户提到以下任何需求时,立即使用本 skill:
11
+ - 录屏、网页录制、录制视频、录一段操作、生成 webm、screencast
12
+ - 滚动录屏、页面从头划到尾的视频
13
+ - 多场景视频、分镜视频、storyboard、按时间线编排(红框高亮 / 局部放大 / 滚动介绍)
14
+ - 演示点击 / 输入 / 悬停(虚拟鼠标 + 涟漪 / 打字机 / 触发 tooltip)的动态视频
15
+ - 用模板一行出片(github-repo-intro / github-readme-tour / github-code-tour 等)
16
+ triggers:
17
+ - 录屏、网页录制、录制视频、录一段操作、生成 webm、screencast
18
+ - 滚动录屏、页面从头划到尾的视频
19
+ - 多场景视频、分镜视频、storyboard、按时间线编排
20
+ - 演示点击 / 输入 / 悬停(虚拟鼠标 + 涟漪 / 打字机 / 触发 tooltip)
21
+ - 模板视频一行出片、按顺序编号高亮多个区域并录制
22
+ ---
23
+
24
+ # 网页录屏 / 分镜视频 Skill(web_record)
25
+
26
+ 录屏入口,输出 **`.webm`**。底层复用 web-capture 的 `scripts/record.py` 与 `_media_screenshot/` 包
27
+ (本 skill 的 `entry.scriptPath` 相对指向 `../web-capture/scripts/record.py`)。静态截图请改用
28
+ **web-capture / `web_capture`**。
29
+
30
+ 完整的参数说明、storyboard 场景类型、模板列表与用法,见 **web-capture 的 `SKILL.md`「录屏 `record.py`」章节**——本 skill 就是把那条录屏路径独立成一个工具,避免把 `.webm` 误喂给截图入口。
31
+
32
+ ## 常见用法
33
+
34
+ 固定时长:
35
+
36
+ ```bash
37
+ python3 <SkillDir>/../web-capture/scripts/record.py \
38
+ --url "https://example.com" --output "demo.webm" \
39
+ --viewport "1280,720" --duration 8000
40
+ ```
41
+
42
+ 自动滚动(从头划到尾):
43
+
44
+ ```bash
45
+ python3 <SkillDir>/../web-capture/scripts/record.py \
46
+ --url "https://example.com" --output "scroll.webm" \
47
+ --viewport "1440,900" --scroll-through \
48
+ --scroll-pause-top 1000 --scroll-pause-bottom 1500
49
+ ```
50
+
51
+ 模板出片:
52
+
53
+ ```bash
54
+ python3 <SkillDir>/../web-capture/scripts/record.py \
55
+ --template github-repo-intro \
56
+ --param repo_url=https://github.com/user/repo \
57
+ --output repo.webm
58
+ ```
59
+
60
+ 至少需提供 `--duration` / `--stop-when-selector` / `--stop-when-hidden` / `--scroll-through`
61
+ / `--storyboard` / `--template` 之一。模板 / storyboard JSON 可自带 `url`。
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "web-record",
3
+ "toolName": "web_record",
4
+ "tier": "tool",
5
+ "category": "consuming",
6
+ "title": "Web Page Recording",
7
+ "description": "Drive a headless browser (Playwright Python) to RECORD any URL to a video, then (by default) transcode to mp4, grab a cover frame, upload to VOD and return a playable CDN URL. Modes: fixed-duration recording, condition-triggered stop (element appears / disappears), auto-scroll from top to bottom, custom storyboards, and parameterized templates. Storyboard scenes: highlight / focus / zoom / scroll / virtual-cursor click / type / hover / caption / title-card / arrow / numbered sequence / redact / code-line highlight. For still images (png/jpg) use the web_capture tool instead. Use this tool whenever the user wants a video / recording / screencast of a web page: record a page, scroll-through video, page-from-top-to-bottom clip, demo of clicks/typing/hover, storyboard / multi-scene intro video, or a template-based clip.",
8
+ "envVars": ["WEB_CAPTURE_BROWSER", "WEB_CAPTURE_OUTPUT_BASE", "WEB_CAPTURE_NO_SANDBOX", "WEB_CAPTURE_ALLOW_PRIVATE_HOSTS", "PLAYWRIGHT_BROWSERS_PATH", "REMOTION_RENDER_API_URL", "PRIV_TOKEN"],
9
+ "entry": { "type": "python", "scriptPath": "../web-capture/scripts/record.py" },
10
+ "parameters": {
11
+ "type": "object",
12
+ "properties": {
13
+ "url": { "type": "string", "description": "Target page URL (http/https). May be omitted only when a template or storyboard JSON supplies its own url." },
14
+ "output": { "type": "string", "description": "Local output path, must end in .webm (default recording.webm)" },
15
+ "browser": { "type": "string", "description": "chromium | firefox | webkit (default chromium; chromium recommended for recording)" },
16
+ "device": { "type": "string", "description": "Device emulation name, e.g. 'iPhone 15 Pro'" },
17
+ "viewport": { "type": "string", "description": "Viewport as 'width,height', e.g. '1280,720'" },
18
+ "duration": { "type": "number", "description": "Fixed recording length in ms" },
19
+ "stop_when_selector": { "type": "string", "description": "Stop recording once this CSS selector appears" },
20
+ "stop_when_hidden": { "type": "string", "description": "Stop recording once this CSS selector disappears" },
21
+ "max_duration": { "type": "number", "description": "Safety cap (ms) for condition-based stop modes (default 60000)" },
22
+ "scroll_through": { "type": "boolean", "description": "Auto-scroll smoothly from top to bottom while recording" },
23
+ "scroll_step": { "type": "number", "description": "Pixels per scroll step (default 60)" },
24
+ "scroll_interval": { "type": "number", "description": "Interval between scroll steps in ms (default 50)" },
25
+ "scroll_pause_top": { "type": "number", "description": "Pause at the top before scrolling, ms (default 800)" },
26
+ "scroll_pause_bottom": { "type": "number", "description": "Pause at the bottom after scrolling, ms (default 1200)" },
27
+ "storyboard": { "type": "string", "description": "Path to a storyboard JSON file describing scenes (mutually exclusive with template)" },
28
+ "template": { "type": "string", "description": "Template name under templates/ (e.g. github-repo-intro); combine with param (mutually exclusive with storyboard)" },
29
+ "param": { "type": "array", "description": "Template params as 'key=value' strings; repeatable" },
30
+ "list_templates": { "type": "boolean", "description": "List available templates and exit" },
31
+ "wait_for_selector": { "type": "string", "description": "Wait for this CSS selector before recording" },
32
+ "wait_for_timeout": { "type": "number", "description": "Fixed wait (ms) before recording" },
33
+ "color_scheme": { "type": "string", "description": "light | dark | no-preference (emulate prefers-color-scheme)" },
34
+ "user_agent": { "type": "string", "description": "Override User-Agent" },
35
+ "timeout": { "type": "number", "description": "Playwright global action timeout in ms" },
36
+ "ignore_https_errors": { "type": "boolean", "description": "Ignore HTTPS certificate errors" },
37
+ "storage_state": { "type": "string", "description": "storageState JSON file path (logged-in recording)" },
38
+ "cookies": { "type": "string", "description": "Playwright cookies JSON string or file (top-level array)" },
39
+ "no_upload": { "type": "boolean", "description": "Skip VOD upload; keep only the local webm. By default the recording is transcoded to mp4, a cover frame is grabbed, uploaded to VOD, and a playable CDN URL is returned." },
40
+ "vod_title": { "type": "string", "description": "Title for the uploaded VOD asset (defaults to the output file name)" },
41
+ "cover_at_sec": { "type": "number", "description": "Cover frame timestamp in seconds (default 0.5)" },
42
+ "keep_webm": { "type": "boolean", "description": "Keep the local webm after a successful upload (default: delete it since VOD holds the copy)" }
43
+ },
44
+ "required": ["url"]
45
+ }
46
+ }