snapstep 0.2.0__py3-none-any.whl
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.
- snapstep/__init__.py +3 -0
- snapstep/__main__.py +6 -0
- snapstep/capture.py +161 -0
- snapstep/cli.py +298 -0
- snapstep/config.py +109 -0
- snapstep/export/__init__.py +68 -0
- snapstep/export/docx_export.py +38 -0
- snapstep/export/html_export.py +59 -0
- snapstep/export/markdown_export.py +34 -0
- snapstep/log.py +41 -0
- snapstep/models.py +106 -0
- snapstep/privacy.py +27 -0
- snapstep/recorder.py +374 -0
- snapstep/resources/icon.ico +0 -0
- snapstep/resources/icon.png +0 -0
- snapstep/resources/icon_rec.png +0 -0
- snapstep/resources/templates/report.html.j2 +56 -0
- snapstep/ui/__init__.py +1 -0
- snapstep/ui/settings.py +149 -0
- snapstep/ui/tray.py +415 -0
- snapstep/writer.py +263 -0
- snapstep-0.2.0.dist-info/METADATA +186 -0
- snapstep-0.2.0.dist-info/RECORD +27 -0
- snapstep-0.2.0.dist-info/WHEEL +5 -0
- snapstep-0.2.0.dist-info/entry_points.txt +2 -0
- snapstep-0.2.0.dist-info/licenses/LICENSE +21 -0
- snapstep-0.2.0.dist-info/top_level.txt +1 -0
snapstep/__init__.py
ADDED
snapstep/__main__.py
ADDED
snapstep/capture.py
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""屏幕截图与步骤标注。
|
|
2
|
+
|
|
3
|
+
导入本模块只需 Pillow;mss 延迟到真正截屏时才加载,
|
|
4
|
+
保证导出器/测试在没有屏幕的环境里也能工作。
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import time
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from PIL import Image, ImageDraw, ImageFont
|
|
13
|
+
|
|
14
|
+
# 点击高亮圈颜色(快照红)
|
|
15
|
+
ACCENT = (245, 63, 63)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ScreenCapture:
|
|
19
|
+
"""基于 mss 的截屏器。线程注意:mss 实例不跨线程,按线程懒加载。"""
|
|
20
|
+
|
|
21
|
+
def __init__(self) -> None:
|
|
22
|
+
self._sct = None
|
|
23
|
+
|
|
24
|
+
def _client(self):
|
|
25
|
+
if self._sct is None:
|
|
26
|
+
import mss
|
|
27
|
+
|
|
28
|
+
self._sct = mss.mss()
|
|
29
|
+
return self._sct
|
|
30
|
+
|
|
31
|
+
def monitor_for_point(self, x: int, y: int) -> dict:
|
|
32
|
+
"""返回包含该点显示器的 mss monitor 字典;找不到时退回主显示器。"""
|
|
33
|
+
monitors = self._client().monitors
|
|
34
|
+
for mon in monitors[1:]:
|
|
35
|
+
if (
|
|
36
|
+
mon["left"] <= x < mon["left"] + mon["width"]
|
|
37
|
+
and mon["top"] <= y < mon["top"] + mon["height"]
|
|
38
|
+
):
|
|
39
|
+
return dict(mon)
|
|
40
|
+
return dict(monitors[1] if len(monitors) > 1 else monitors[0])
|
|
41
|
+
|
|
42
|
+
def capture_point(self, x: int, y: int) -> tuple[Image.Image, dict]:
|
|
43
|
+
"""截取包含 (x, y) 的显示器,返回 (PIL 图像, monitor 字典)。"""
|
|
44
|
+
mon = self.monitor_for_point(x, y)
|
|
45
|
+
shot = self._client().grab(mon)
|
|
46
|
+
img = Image.frombytes("RGB", shot.size, shot.bgra, "raw", "BGRX")
|
|
47
|
+
return img, mon
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def save_screenshot(
|
|
51
|
+
img: Image.Image,
|
|
52
|
+
session_dir: Path,
|
|
53
|
+
index: int,
|
|
54
|
+
image_format: str = "png",
|
|
55
|
+
suffix: str = "",
|
|
56
|
+
) -> str:
|
|
57
|
+
"""保存原始截图,返回相对 session 目录的 POSIX 风格路径。
|
|
58
|
+
|
|
59
|
+
suffix 用于区分同一步骤的候选帧(如 "-imm" / "-set")。
|
|
60
|
+
"""
|
|
61
|
+
images_dir = session_dir / "images"
|
|
62
|
+
images_dir.mkdir(parents=True, exist_ok=True)
|
|
63
|
+
path = images_dir / f"step-{index:02d}{suffix}.{image_format}"
|
|
64
|
+
img.save(path)
|
|
65
|
+
return path.relative_to(session_dir).as_posix()
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
# 图片相似度:缩到 96x54 灰度后逐像素求平均绝对差(0~255)。
|
|
69
|
+
# < 2.0 视为「画面没变」,> 4.0 视为「明显变化」。
|
|
70
|
+
_SIMILARITY_SIZE = (96, 54)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def mean_diff(a: Path | Image.Image, b: Path | Image.Image) -> float:
|
|
74
|
+
"""两张截图的平均像素差(0~255),越小越相似。"""
|
|
75
|
+
pa = a if isinstance(a, Image.Image) else Image.open(a)
|
|
76
|
+
pb = b if isinstance(b, Image.Image) else Image.open(b)
|
|
77
|
+
ga = pa.convert("L").resize(_SIMILARITY_SIZE)
|
|
78
|
+
gb = pb.convert("L").resize(_SIMILARITY_SIZE)
|
|
79
|
+
da = list(ga.getdata())
|
|
80
|
+
db = list(gb.getdata())
|
|
81
|
+
total = sum(abs(x - y) for x, y in zip(da, db, strict=True))
|
|
82
|
+
return total / len(da)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def settle_grab(
|
|
86
|
+
capture: ScreenCapture,
|
|
87
|
+
monitor: dict,
|
|
88
|
+
max_wait_ms: int,
|
|
89
|
+
poll_ms: int = 150,
|
|
90
|
+
stable_diff: float = 1.5,
|
|
91
|
+
) -> tuple[Image.Image, int]:
|
|
92
|
+
"""轮询截屏直到画面连续两帧几乎不变(界面稳定),或超时。
|
|
93
|
+
|
|
94
|
+
返回 (稳定帧图像, 实际等待毫秒数)。
|
|
95
|
+
"""
|
|
96
|
+
deadline = time.monotonic() + max(max_wait_ms, 0) / 1000
|
|
97
|
+
last = capture.grab_monitor(monitor)
|
|
98
|
+
waited = 0
|
|
99
|
+
while True:
|
|
100
|
+
if time.monotonic() >= deadline:
|
|
101
|
+
return last, waited
|
|
102
|
+
time.sleep(poll_ms / 1000)
|
|
103
|
+
waited += poll_ms
|
|
104
|
+
current = capture.grab_monitor(monitor)
|
|
105
|
+
if mean_diff(last, current) < stable_diff:
|
|
106
|
+
return current, waited
|
|
107
|
+
last = current
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _load_font(size: int) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
|
|
111
|
+
for name in ("msyh.ttc", "arial.ttf"): # 优先微软雅黑,回退 Arial
|
|
112
|
+
try:
|
|
113
|
+
return ImageFont.truetype(name, size)
|
|
114
|
+
except OSError:
|
|
115
|
+
continue
|
|
116
|
+
return ImageFont.load_default()
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def annotate(
|
|
120
|
+
img: Image.Image, rel_x: float, rel_y: float, number: int, radius: int = 28
|
|
121
|
+
) -> Image.Image:
|
|
122
|
+
"""在截图上标注点击位置:红色高亮圈 + 序号徽章,返回新图。"""
|
|
123
|
+
out = img.convert("RGBA")
|
|
124
|
+
overlay = Image.new("RGBA", out.size, (0, 0, 0, 0))
|
|
125
|
+
d = ImageDraw.Draw(overlay)
|
|
126
|
+
|
|
127
|
+
cx, cy = rel_x * out.width, rel_y * out.height
|
|
128
|
+
ring_w = max(3, out.width // 450)
|
|
129
|
+
# 外圈柔光 + 内圈实线
|
|
130
|
+
d.ellipse(
|
|
131
|
+
[cx - radius - 8, cy - radius - 8, cx + radius + 8, cy + radius + 8],
|
|
132
|
+
outline=ACCENT + (80,),
|
|
133
|
+
width=ring_w + 3,
|
|
134
|
+
)
|
|
135
|
+
d.ellipse(
|
|
136
|
+
[cx - radius, cy - radius, cx + radius, cy + radius],
|
|
137
|
+
outline=ACCENT + (255,),
|
|
138
|
+
width=ring_w,
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
# 序号徽章放在圈右上,越界时往里收
|
|
142
|
+
badge_r = max(14, int(radius * 0.62))
|
|
143
|
+
bx = min(max(cx + radius + 6, badge_r), out.width - badge_r - 2)
|
|
144
|
+
by = max(min(cy - radius - 6, out.height - badge_r - 2), badge_r + 2)
|
|
145
|
+
d.ellipse(
|
|
146
|
+
[bx - badge_r, by - badge_r, bx + badge_r, by + badge_r],
|
|
147
|
+
fill=ACCENT + (235,),
|
|
148
|
+
)
|
|
149
|
+
out = Image.alpha_composite(out, overlay)
|
|
150
|
+
|
|
151
|
+
dd = ImageDraw.Draw(out)
|
|
152
|
+
text = str(number)
|
|
153
|
+
font = _load_font(badge_r * 2 - 4)
|
|
154
|
+
left, top, right, bottom = dd.textbbox((0, 0), text, font=font)
|
|
155
|
+
dd.text(
|
|
156
|
+
(bx - (right - left) / 2 - left, by - (bottom - top) / 2 - top),
|
|
157
|
+
text,
|
|
158
|
+
font=font,
|
|
159
|
+
fill=(255, 255, 255, 255),
|
|
160
|
+
)
|
|
161
|
+
return out.convert("RGB")
|
snapstep/cli.py
ADDED
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
"""命令行入口。
|
|
2
|
+
|
|
3
|
+
snapstep 启动托盘 GUI(默认)
|
|
4
|
+
snapstep record 命令行录制,回车结束
|
|
5
|
+
snapstep export 导出已有会话
|
|
6
|
+
snapstep config 查看/修改配置
|
|
7
|
+
snapstep demo 生成一份示例教程(无需录制,验证安装)
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import argparse
|
|
13
|
+
import json
|
|
14
|
+
import sys
|
|
15
|
+
from dataclasses import fields as dc_fields
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
from . import __version__
|
|
19
|
+
from .config import Config, load_config, save_config
|
|
20
|
+
from .models import Session
|
|
21
|
+
from .writer import AIWriter, TemplateWriter, apply_copy
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def generate_and_export(
|
|
25
|
+
session: Session,
|
|
26
|
+
session_dir: Path,
|
|
27
|
+
cfg: Config,
|
|
28
|
+
fmt: str | None = None,
|
|
29
|
+
use_ai: bool | None = None,
|
|
30
|
+
) -> tuple[str, list[Path]]:
|
|
31
|
+
"""生成文案并导出。CLI 与 GUI 共用。返回 (writer 名称, 导出文件列表)。"""
|
|
32
|
+
from .export import export_session
|
|
33
|
+
|
|
34
|
+
use_ai = cfg.ai_enabled() if use_ai is None else (use_ai and cfg.ai_enabled())
|
|
35
|
+
ai = (
|
|
36
|
+
AIWriter(cfg.api.base_url, cfg.api.api_key, cfg.api.model, cfg.api.language)
|
|
37
|
+
if use_ai
|
|
38
|
+
else None
|
|
39
|
+
)
|
|
40
|
+
used = apply_copy(session, TemplateWriter(cfg.api.language), ai)
|
|
41
|
+
session.save(session_dir)
|
|
42
|
+
paths = export_session(
|
|
43
|
+
session, session_dir, fmt or cfg.export.format, None, cfg.export.embed_images
|
|
44
|
+
)
|
|
45
|
+
return used, paths
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# ---------- 子命令 ----------
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def cmd_record(args, cfg: Config) -> int:
|
|
52
|
+
from .recorder import Recorder
|
|
53
|
+
|
|
54
|
+
privacy = "开启" if cfg.privacy.privacy_mode else "关闭"
|
|
55
|
+
ai = "已配置" if cfg.ai_enabled() else "未配置(使用本地模板)"
|
|
56
|
+
print(f"SnapStep v{__version__} 开始录制")
|
|
57
|
+
print(f" 隐私模式:{privacy}(截屏{'被跳过' if cfg.privacy.privacy_mode else '正常'})")
|
|
58
|
+
print(f" AI 文案:{ai}")
|
|
59
|
+
print(" 进行你的操作,回到本窗口按回车结束录制。\n")
|
|
60
|
+
|
|
61
|
+
recorder = Recorder(cfg, out_dir=Path(args.out) if args.out else None)
|
|
62
|
+
recorder.on_step_captured = lambda step: print(
|
|
63
|
+
f" [步骤 {step.index}] {step.window_title or '未知窗口'}"
|
|
64
|
+
)
|
|
65
|
+
recorder.start()
|
|
66
|
+
try:
|
|
67
|
+
input()
|
|
68
|
+
except KeyboardInterrupt:
|
|
69
|
+
pass
|
|
70
|
+
session, session_dir = recorder.stop_and_save()
|
|
71
|
+
print(f"\n录制完成:{len(session.steps)} 个步骤 → {session_dir}")
|
|
72
|
+
|
|
73
|
+
used, paths = generate_and_export(
|
|
74
|
+
session, session_dir, cfg, fmt=args.format, use_ai=not args.no_ai
|
|
75
|
+
)
|
|
76
|
+
_print_export_result(used, paths)
|
|
77
|
+
return 0
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def cmd_export(args, cfg: Config) -> int:
|
|
81
|
+
session_dir = Path(args.session_dir)
|
|
82
|
+
if not (session_dir / "session.json").exists():
|
|
83
|
+
print(f"错误:{session_dir} 下没有 session.json", file=sys.stderr)
|
|
84
|
+
return 1
|
|
85
|
+
session = Session.load(session_dir)
|
|
86
|
+
used, paths = generate_and_export(
|
|
87
|
+
session, session_dir, cfg, fmt=args.format, use_ai=not args.no_ai
|
|
88
|
+
)
|
|
89
|
+
_print_export_result(used, paths)
|
|
90
|
+
return 0
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _print_export_result(used: str, paths: list[Path]) -> None:
|
|
94
|
+
if used == "ai":
|
|
95
|
+
print("文案:AI 生成")
|
|
96
|
+
else:
|
|
97
|
+
print("文案:本地模板(AI 未配置或调用失败,已自动兜底)")
|
|
98
|
+
for path in paths:
|
|
99
|
+
print(f"已导出:{path}")
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def cmd_config(args, cfg: Config) -> int:
|
|
103
|
+
if args.config_action == "list" or args.key is None:
|
|
104
|
+
print(json.dumps(_config_to_dict(cfg), ensure_ascii=False, indent=2))
|
|
105
|
+
return 0
|
|
106
|
+
if args.config_action == "get":
|
|
107
|
+
print(_get_key(cfg, args.key))
|
|
108
|
+
return 0
|
|
109
|
+
# set
|
|
110
|
+
_set_key(cfg, args.key, args.value)
|
|
111
|
+
save_config(cfg)
|
|
112
|
+
print(f"已保存 {args.key} = {args.value}")
|
|
113
|
+
return 0
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _config_to_dict(cfg: Config) -> dict:
|
|
117
|
+
return {
|
|
118
|
+
"hotkey": cfg.hotkey,
|
|
119
|
+
"capture": {f.name: getattr(cfg.capture, f.name) for f in dc_fields(cfg.capture)},
|
|
120
|
+
"privacy": {f.name: getattr(cfg.privacy, f.name) for f in dc_fields(cfg.privacy)},
|
|
121
|
+
"api": {f.name: getattr(cfg.api, f.name) for f in dc_fields(cfg.api)},
|
|
122
|
+
"export": {f.name: getattr(cfg.export, f.name) for f in dc_fields(cfg.export)},
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _get_key(cfg: Config, key: str) -> object:
|
|
127
|
+
target: object = cfg
|
|
128
|
+
for part in key.split("."):
|
|
129
|
+
target = getattr(target, part)
|
|
130
|
+
return target
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _set_key(cfg: Config, key: str, value: str) -> None:
|
|
134
|
+
parts = key.split(".")
|
|
135
|
+
target: object = cfg
|
|
136
|
+
for part in parts[:-1]:
|
|
137
|
+
target = getattr(target, part)
|
|
138
|
+
leaf = parts[-1]
|
|
139
|
+
if not hasattr(target, leaf):
|
|
140
|
+
valid = _valid_keys(cfg)
|
|
141
|
+
raise SystemExit(f"未知配置项 {key}。可用项:\n " + "\n ".join(valid))
|
|
142
|
+
current = getattr(target, leaf)
|
|
143
|
+
if isinstance(current, bool):
|
|
144
|
+
setattr(target, leaf, value.lower() in ("1", "true", "yes", "on"))
|
|
145
|
+
elif isinstance(current, int):
|
|
146
|
+
setattr(target, leaf, int(value))
|
|
147
|
+
else:
|
|
148
|
+
setattr(target, leaf, value)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _valid_keys(cfg: Config) -> list[str]:
|
|
152
|
+
keys = ["hotkey"]
|
|
153
|
+
for section in ("capture", "privacy", "api", "export"):
|
|
154
|
+
obj = getattr(cfg, section)
|
|
155
|
+
keys += [f"{section}.{f.name}" for f in dc_fields(obj)]
|
|
156
|
+
return keys
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def cmd_demo(args, cfg: Config) -> int:
|
|
160
|
+
session = _build_demo_session()
|
|
161
|
+
session_dir = Path(args.out)
|
|
162
|
+
_render_demo_screenshots(session, session_dir)
|
|
163
|
+
used, paths = generate_and_export(
|
|
164
|
+
session, session_dir, cfg, fmt=args.format, use_ai=not args.no_ai
|
|
165
|
+
)
|
|
166
|
+
print(f"示例会话已生成:{session_dir}")
|
|
167
|
+
_print_export_result(used, paths)
|
|
168
|
+
return 0
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _build_demo_session() -> Session:
|
|
172
|
+
from .models import Step, TypedRun
|
|
173
|
+
|
|
174
|
+
session = Session(title=None, intro=None)
|
|
175
|
+
demo_steps = [
|
|
176
|
+
("发布新文章 - 后台管理", (0.5, 0.12), [("操作手册", False)]),
|
|
177
|
+
("发布新文章 - 后台管理", (0.32, 0.45), []),
|
|
178
|
+
("确认发布 - 后台管理", (0.5, 0.62), [("文章已就绪,点击确认", False)]),
|
|
179
|
+
]
|
|
180
|
+
for i, (win, (rx, ry), runs) in enumerate(demo_steps, 1):
|
|
181
|
+
step = Step(
|
|
182
|
+
index=i,
|
|
183
|
+
created_at=f"2026-01-01T10:0{i}:00",
|
|
184
|
+
window_title=win,
|
|
185
|
+
click_x=int(rx * 1920),
|
|
186
|
+
click_y=int(ry * 1080),
|
|
187
|
+
monitor_width=1920,
|
|
188
|
+
monitor_height=1080,
|
|
189
|
+
typed_runs=[TypedRun(text=t, is_secret=s) for t, s in runs],
|
|
190
|
+
)
|
|
191
|
+
session.steps.append(step)
|
|
192
|
+
return session
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _render_demo_screenshots(session: Session, session_dir: Path) -> None:
|
|
196
|
+
"""用 Pillow 画出假窗口截图,让 demo 无需真实录屏。"""
|
|
197
|
+
from PIL import Image, ImageDraw, ImageFont
|
|
198
|
+
|
|
199
|
+
from .capture import save_screenshot
|
|
200
|
+
|
|
201
|
+
def font(size: int):
|
|
202
|
+
for name in ("msyh.ttc", "arial.ttf"):
|
|
203
|
+
try:
|
|
204
|
+
return ImageFont.truetype(name, size)
|
|
205
|
+
except OSError:
|
|
206
|
+
continue
|
|
207
|
+
return ImageFont.load_default()
|
|
208
|
+
|
|
209
|
+
for step in session.steps:
|
|
210
|
+
img = Image.new("RGB", (1280, 800), (247, 248, 250))
|
|
211
|
+
d = ImageDraw.Draw(img)
|
|
212
|
+
# 假窗口
|
|
213
|
+
win = [120, 90, 1160, 700]
|
|
214
|
+
d.rounded_rectangle(win, 12, fill=(255, 255, 255), outline=(229, 230, 235), width=2)
|
|
215
|
+
d.rounded_rectangle([win[0], win[1], win[2], win[1] + 44], 12, fill=(242, 243, 245))
|
|
216
|
+
d.rectangle([win[0], win[1] + 30, win[2], win[1] + 44], fill=(242, 243, 245))
|
|
217
|
+
for k, color in enumerate([(255, 95, 86), (255, 189, 46), (39, 201, 63)]):
|
|
218
|
+
x0, x1 = win[0] + 18 + k * 26, win[0] + 32 + k * 26
|
|
219
|
+
d.ellipse([x0, win[1] + 15, x1, win[1] + 29], fill=color)
|
|
220
|
+
d.text(
|
|
221
|
+
(win[0] + 110, win[1] + 10),
|
|
222
|
+
step.window_title or "示例窗口",
|
|
223
|
+
font=font(17),
|
|
224
|
+
fill=(31, 35, 41),
|
|
225
|
+
)
|
|
226
|
+
# 几行假正文
|
|
227
|
+
for k in range(4):
|
|
228
|
+
y = win[1] + 80 + k * 46
|
|
229
|
+
x1 = win[2] - 40 - k * 130
|
|
230
|
+
d.rounded_rectangle([win[0] + 40, y, x1, y + 22], 6, fill=(240, 241, 244))
|
|
231
|
+
# 点击处的假按钮
|
|
232
|
+
cx = win[0] + step.rel_x * (win[2] - win[0])
|
|
233
|
+
cy = win[1] + step.rel_y * (win[3] - win[1])
|
|
234
|
+
d.rounded_rectangle([cx - 80, cy - 22, cx + 80, cy + 22], 8, fill=(245, 63, 63))
|
|
235
|
+
d.text((cx - 32, cy - 11), "确 定", font=font(16), fill=(255, 255, 255))
|
|
236
|
+
step.screenshot = save_screenshot(img, session_dir, step.index)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
# ---------- 入口 ----------
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def main(argv: list[str] | None = None) -> int:
|
|
243
|
+
for stream in (sys.stdout, sys.stderr):
|
|
244
|
+
try:
|
|
245
|
+
stream.reconfigure(encoding="utf-8")
|
|
246
|
+
except Exception:
|
|
247
|
+
pass
|
|
248
|
+
|
|
249
|
+
parser = argparse.ArgumentParser(
|
|
250
|
+
prog="snapstep",
|
|
251
|
+
description="录屏自动生成图文教程 / Record your screen, get a step-by-step guide.",
|
|
252
|
+
)
|
|
253
|
+
parser.add_argument("--version", action="version", version=f"SnapStep {__version__}")
|
|
254
|
+
sub = parser.add_subparsers(dest="command")
|
|
255
|
+
|
|
256
|
+
p_record = sub.add_parser("record", help="开始录制,回车结束")
|
|
257
|
+
p_record.add_argument("--out", help="会话输出目录(默认 ~/.snapstep/sessions/<时间戳>)")
|
|
258
|
+
p_record.add_argument("--format", choices=["md", "html", "docx", "all"], help="导出格式")
|
|
259
|
+
p_record.add_argument("--no-ai", action="store_true", help="跳过 AI 文案,直接用本地模板")
|
|
260
|
+
|
|
261
|
+
p_export = sub.add_parser("export", help="导出已有会话")
|
|
262
|
+
p_export.add_argument("session_dir", help="会话目录(含 session.json)")
|
|
263
|
+
p_export.add_argument("--format", choices=["md", "html", "docx", "all"])
|
|
264
|
+
p_export.add_argument("--out", help="导出目录(默认 <会话>/export)")
|
|
265
|
+
p_export.add_argument("--no-ai", action="store_true")
|
|
266
|
+
|
|
267
|
+
p_config = sub.add_parser("config", help="查看/修改配置")
|
|
268
|
+
p_config.add_argument(
|
|
269
|
+
"config_action", nargs="?", choices=["list", "get", "set"], default="list"
|
|
270
|
+
)
|
|
271
|
+
p_config.add_argument("key", nargs="?", help="如 api.api_key、privacy.privacy_mode、hotkey")
|
|
272
|
+
p_config.add_argument("value", nargs="?", help="set 时的新值")
|
|
273
|
+
|
|
274
|
+
p_demo = sub.add_parser("demo", help="生成示例教程,验证安装")
|
|
275
|
+
p_demo.add_argument("--out", default="snapstep-demo")
|
|
276
|
+
p_demo.add_argument("--format", choices=["md", "html", "docx", "all"])
|
|
277
|
+
p_demo.add_argument("--no-ai", action="store_true")
|
|
278
|
+
|
|
279
|
+
args = parser.parse_args(argv)
|
|
280
|
+
cfg = load_config()
|
|
281
|
+
|
|
282
|
+
if args.command == "record":
|
|
283
|
+
return cmd_record(args, cfg)
|
|
284
|
+
if args.command == "export":
|
|
285
|
+
return cmd_export(args, cfg)
|
|
286
|
+
if args.command == "config":
|
|
287
|
+
return cmd_config(args, cfg)
|
|
288
|
+
if args.command == "demo":
|
|
289
|
+
return cmd_demo(args, cfg)
|
|
290
|
+
|
|
291
|
+
from .ui.tray import run_gui
|
|
292
|
+
|
|
293
|
+
run_gui()
|
|
294
|
+
return 0
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
if __name__ == "__main__":
|
|
298
|
+
raise SystemExit(main())
|
snapstep/config.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""SnapStep 配置:JSON 持久化在 ~/.snapstep/config.json。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from dataclasses import asdict, dataclass, field
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
# 设置界面里的 API 预设(OpenAI 兼容端点)
|
|
10
|
+
API_PRESETS: dict[str, dict[str, str]] = {
|
|
11
|
+
"GLM 智谱": {
|
|
12
|
+
"base_url": "https://open.bigmodel.cn/api/paas/v4",
|
|
13
|
+
"model": "glm-4-flash",
|
|
14
|
+
},
|
|
15
|
+
"DeepSeek": {
|
|
16
|
+
"base_url": "https://api.deepseek.com/v1",
|
|
17
|
+
"model": "deepseek-chat",
|
|
18
|
+
},
|
|
19
|
+
"OpenAI": {
|
|
20
|
+
"base_url": "https://api.openai.com/v1",
|
|
21
|
+
"model": "gpt-4o-mini",
|
|
22
|
+
},
|
|
23
|
+
"自定义": {"base_url": "", "model": ""},
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class CaptureConfig:
|
|
29
|
+
settle_max_ms: int = 1500 # 点击后等待界面稳定的上限,期间持续比对帧,稳定即取「稳定帧」
|
|
30
|
+
image_format: str = "png"
|
|
31
|
+
filter_idle_clicks: bool = True # 停止时自动剔除画面无变化且无输入的无效点击
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class PrivacyConfig:
|
|
36
|
+
privacy_mode: bool = False # 开启后完全不截屏,只记录步骤
|
|
37
|
+
mask_passwords: bool = True # 密码框键入不记录明文(尽力检测)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class ApiConfig:
|
|
42
|
+
base_url: str = "" # 为空时不调用 AI,走本地模板文案
|
|
43
|
+
api_key: str = ""
|
|
44
|
+
model: str = ""
|
|
45
|
+
language: str = "zh" # AI 文案语言:zh / en
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass
|
|
49
|
+
class ExportConfig:
|
|
50
|
+
format: str = "html" # md / html / docx
|
|
51
|
+
embed_images: bool = True # HTML 单文件内嵌 base64,方便直接分享
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass
|
|
55
|
+
class Config:
|
|
56
|
+
hotkey: str = "<ctrl>+<alt>+s" # 开始/停止录制(GUI 生效)
|
|
57
|
+
capture: CaptureConfig = field(default_factory=CaptureConfig)
|
|
58
|
+
privacy: PrivacyConfig = field(default_factory=PrivacyConfig)
|
|
59
|
+
api: ApiConfig = field(default_factory=ApiConfig)
|
|
60
|
+
export: ExportConfig = field(default_factory=ExportConfig)
|
|
61
|
+
|
|
62
|
+
def ai_enabled(self) -> bool:
|
|
63
|
+
return bool(self.api.base_url and self.api.api_key and self.api.model)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def config_dir() -> Path:
|
|
67
|
+
return Path.home() / ".snapstep"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def sessions_dir() -> Path:
|
|
71
|
+
return config_dir() / "sessions"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def config_path() -> Path:
|
|
75
|
+
return config_dir() / "config.json"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def load_config() -> Config:
|
|
79
|
+
"""加载配置;文件缺失或字段不完整时按默认值补齐。"""
|
|
80
|
+
path = config_path()
|
|
81
|
+
cfg = Config()
|
|
82
|
+
if not path.exists():
|
|
83
|
+
return cfg
|
|
84
|
+
try:
|
|
85
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
86
|
+
except (json.JSONDecodeError, OSError):
|
|
87
|
+
return cfg
|
|
88
|
+
cfg.hotkey = data.get("hotkey", cfg.hotkey)
|
|
89
|
+
for section, cls in (
|
|
90
|
+
("capture", CaptureConfig),
|
|
91
|
+
("privacy", PrivacyConfig),
|
|
92
|
+
("api", ApiConfig),
|
|
93
|
+
("export", ExportConfig),
|
|
94
|
+
):
|
|
95
|
+
raw = data.get(section, {})
|
|
96
|
+
current = getattr(cfg, section)
|
|
97
|
+
for f in cls.__dataclass_fields__:
|
|
98
|
+
if f in raw:
|
|
99
|
+
setattr(current, f, raw[f])
|
|
100
|
+
return cfg
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def save_config(cfg: Config) -> Path:
|
|
104
|
+
path = config_path()
|
|
105
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
106
|
+
path.write_text(
|
|
107
|
+
json.dumps(asdict(cfg), ensure_ascii=False, indent=2), encoding="utf-8"
|
|
108
|
+
)
|
|
109
|
+
return path
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""导出器包:Markdown / HTML / Word + 标注图准备。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from PIL import Image
|
|
8
|
+
|
|
9
|
+
from ..models import Session
|
|
10
|
+
|
|
11
|
+
FORMATS = ("md", "html", "docx")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def prepare_images(
|
|
15
|
+
session: Session, session_dir: Path, out_dir: Path
|
|
16
|
+
) -> dict[int, Path]:
|
|
17
|
+
"""为每一步生成带点击高亮和序号徽章的标注图。
|
|
18
|
+
|
|
19
|
+
返回 {step.index: 标注图路径};没有截图或原始文件缺失的步骤跳过。
|
|
20
|
+
"""
|
|
21
|
+
from ..capture import annotate
|
|
22
|
+
|
|
23
|
+
images_dir = out_dir / "images"
|
|
24
|
+
images_dir.mkdir(parents=True, exist_ok=True)
|
|
25
|
+
mapping: dict[int, Path] = {}
|
|
26
|
+
for step in session.steps:
|
|
27
|
+
if not step.screenshot:
|
|
28
|
+
continue
|
|
29
|
+
raw = session_dir / step.screenshot
|
|
30
|
+
if not raw.exists():
|
|
31
|
+
continue
|
|
32
|
+
img = Image.open(raw)
|
|
33
|
+
annotated = annotate(img, step.rel_x, step.rel_y, step.index)
|
|
34
|
+
path = images_dir / f"step-{step.index:02d}.png"
|
|
35
|
+
annotated.save(path)
|
|
36
|
+
mapping[step.index] = path
|
|
37
|
+
return mapping
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def export_session(
|
|
41
|
+
session: Session,
|
|
42
|
+
session_dir: Path,
|
|
43
|
+
fmt: str,
|
|
44
|
+
out_dir: Path | None = None,
|
|
45
|
+
embed_images: bool = True,
|
|
46
|
+
) -> list[Path]:
|
|
47
|
+
"""把 Session 导出为指定格式(fmt=all 时导出全部三种)。"""
|
|
48
|
+
from .docx_export import export_docx
|
|
49
|
+
from .html_export import export_html
|
|
50
|
+
from .markdown_export import export_markdown
|
|
51
|
+
|
|
52
|
+
out_dir = out_dir or session_dir / "export"
|
|
53
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
54
|
+
fmts = list(FORMATS) if fmt == "all" else [fmt]
|
|
55
|
+
unknown = set(fmts) - set(FORMATS)
|
|
56
|
+
if unknown:
|
|
57
|
+
raise ValueError(f"未知导出格式:{'、'.join(sorted(unknown))}(支持 {FORMATS})")
|
|
58
|
+
|
|
59
|
+
images = prepare_images(session, session_dir, out_dir)
|
|
60
|
+
results: list[Path] = []
|
|
61
|
+
for f in fmts:
|
|
62
|
+
if f == "md":
|
|
63
|
+
results.append(export_markdown(session, images, out_dir))
|
|
64
|
+
elif f == "html":
|
|
65
|
+
results.append(export_html(session, images, out_dir, embed_images))
|
|
66
|
+
elif f == "docx":
|
|
67
|
+
results.append(export_docx(session, images, out_dir))
|
|
68
|
+
return results
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Word(docx)导出。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from docx import Document
|
|
8
|
+
from docx.shared import Inches
|
|
9
|
+
|
|
10
|
+
from ..models import Session
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def export_docx(
|
|
14
|
+
session: Session, images: dict[int, Path], out_dir: Path
|
|
15
|
+
) -> Path:
|
|
16
|
+
doc = Document()
|
|
17
|
+
doc.add_heading(session.title or "操作教程", level=0)
|
|
18
|
+
if session.intro:
|
|
19
|
+
doc.add_paragraph(session.intro)
|
|
20
|
+
|
|
21
|
+
for step in session.steps:
|
|
22
|
+
doc.add_heading(f"{step.index}. {step.title or f'步骤 {step.index}'}", level=2)
|
|
23
|
+
if step.description:
|
|
24
|
+
doc.add_paragraph(step.description)
|
|
25
|
+
typed = step.typed_text()
|
|
26
|
+
if typed:
|
|
27
|
+
p = doc.add_paragraph()
|
|
28
|
+
run = p.add_run(f"输入:{typed}")
|
|
29
|
+
run.italic = True
|
|
30
|
+
if step.has_secret():
|
|
31
|
+
doc.add_paragraph("(本步包含密码输入,内容已隐藏)")
|
|
32
|
+
img = images.get(step.index)
|
|
33
|
+
if img:
|
|
34
|
+
doc.add_picture(str(img), width=Inches(5.9))
|
|
35
|
+
|
|
36
|
+
path = out_dir / "guide.docx"
|
|
37
|
+
doc.save(path)
|
|
38
|
+
return path
|