@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,461 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ 网页捕获脚本 — 驱动无头浏览器(Playwright)对网页进行截图或滚动录屏。
4
+
5
+ 三种模式:
6
+ - screenshot 截视口 / 整页 / 指定元素 → PNG
7
+ - scroll-video 从顶到底平滑滚动,逐帧截图后用 ffmpeg 合成确定性 MP4
8
+ - record-video Playwright 原生 context 录制(webm),再 ffmpeg 转 MP4
9
+
10
+ 产物(PNG / MP4)用于喂给 screen-walkthrough 等模板。
11
+
12
+ 依赖(运行时提供,skill 不打包浏览器):
13
+ - Python3 + Playwright: pip install playwright && playwright install chromium
14
+ - ffmpeg(视频模式): brew install ffmpeg
15
+
16
+ 用法:
17
+ python web_capture.py --url "https://example.com"
18
+ python web_capture.py --url "https://example.com" --mode screenshot --full-page
19
+ python web_capture.py --url "https://github.com/x/y" --mode scroll-video --fps 30 --scroll-duration 8
20
+ python web_capture.py --url "https://example.com" --mode record-video
21
+ python web_capture.py --url "https://example.com" --json-output
22
+
23
+ 环境变量:
24
+ WEB_CAPTURE_BROWSER - 默认浏览器(chromium/firefox/webkit),--browser 优先
25
+ PLAYWRIGHT_BROWSERS_PATH - Playwright 浏览器二进制目录(可选,由运行环境提供)
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import argparse
31
+ import ipaddress
32
+ import json
33
+ import math
34
+ import os
35
+ import shutil
36
+ import socket
37
+ import subprocess
38
+ import sys
39
+ import tempfile
40
+ from pathlib import Path
41
+ from urllib.parse import urlsplit
42
+
43
+ SKILL_NAME = "web-capture"
44
+
45
+ # 多用户并发下,每个进程独立浏览器实例 + 独立输出目录即可隔离。
46
+ # 默认输出落系统临时目录,避免 cwd(skills/web-capture/scripts)被多用户产物污染。
47
+ OUTPUT_PREFIX = "web_capture_"
48
+
49
+ # 是否静默模式(--json-output 时抑制人类可读进度输出,仍发结构化 __progress__)
50
+ QUIET = False
51
+
52
+
53
+ def log(msg: str) -> None:
54
+ """打印进度信息到 stderr(静默模式下不输出)。stdout 仅留给 --json-output 的最终 JSON。"""
55
+ if not QUIET:
56
+ print(msg, file=sys.stderr, flush=True)
57
+
58
+
59
+ def log_err(msg: str) -> None:
60
+ """错误信息始终输出到 stderr。"""
61
+ print(msg, file=sys.stderr, flush=True)
62
+
63
+
64
+ def emit_progress(phase: str, progress: float | None = None, **extra) -> None:
65
+ """输出一行 __progress__ JSON 到 stdout,供 ab-agent executor 解析并转发 SSE。
66
+
67
+ 与 src/progress.ts 约定一致:额外字段直接与 phase 平级。
68
+ 注意:--json-output 模式下 stdout 需保持纯净(只输出最终 manifest),
69
+ 因此静默模式下不发结构化进度,避免污染管道。
70
+ """
71
+ if QUIET:
72
+ return
73
+ payload = {"__progress__": True, "phase": phase}
74
+ if progress is not None:
75
+ payload["progress"] = round(progress, 4)
76
+ payload.update(extra)
77
+ print(json.dumps(payload, ensure_ascii=False), flush=True)
78
+
79
+
80
+ class CaptureError(Exception):
81
+ """受控的失败:以非 0 退出并打印简洁错误。"""
82
+
83
+ def __init__(self, message: str, exit_code: int = 1):
84
+ super().__init__(message)
85
+ self.exit_code = exit_code
86
+
87
+
88
+ # ─────────────────────────── 预检 ───────────────────────────
89
+
90
+ def preflight(mode: str):
91
+ """检查 Playwright 与(视频模式)ffmpeg 是否就绪,返回 sync_playwright 工厂。"""
92
+ emit_progress("web-capture:preflight")
93
+ try:
94
+ from playwright.sync_api import sync_playwright # noqa: F401
95
+ except ImportError as exc:
96
+ raise CaptureError(
97
+ "Playwright 未安装。请运行:\n"
98
+ " pip install playwright && playwright install chromium",
99
+ exit_code=2,
100
+ ) from exc
101
+
102
+ if mode in ("scroll-video", "record-video") and shutil.which("ffmpeg") is None:
103
+ raise CaptureError(
104
+ "未检测到 ffmpeg(视频模式必需)。请运行:\n brew install ffmpeg",
105
+ exit_code=3,
106
+ )
107
+
108
+ from playwright.sync_api import sync_playwright
109
+
110
+ return sync_playwright
111
+
112
+
113
+ def ensure_output_dir(output_dir: str | None) -> Path:
114
+ """显式指定则用之;否则在系统临时目录下创建唯一目录(多用户并发隔离)。
115
+
116
+ 旧实现用 ./web_capture_<秒级时间戳>,同一秒内的并发调用会互相覆盖,且产物
117
+ 堆在脚本所在目录(污染 skills/ 并触发 AGENTS.md 的 output/ 守卫)。改用
118
+ mkdtemp 保证目录唯一,且默认落系统 tempdir。对齐 render_video 的 tempdir 范式。
119
+ """
120
+ if output_dir:
121
+ path = Path(output_dir).expanduser().resolve()
122
+ path.mkdir(parents=True, exist_ok=True)
123
+ return path
124
+ base = os.environ.get("WEB_CAPTURE_OUTPUT_BASE") or None
125
+ return Path(tempfile.mkdtemp(prefix=OUTPUT_PREFIX, dir=base)).resolve()
126
+
127
+
128
+ # ─────────────────────────── URL 校验(防 SSRF)───────────────────────────
129
+
130
+ def validate_url(url: str) -> None:
131
+ """多用户场景下,url 来自用户输入,必须挡住 SSRF:
132
+ - 仅允许 http/https
133
+ - 禁止解析到内网 / 环回 / 链路本地 / 云元数据(169.254.169.254)等地址
134
+
135
+ 可用 WEB_CAPTURE_ALLOW_PRIVATE_HOSTS=1 关闭内网拦截(单租户/内网自测场景)。
136
+ """
137
+ parts = urlsplit(url)
138
+ if parts.scheme not in ("http", "https"):
139
+ raise CaptureError(f"仅支持 http/https URL,收到:{parts.scheme or '(空)'}://", exit_code=5)
140
+ host = parts.hostname
141
+ if not host:
142
+ raise CaptureError("URL 缺少主机名", exit_code=5)
143
+
144
+ if os.environ.get("WEB_CAPTURE_ALLOW_PRIVATE_HOSTS") == "1":
145
+ return
146
+
147
+ # 解析所有 A/AAAA 记录,任一落在受限网段即拒绝(防 DNS 把公网名指到内网)。
148
+ try:
149
+ infos = socket.getaddrinfo(host, parts.port or (443 if parts.scheme == "https" else 80),
150
+ proto=socket.IPPROTO_TCP)
151
+ except socket.gaierror as exc:
152
+ raise CaptureError(f"无法解析主机:{host}({exc})", exit_code=5) from exc
153
+
154
+ for info in infos:
155
+ ip_str = info[4][0]
156
+ try:
157
+ ip = ipaddress.ip_address(ip_str)
158
+ except ValueError:
159
+ continue
160
+ if (ip.is_private or ip.is_loopback or ip.is_link_local
161
+ or ip.is_reserved or ip.is_multicast or ip.is_unspecified):
162
+ raise CaptureError(
163
+ f"目标地址 {host} 解析到受限网段 {ip_str}(疑似内网/元数据),已拒绝。"
164
+ f"\n如确为可信内网目标,可设 WEB_CAPTURE_ALLOW_PRIVATE_HOSTS=1。",
165
+ exit_code=5,
166
+ )
167
+
168
+
169
+ def launch_browser(playwright, browser_name: str):
170
+ """启动指定浏览器;缺二进制时给出可执行的安装提示。
171
+
172
+ 默认保留浏览器沙箱(多用户 + 访问任意 URL 时,关沙箱会让浏览器漏洞更易逃逸到主机)。
173
+ 仅当显式设置 WEB_CAPTURE_NO_SANDBOX=1(如某些无法配置 user namespace 的容器)才关闭。
174
+ """
175
+ engine = getattr(playwright, browser_name, None)
176
+ if engine is None:
177
+ raise CaptureError(f"未知浏览器引擎:{browser_name}(可选 chromium/firefox/webkit)")
178
+ launch_args: list[str] = []
179
+ if browser_name == "chromium":
180
+ launch_args.append("--disable-dev-shm-usage")
181
+ if os.environ.get("WEB_CAPTURE_NO_SANDBOX") == "1":
182
+ launch_args.append("--no-sandbox")
183
+ try:
184
+ return engine.launch(headless=True, args=launch_args)
185
+ except Exception as exc: # noqa: BLE001
186
+ msg = str(exc)
187
+ if "Executable doesn't exist" in msg or "playwright install" in msg:
188
+ raise CaptureError(
189
+ f"{browser_name} 浏览器二进制缺失。请运行:\n playwright install {browser_name}",
190
+ exit_code=4,
191
+ ) from exc
192
+ raise CaptureError(f"启动浏览器失败:{msg}") from exc
193
+
194
+
195
+ # ─────────────────────────── 页面准备 ───────────────────────────
196
+
197
+ def goto_and_settle(page, url: str, wait_until: str, wait_ms: int) -> str:
198
+ """导航到 url,等待加载与额外静置,返回 finalUrl。"""
199
+ emit_progress("web-capture:navigate", url=url)
200
+ try:
201
+ page.goto(url, wait_until=wait_until, timeout=60_000)
202
+ except Exception as exc: # noqa: BLE001
203
+ # networkidle 在某些长轮询页面永远不触发,降级到 load 再试一次
204
+ if wait_until == "networkidle":
205
+ log("⚠️ networkidle 超时,降级到 load 重试")
206
+ page.goto(url, wait_until="load", timeout=60_000)
207
+ else:
208
+ raise CaptureError(f"页面导航失败:{exc}") from exc
209
+ if wait_ms > 0:
210
+ page.wait_for_timeout(wait_ms)
211
+ return page.url
212
+
213
+
214
+ def get_page_height(page) -> int:
215
+ return int(page.evaluate(
216
+ "() => Math.max(document.body.scrollHeight, document.documentElement.scrollHeight,"
217
+ " document.body.offsetHeight, document.documentElement.offsetHeight)"
218
+ ))
219
+
220
+
221
+ # ─────────────────────────── 模式实现 ───────────────────────────
222
+
223
+ def do_screenshot(page, out_dir: Path, full_page: bool, selector: str | None) -> dict:
224
+ emit_progress("web-capture:screenshot", full_page=full_page)
225
+ target = out_dir / "screenshot.png"
226
+ if selector:
227
+ el = page.query_selector(selector)
228
+ if el is None:
229
+ raise CaptureError(f"未找到选择器对应元素:{selector}")
230
+ el.screenshot(path=str(target))
231
+ else:
232
+ page.screenshot(path=str(target), full_page=full_page)
233
+ return {
234
+ "type": "image",
235
+ "localPath": str(target),
236
+ "fullPage": bool(full_page and not selector),
237
+ "selector": selector,
238
+ }
239
+
240
+
241
+ def do_scroll_video(page, out_dir: Path, viewport: dict, fps: int,
242
+ scroll_duration: float) -> dict:
243
+ """逐帧截图 + ffmpeg 合成确定性 MP4。fps / 分辨率 / 滚动速度全可控。"""
244
+ frames_dir = out_dir / "_frames"
245
+ frames_dir.mkdir(parents=True, exist_ok=True)
246
+ try:
247
+ total_frames = max(1, int(round(fps * scroll_duration)))
248
+ vh = viewport["height"]
249
+ page_height = get_page_height(page)
250
+ max_scroll = max(0, page_height - vh)
251
+
252
+ emit_progress("web-capture:scroll-capture", progress=0.0,
253
+ total=total_frames, pageHeight=page_height)
254
+
255
+ for i in range(total_frames):
256
+ # ease-in-out 让滚动更自然
257
+ t = i / (total_frames - 1) if total_frames > 1 else 1.0
258
+ eased = 0.5 - 0.5 * math.cos(math.pi * t)
259
+ y = int(round(max_scroll * eased))
260
+ page.evaluate(f"window.scrollTo(0, {y})")
261
+ page.wait_for_timeout(int(1000 / fps))
262
+ frame_path = frames_dir / f"frame_{i:05d}.png"
263
+ page.screenshot(path=str(frame_path))
264
+ if i % max(1, total_frames // 10) == 0:
265
+ emit_progress("web-capture:scroll-capture",
266
+ progress=(i + 1) / total_frames, current=i + 1, total=total_frames)
267
+
268
+ emit_progress("web-capture:encode", progress=0.0)
269
+ target = out_dir / "scroll.mp4"
270
+ _ffmpeg_from_frames(frames_dir, target, fps)
271
+ finally:
272
+ # 无论成功 / ffmpeg 失败 / 异常,都清掉逐帧临时图,避免临时目录堆积。
273
+ shutil.rmtree(frames_dir, ignore_errors=True)
274
+
275
+ return {
276
+ "type": "video",
277
+ "localPath": str(target),
278
+ "fps": fps,
279
+ "durationSec": round(total_frames / fps, 3),
280
+ "frames": total_frames,
281
+ }
282
+
283
+
284
+ def _ffmpeg_from_frames(frames_dir: Path, target: Path, fps: int) -> None:
285
+ cmd = [
286
+ "ffmpeg", "-y",
287
+ "-framerate", str(fps),
288
+ "-i", str(frames_dir / "frame_%05d.png"),
289
+ # 偶数化宽高,yuv420p 保证主流播放器/Remotion 兼容
290
+ "-vf", "scale=trunc(iw/2)*2:trunc(ih/2)*2,format=yuv420p",
291
+ "-c:v", "libx264",
292
+ "-preset", "medium",
293
+ "-crf", "18",
294
+ "-movflags", "+faststart",
295
+ str(target),
296
+ ]
297
+ proc = subprocess.run(cmd, capture_output=True, text=True)
298
+ if proc.returncode != 0:
299
+ raise CaptureError(f"ffmpeg 合成失败:\n{proc.stderr[-800:]}")
300
+
301
+
302
+ def do_record_video(page, context, out_dir: Path, scroll_duration: float) -> dict:
303
+ """Playwright 原生录制:滚动一遍后关闭 page,取 webm 再转 mp4。"""
304
+ emit_progress("web-capture:record", progress=0.0)
305
+ page_height = get_page_height(page)
306
+ vh = int(page.evaluate("() => window.innerHeight"))
307
+ max_scroll = max(0, page_height - vh)
308
+
309
+ steps = max(1, int(scroll_duration * 10)) # 每 100ms 一步
310
+ for i in range(steps):
311
+ t = (i + 1) / steps
312
+ eased = 0.5 - 0.5 * math.cos(math.pi * t)
313
+ page.evaluate(f"window.scrollTo(0, {int(max_scroll * eased)})")
314
+ page.wait_for_timeout(100)
315
+
316
+ # 关闭 page/context 后 Playwright 才落盘 webm
317
+ video = page.video
318
+ page.close()
319
+ context.close()
320
+ if video is None:
321
+ raise CaptureError("Playwright 未产出录制文件(context 未开启 record_video_dir?)")
322
+ webm_path = Path(video.path())
323
+
324
+ emit_progress("web-capture:encode", progress=0.0)
325
+ target = out_dir / "scroll.mp4"
326
+ _ffmpeg_transcode(webm_path, target)
327
+ try:
328
+ webm_path.unlink(missing_ok=True)
329
+ except OSError:
330
+ pass
331
+
332
+ return {
333
+ "type": "video",
334
+ "localPath": str(target),
335
+ "source": "playwright-native",
336
+ "durationSec": round(scroll_duration, 3),
337
+ }
338
+
339
+
340
+ def _ffmpeg_transcode(src: Path, target: Path) -> None:
341
+ cmd = [
342
+ "ffmpeg", "-y",
343
+ "-i", str(src),
344
+ "-vf", "scale=trunc(iw/2)*2:trunc(ih/2)*2,format=yuv420p",
345
+ "-c:v", "libx264", "-preset", "medium", "-crf", "20",
346
+ "-movflags", "+faststart",
347
+ str(target),
348
+ ]
349
+ proc = subprocess.run(cmd, capture_output=True, text=True)
350
+ if proc.returncode != 0:
351
+ raise CaptureError(f"ffmpeg 转码失败:\n{proc.stderr[-800:]}")
352
+
353
+
354
+ # ─────────────────────────── 主流程 ───────────────────────────
355
+
356
+ def run(args) -> dict:
357
+ validate_url(args.url)
358
+ sync_playwright = preflight(args.mode)
359
+ out_dir = ensure_output_dir(args.output_dir)
360
+ viewport = {"width": args.width, "height": args.height,
361
+ "deviceScaleFactor": args.device_scale_factor}
362
+ rec_dir = out_dir / "_rec"
363
+
364
+ with sync_playwright() as pw:
365
+ browser = launch_browser(pw, args.browser)
366
+ try:
367
+ context_kwargs = {
368
+ "viewport": {"width": args.width, "height": args.height},
369
+ "device_scale_factor": args.device_scale_factor,
370
+ }
371
+ if args.mode == "record-video":
372
+ context_kwargs["record_video_dir"] = str(rec_dir)
373
+ context_kwargs["record_video_size"] = {"width": args.width, "height": args.height}
374
+
375
+ context = browser.new_context(**context_kwargs)
376
+ page = context.new_page()
377
+ final_url = goto_and_settle(page, args.url, args.wait_until, args.wait_ms)
378
+
379
+ if args.mode == "screenshot":
380
+ output = do_screenshot(page, out_dir, args.full_page, args.selector)
381
+ context.close()
382
+ elif args.mode == "scroll-video":
383
+ output = do_scroll_video(page, out_dir, viewport, args.fps, args.scroll_duration)
384
+ context.close()
385
+ elif args.mode == "record-video":
386
+ # do_record_video 内部会 page.close()/context.close() 以触发 webm 落盘
387
+ output = do_record_video(page, context, out_dir, args.scroll_duration)
388
+ else:
389
+ raise CaptureError(f"未知 mode:{args.mode}")
390
+ finally:
391
+ browser.close()
392
+ # 原生录制的中间 webm 目录无论成败都清理。
393
+ shutil.rmtree(rec_dir, ignore_errors=True)
394
+
395
+ manifest = {
396
+ "source": {"url": args.url, "finalUrl": final_url},
397
+ "mode": args.mode,
398
+ "viewport": viewport,
399
+ "outputs": [output],
400
+ }
401
+ manifest_path = out_dir / "capture.json"
402
+ manifest_path.write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8")
403
+ emit_progress("web-capture:done", progress=1.0, manifest=str(manifest_path))
404
+ return manifest
405
+
406
+
407
+ def build_parser() -> argparse.ArgumentParser:
408
+ p = argparse.ArgumentParser(
409
+ description="网页捕获:截图 / 滚动录屏(Playwright + ffmpeg)",
410
+ formatter_class=argparse.ArgumentDefaultsHelpFormatter,
411
+ )
412
+ p.add_argument("--url", required=True, help="目标页面 URL(http/https)")
413
+ p.add_argument("--mode", default="screenshot",
414
+ choices=["screenshot", "scroll-video", "record-video"],
415
+ help="捕获模式")
416
+ p.add_argument("-o", "--output-dir", default=None,
417
+ help="输出目录(默认在系统临时目录创建唯一目录,多用户隔离)")
418
+ p.add_argument("--width", type=int, default=1280, help="视口宽度 px")
419
+ p.add_argument("--height", type=int, default=720, help="视口高度 px")
420
+ p.add_argument("--device-scale-factor", type=float, default=2.0, help="设备像素比(清晰/视网膜)")
421
+ p.add_argument("--full-page", action="store_true", help="screenshot:截取整个可滚动页面")
422
+ p.add_argument("--selector", default=None, help="screenshot:仅截取该 CSS 元素")
423
+ p.add_argument("--fps", type=int, default=30, help="scroll/record 视频帧率")
424
+ p.add_argument("--scroll-duration", type=float, default=8.0, help="从顶到底滚动总秒数")
425
+ p.add_argument("--browser", default=os.environ.get("WEB_CAPTURE_BROWSER", "chromium"),
426
+ choices=["chromium", "firefox", "webkit"], help="浏览器引擎")
427
+ p.add_argument("--wait-until", default="networkidle",
428
+ choices=["load", "domcontentloaded", "networkidle"], help="导航等待条件")
429
+ p.add_argument("--wait-ms", type=int, default=1500, help="加载后额外静置毫秒")
430
+ p.add_argument("--json-output", action="store_true",
431
+ help="管道模式:stdout 仅输出最终 manifest JSON")
432
+ return p
433
+
434
+
435
+ def main() -> None:
436
+ global QUIET
437
+ args = build_parser().parse_args()
438
+ QUIET = args.json_output
439
+
440
+ try:
441
+ manifest = run(args)
442
+ except CaptureError as exc:
443
+ log_err(f"❌ {exc}")
444
+ sys.exit(exc.exit_code)
445
+ except KeyboardInterrupt:
446
+ log_err("⚠️ 已取消")
447
+ sys.exit(130)
448
+ except Exception as exc: # noqa: BLE001
449
+ log_err(f"❌ 捕获失败:{exc}")
450
+ sys.exit(1)
451
+
452
+ if args.json_output:
453
+ # 管道模式:stdout 仅输出 manifest
454
+ print(json.dumps(manifest, ensure_ascii=False))
455
+ else:
456
+ out = manifest["outputs"][0]
457
+ log(f"✅ 完成:{out.get('localPath')}")
458
+
459
+
460
+ if __name__ == "__main__":
461
+ main()
@@ -0,0 +1,29 @@
1
+ {
2
+ "name": "web-capture",
3
+ "toolName": "web_capture",
4
+ "tier": "tool",
5
+ "title": "Web Page Capture",
6
+ "description": "Drive a headless browser (Playwright) to capture a web page: viewport/full-page screenshots, or a smooth top-to-bottom scrolling video. Produces PNG / MP4 assets that feed the screen-walkthrough template.",
7
+ "envVars": ["WEB_CAPTURE_BROWSER", "WEB_CAPTURE_OUTPUT_BASE", "WEB_CAPTURE_NO_SANDBOX", "WEB_CAPTURE_ALLOW_PRIVATE_HOSTS", "PLAYWRIGHT_BROWSERS_PATH"],
8
+ "scriptPath": "scripts/web_capture.py",
9
+ "parameters": {
10
+ "type": "object",
11
+ "properties": {
12
+ "url": { "type": "string", "description": "Target page URL (http/https)" },
13
+ "mode": { "type": "string", "description": "screenshot | scroll-video | record-video (default screenshot)" },
14
+ "output_dir": { "type": "string", "description": "Output directory (default ./web_capture_<timestamp>)" },
15
+ "width": { "type": "number", "description": "Viewport width in px (default 1280)" },
16
+ "height": { "type": "number", "description": "Viewport height in px (default 720)" },
17
+ "device_scale_factor": { "type": "number", "description": "Device pixel ratio for crisp/retina output (default 2)" },
18
+ "full_page": { "type": "boolean", "description": "screenshot mode: capture the whole scrollable page" },
19
+ "selector": { "type": "string", "description": "screenshot mode: capture only the element matching this CSS selector" },
20
+ "fps": { "type": "number", "description": "scroll/record video frames per second (default 30)" },
21
+ "scroll_duration": { "type": "number", "description": "Seconds for the full top-to-bottom scroll (default 8)" },
22
+ "browser": { "type": "string", "description": "chromium | firefox | webkit (default chromium)" },
23
+ "wait_until": { "type": "string", "description": "load | domcontentloaded | networkidle (default networkidle)" },
24
+ "wait_ms": { "type": "number", "description": "Extra wait after load before capture, in ms (default 1500)" },
25
+ "json_output": { "type": "boolean", "description": "Pipeline mode — emit only the manifest JSON on stdout" }
26
+ },
27
+ "required": ["url"]
28
+ }
29
+ }
@@ -0,0 +1,7 @@
1
+ {
2
+ "skillName": "web-capture",
3
+ "repoName": "agent-skill-media-maker",
4
+ "skillId": "555",
5
+ "version": "V2",
6
+ "skillDescription": "网页捕获技能,驱动无头浏览器(Playwright)对网页进行截图或滚动录屏,产出 PNG / MP4 素材,供 screen-walkthrough 等模板消费。\n\n当用户提到以下任何需求时,立即使用本 skill:\n- 网页截图、网站截图、给某个 URL 截图、整页截图\n- 网页滚动录屏、把网页从上到下滑动录成视频、网页滚动视频\n- GitHub / 官网 / 产品页面的录屏素材、网页演示素材\n\n即使用户没有明确说「Playwright」或「浏览器」,只要他们想要把一个网页地址变成截图或滚动演示视频,也要使用本 skill。"
7
+ }