@remixmate/cli 0.1.0 → 0.1.2
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/README.md +100 -83
- package/README.zh-CN.md +183 -0
- package/dist/handlers/gen-digital-human.d.ts +12 -0
- package/dist/handlers/gen-digital-human.js +157 -0
- package/dist/handlers/gen-image.js +5 -41
- package/dist/handlers/gen-video.d.ts +18 -0
- package/dist/handlers/gen-video.js +153 -0
- package/dist/handlers/gen-voice.js +2 -12
- package/dist/handlers/index.js +4 -2
- package/dist/handlers/shared.d.ts +26 -0
- package/dist/handlers/shared.js +66 -0
- package/dist/http.d.ts +4 -2
- package/dist/http.js +5 -3
- package/dist/manifest.json +38 -24
- package/dist/registry.d.ts +5 -16
- package/dist/registry.js +11 -17
- package/dist/skill-schema.d.ts +65 -0
- package/dist/skill-schema.js +53 -0
- package/package.json +5 -4
- package/skills/export-jianying/scripts/gen_jianying_draft.py +4 -4
- package/skills/export-jianying/skill.json +1 -0
- package/skills/gen-digital-human/SKILL.md +23 -46
- package/skills/gen-digital-human/skill.json +4 -2
- package/skills/gen-digital-human/version.json +1 -1
- package/skills/gen-image/SKILL.md +2 -2
- package/skills/gen-image/skill.json +3 -1
- package/skills/gen-image/version.json +1 -1
- package/skills/gen-script/SKILL.md +13 -13
- package/skills/gen-script/scripts/gen_script.py +50 -12
- package/skills/gen-script/skill.json +3 -1
- package/skills/gen-video/SKILL.md +22 -33
- package/skills/gen-video/skill.json +4 -2
- package/skills/gen-video/version.json +1 -1
- package/skills/gen-voice/SKILL.md +1 -1
- package/skills/gen-voice/skill.json +1 -0
- package/skills/gen-voice/version.json +1 -1
- package/skills/prepare-video-assets/SKILL.md +2 -2
- package/skills/prepare-video-assets/skill.json +2 -1
- package/skills/render-video/scripts/render_video.py +8 -8
- package/skills/render-video/skill.json +1 -0
- package/skills/{template-bind → template-registry}/README.md +4 -4
- package/skills/{template-bind → template-registry}/SKILL.md +14 -12
- package/skills/template-registry/scripts/list_templates.py +214 -0
- package/skills/{template-bind → template-registry}/scripts/match_template.py +4 -3
- package/skills/{template-bind → template-registry}/scripts/registry_loader.py +1 -1
- package/skills/{template-bind → template-registry}/scripts/template_paths.py +7 -7
- package/skills/{template-bind → template-registry}/skill.json +8 -5
- package/skills/template-registry/version.json +7 -0
- package/skills/{template-bind → template-registry}/video_dsl/README.md +2 -2
- package/skills/{template-bind → template-registry}/video_dsl/runtime/__init__.py +1 -1
- package/skills/video-parser/SKILL.md +47 -160
- package/skills/video-parser/skill.json +4 -4
- package/skills/video-parser/version.json +1 -1
- package/skills/web-capture/SKILL.md +407 -116
- package/skills/web-capture/scripts/_media_screenshot/__init__.py +21 -0
- package/skills/web-capture/scripts/_media_screenshot/bootstrap.py +34 -0
- package/skills/web-capture/scripts/_media_screenshot/browser.py +86 -0
- package/skills/web-capture/scripts/_media_screenshot/cli_args.py +111 -0
- package/skills/web-capture/scripts/_media_screenshot/js/arrow.js +86 -0
- package/skills/web-capture/scripts/_media_screenshot/js/caption.js +27 -0
- package/skills/web-capture/scripts/_media_screenshot/js/clear_overlay.js +5 -0
- package/skills/web-capture/scripts/_media_screenshot/js/cursor_bootstrap.js +26 -0
- package/skills/web-capture/scripts/_media_screenshot/js/cursor_hide.js +6 -0
- package/skills/web-capture/scripts/_media_screenshot/js/cursor_move.js +12 -0
- package/skills/web-capture/scripts/_media_screenshot/js/cursor_ripple.js +25 -0
- package/skills/web-capture/scripts/_media_screenshot/js/element_zoom.js +72 -0
- package/skills/web-capture/scripts/_media_screenshot/js/element_zoom_restore.js +21 -0
- package/skills/web-capture/scripts/_media_screenshot/js/fade_out_overlays.js +10 -0
- package/skills/web-capture/scripts/_media_screenshot/js/focus.js +38 -0
- package/skills/web-capture/scripts/_media_screenshot/js/highlight.js +97 -0
- package/skills/web-capture/scripts/_media_screenshot/js/overlay_bootstrap.js +19 -0
- package/skills/web-capture/scripts/_media_screenshot/js/page_zoom_in.js +54 -0
- package/skills/web-capture/scripts/_media_screenshot/js/page_zoom_out.js +11 -0
- package/skills/web-capture/scripts/_media_screenshot/js/page_zoom_restore.js +13 -0
- package/skills/web-capture/scripts/_media_screenshot/js/redact.js +33 -0
- package/skills/web-capture/scripts/_media_screenshot/js/scroll.js +26 -0
- package/skills/web-capture/scripts/_media_screenshot/js/scroll_through.js +18 -0
- package/skills/web-capture/scripts/_media_screenshot/js/sequence.js +76 -0
- package/skills/web-capture/scripts/_media_screenshot/js/settled_check.js +15 -0
- package/skills/web-capture/scripts/_media_screenshot/js/title_card.js +71 -0
- package/skills/web-capture/scripts/_media_screenshot/js_loader.py +14 -0
- package/skills/web-capture/scripts/_media_screenshot/overlay.py +17 -0
- package/skills/web-capture/scripts/_media_screenshot/recording.py +100 -0
- package/skills/web-capture/scripts/_media_screenshot/scenes/__init__.py +39 -0
- package/skills/web-capture/scripts/_media_screenshot/scenes/arrow.py +50 -0
- package/skills/web-capture/scripts/_media_screenshot/scenes/caption.py +20 -0
- package/skills/web-capture/scripts/_media_screenshot/scenes/click.py +35 -0
- package/skills/web-capture/scripts/_media_screenshot/scenes/code_lines.py +54 -0
- package/skills/web-capture/scripts/_media_screenshot/scenes/focus.py +24 -0
- package/skills/web-capture/scripts/_media_screenshot/scenes/highlight.py +54 -0
- package/skills/web-capture/scripts/_media_screenshot/scenes/hover.py +28 -0
- package/skills/web-capture/scripts/_media_screenshot/scenes/redact.py +32 -0
- package/skills/web-capture/scripts/_media_screenshot/scenes/scroll.py +32 -0
- package/skills/web-capture/scripts/_media_screenshot/scenes/sequence.py +43 -0
- package/skills/web-capture/scripts/_media_screenshot/scenes/title_card.py +20 -0
- package/skills/web-capture/scripts/_media_screenshot/scenes/type.py +42 -0
- package/skills/web-capture/scripts/_media_screenshot/scenes/wait.py +9 -0
- package/skills/web-capture/scripts/_media_screenshot/scenes/zoom.py +72 -0
- package/skills/web-capture/scripts/_media_screenshot/screenshot.py +166 -0
- package/skills/web-capture/scripts/_media_screenshot/storyboard.py +88 -0
- package/skills/web-capture/scripts/_media_screenshot/template.py +119 -0
- package/skills/web-capture/scripts/_media_screenshot/trim.py +82 -0
- package/skills/web-capture/scripts/record.py +186 -0
- package/skills/web-capture/scripts/screenshot.py +94 -0
- package/skills/web-capture/skill.json +15 -14
- package/skills/web-capture/templates/github-code-tour.json +59 -0
- package/skills/web-capture/templates/github-readme-tour.json +77 -0
- package/skills/web-capture/templates/github-repo-intro.json +74 -0
- package/skills/web-capture/version.json +2 -2
- package/dist/handlers/template-bind.d.ts +0 -15
- package/dist/handlers/template-bind.js +0 -149
- package/skills/gen-digital-human/scripts/gen_digital_human_video.py +0 -574
- package/skills/gen-video/scripts/gen_video.py +0 -591
- package/skills/template-bind/version.json +0 -7
- package/skills/web-capture/scripts/web_capture.py +0 -461
- /package/skills/{template-bind → template-registry}/scripts/check_contracts.py +0 -0
- /package/skills/{template-bind → template-registry}/scripts/render_job_client.py +0 -0
- /package/skills/{template-bind → template-registry}/scripts/sync_registry.py +0 -0
- /package/skills/{template-bind → template-registry}/video_dsl/runtime/dsl_validator.py +0 -0
- /package/skills/{template-bind → template-registry}/video_dsl/runtime/prompt_enhancer.py +0 -0
- /package/skills/{template-bind → template-registry}/video_dsl/runtime/template_binder.py +0 -0
- /package/skills/{template-bind → template-registry}/video_dsl/runtime/timeline_compiler.py +0 -0
- /package/skills/{template-bind → template-registry}/video_dsl/schema/render-plan-v1alpha1.json +0 -0
- /package/skills/{template-bind → template-registry}/video_dsl/schema/template-binding-v1alpha1.json +0 -0
- /package/skills/{template-bind → template-registry}/video_dsl/schema/template-definition-v1alpha1.json +0 -0
- /package/skills/{template-bind → template-registry}/video_dsl/schema/video-dsl-v1alpha1.json +0 -0
|
@@ -1,461 +0,0 @@
|
|
|
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()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
/package/skills/{template-bind → template-registry}/video_dsl/schema/render-plan-v1alpha1.json
RENAMED
|
File without changes
|
/package/skills/{template-bind → template-registry}/video_dsl/schema/template-binding-v1alpha1.json
RENAMED
|
File without changes
|
|
File without changes
|
/package/skills/{template-bind → template-registry}/video_dsl/schema/video-dsl-v1alpha1.json
RENAMED
|
File without changes
|