@remixmate/cli 0.9.8 → 0.9.9
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/dist/manifest.json +31 -3
- package/package.json +1 -1
- package/skills/web-capture/scripts/_media_screenshot/recording.py +20 -3
- package/skills/web-capture/scripts/_media_screenshot/screenshot.py +18 -0
- package/skills/web-capture/scripts/_media_upload.py +228 -0
- package/skills/web-capture/scripts/record.py +99 -3
- package/skills/web-capture/scripts/screenshot.py +15 -0
- package/skills/web-capture/skill.json +2 -2
- package/skills/web-record/SKILL.md +61 -0
- package/skills/web-record/skill.json +46 -0
package/dist/manifest.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 1,
|
|
3
|
-
"version": "0.9.
|
|
4
|
-
"generatedAt": "2026-07-
|
|
3
|
+
"version": "0.9.9",
|
|
4
|
+
"generatedAt": "2026-07-26T13:09:06.488Z",
|
|
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
|
|
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.
|
|
3
|
+
"version": "0.9.9",
|
|
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",
|
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 (
|
|
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
|
+
}
|