human-input-kit 0.2.1__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.
@@ -0,0 +1,51 @@
1
+ #!/usr/bin/env python3
2
+ """Example warmup script using human-input-kit with Playwright (async API).
3
+
4
+ Run from the package root::
5
+
6
+ python examples/demo_warmup.py
7
+ python examples/demo_warmup.py --seed 42 --url https://news.ycombinator.com
8
+
9
+ Or after install::
10
+
11
+ python -m examples.demo_warmup # if on PYTHONPATH
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import argparse
17
+ import asyncio
18
+
19
+ from human_input_kit import seed_rng
20
+ from human_input_kit.async_ import bezier_mouse_path, idle_jitter, move_mouse_along, random_scroll
21
+ from playwright.async_api import async_playwright
22
+
23
+
24
+ async def warmup(*, url: str, seed: int, headless: bool) -> None:
25
+ seed_rng(seed)
26
+ async with async_playwright() as playwright:
27
+ browser = await playwright.chromium.launch(headless=headless)
28
+ page = await browser.new_page(viewport={"width": 1280, "height": 720})
29
+ await page.goto(url, wait_until="domcontentloaded")
30
+
31
+ path = bezier_mouse_path((80, 120), (640, 360), steps=20)
32
+ await move_mouse_along(page, path)
33
+ await random_scroll(page, bursts=4)
34
+ await idle_jitter(page, moves=3)
35
+
36
+ title = await page.title()
37
+ print(f"Warmup complete (seed={seed}): {title}")
38
+ await browser.close()
39
+
40
+
41
+ def main() -> None:
42
+ parser = argparse.ArgumentParser(description="Human-input-kit warmup demo")
43
+ parser.add_argument("--url", default="https://news.ycombinator.com")
44
+ parser.add_argument("--seed", type=int, default=42, help="RNG seed for reproducible motion")
45
+ parser.add_argument("--headed", action="store_true", help="Run browser with UI")
46
+ args = parser.parse_args()
47
+ asyncio.run(warmup(url=args.url, seed=args.seed, headless=not args.headed))
48
+
49
+
50
+ if __name__ == "__main__":
51
+ main()
@@ -0,0 +1,200 @@
1
+ #!/usr/bin/env python3
2
+ """MLX profile warmup demo — pseudocode + optional cdp-connect-kit peer integration.
3
+
4
+ This script is **documentation-first**: it shows how to run human-input-kit warmup
5
+ gestures against a Multilogin X profile started via the local Launcher API.
6
+
7
+ Peer dependencies (install separately — NOT required by human-input-kit):
8
+
9
+ pip install human-input-kit
10
+ pip install cdp-connect-kit[mlx] # Launcher start/stop + CDP helpers
11
+ playwright install chromium
12
+
13
+ Environment:
14
+
15
+ export MLX_TOKEN=your_bearer_token
16
+ export MLX_FOLDER_ID=your-folder-uuid
17
+
18
+ Run with an existing CDP endpoint (skip Launcher TODOs):
19
+
20
+ CDP_URL=http://127.0.0.1:55513 python examples/mlx_warmup_demo.py --profile-id UUID
21
+
22
+ Or after wiring Launcher start below:
23
+
24
+ python examples/mlx_warmup_demo.py --profile-id UUID --url https://example.com
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import argparse
30
+ import asyncio
31
+ import os
32
+ from typing import TYPE_CHECKING
33
+
34
+ from human_input_kit import seed_rng
35
+ from human_input_kit.async_ import bezier_mouse_path, idle_jitter, move_mouse_along, random_scroll
36
+ from playwright.async_api import async_playwright
37
+
38
+ if TYPE_CHECKING:
39
+ from cdp_connect_kit.mlx import MlxLauncher, MlxSession
40
+
41
+
42
+ def _require_cdp_connect_kit() -> tuple[type[MlxLauncher], type[MlxSession]]:
43
+ try:
44
+ from cdp_connect_kit.mlx import MlxLauncher, MlxSession
45
+ except ImportError as exc:
46
+ msg = (
47
+ "cdp-connect-kit[mlx] is a peer dependency for Launcher integration.\n"
48
+ "Install with: pip install cdp-connect-kit[mlx]\n"
49
+ "Or set CDP_URL to an already-running profile and skip Launcher start."
50
+ )
51
+ raise SystemExit(msg) from exc
52
+ return MlxLauncher, MlxSession
53
+
54
+
55
+ async def start_mlx_profile(
56
+ *,
57
+ profile_id: str,
58
+ folder_id: str | None,
59
+ token: str | None,
60
+ headless: bool,
61
+ ) -> str:
62
+ """Return CDP HTTP endpoint for the started MLX profile.
63
+
64
+ TODO: Uncomment and use cdp-connect-kit once peer deps are installed.
65
+ """
66
+ cdp_url = os.environ.get("CDP_URL")
67
+ if cdp_url:
68
+ print(f"Using CDP_URL from environment: {cdp_url}")
69
+ return cdp_url.rstrip("/")
70
+
71
+ # --- Launcher start (cdp-connect-kit pattern) ---
72
+ # TODO: Remove the guard below and call MlxLauncher when ready for production.
73
+ if os.environ.get("HUMAN_INPUT_KIT_MLX_DEMO") != "1":
74
+ print(
75
+ "Pseudocode mode: Launcher start is stubbed.\n"
76
+ " 1. pip install cdp-connect-kit[mlx]\n"
77
+ " 2. export MLX_TOKEN=... MLX_FOLDER_ID=...\n"
78
+ " 3. export HUMAN_INPUT_KIT_MLX_DEMO=1\n"
79
+ " 4. Re-run this script\n"
80
+ "Or export CDP_URL=http://127.0.0.1:PORT from an already-started profile."
81
+ )
82
+ raise SystemExit(2)
83
+
84
+ resolved_folder = folder_id or os.environ.get("MLX_FOLDER_ID")
85
+ resolved_token = token or os.environ.get("MLX_TOKEN")
86
+ if not resolved_folder or not resolved_token:
87
+ raise SystemExit(
88
+ "Set MLX_FOLDER_ID + MLX_TOKEN (or pass --folder-id / --token), "
89
+ "or export CDP_URL to skip Launcher start."
90
+ )
91
+
92
+ MlxLauncher, _MlxSession = _require_cdp_connect_kit()
93
+ launcher = MlxLauncher(token=resolved_token)
94
+ try:
95
+ session = launcher.start_profile(
96
+ resolved_folder,
97
+ profile_id,
98
+ automation_type="playwright",
99
+ headless=headless,
100
+ )
101
+ print(f"MLX Launcher started profile {profile_id} → {session.http_endpoint}")
102
+ return session.http_endpoint
103
+ finally:
104
+ launcher.close()
105
+
106
+
107
+ async def stop_mlx_profile(*, profile_id: str, token: str | None) -> None:
108
+ """Stop MLX profile via Launcher API."""
109
+ if os.environ.get("CDP_URL"):
110
+ print("CDP_URL was supplied externally — skipping Launcher stop.")
111
+ return
112
+
113
+ # TODO: Call launcher.stop_profile(profile_id) when HUMAN_INPUT_KIT_MLX_DEMO=1.
114
+ if os.environ.get("HUMAN_INPUT_KIT_MLX_DEMO") != "1":
115
+ return
116
+
117
+ MlxLauncher, _ = _require_cdp_connect_kit()
118
+ resolved_token = token or os.environ.get("MLX_TOKEN")
119
+ if not resolved_token:
120
+ return
121
+ launcher = MlxLauncher(token=resolved_token)
122
+ try:
123
+ launcher.stop_profile(profile_id)
124
+ print(f"Stopped MLX profile {profile_id}")
125
+ finally:
126
+ launcher.close()
127
+
128
+
129
+ async def warmup_over_cdp(
130
+ *,
131
+ cdp_endpoint: str,
132
+ url: str,
133
+ seed: int,
134
+ ) -> None:
135
+ """Connect Playwright over CDP and run human-input-kit warmup gestures."""
136
+ seed_rng(seed)
137
+ async with async_playwright() as playwright:
138
+ browser = await playwright.chromium.connect_over_cdp(cdp_endpoint)
139
+ context = browser.contexts[0] if browser.contexts else await browser.new_context()
140
+ page = context.pages[0] if context.pages else await context.new_page()
141
+ await page.goto(url, wait_until="domcontentloaded")
142
+
143
+ path = bezier_mouse_path((80, 120), (640, 360), steps=20)
144
+ await move_mouse_along(page, path)
145
+ await random_scroll(page, bursts=4)
146
+ await idle_jitter(page, moves=3)
147
+
148
+ title = await page.title()
149
+ print(f"MLX warmup complete (seed={seed}): {title}")
150
+ await browser.close()
151
+
152
+
153
+ async def mlx_warmup_flow(
154
+ *,
155
+ profile_id: str,
156
+ folder_id: str | None,
157
+ token: str | None,
158
+ url: str,
159
+ seed: int,
160
+ headless: bool,
161
+ ) -> None:
162
+ """End-to-end: Launcher start → CDP connect → warmup → Launcher stop."""
163
+ cdp_endpoint = await start_mlx_profile(
164
+ profile_id=profile_id,
165
+ folder_id=folder_id,
166
+ token=token,
167
+ headless=headless,
168
+ )
169
+ try:
170
+ await warmup_over_cdp(cdp_endpoint=cdp_endpoint, url=url, seed=seed)
171
+ finally:
172
+ await stop_mlx_profile(profile_id=profile_id, token=token)
173
+
174
+
175
+ def main() -> None:
176
+ parser = argparse.ArgumentParser(
177
+ description="MLX profile warmup demo (peer: cdp-connect-kit[mlx])",
178
+ )
179
+ parser.add_argument("--profile-id", required=True, help="MLX profile UUID")
180
+ parser.add_argument("--folder-id", default=None, help="MLX folder UUID (or MLX_FOLDER_ID)")
181
+ parser.add_argument("--token", default=None, help="Bearer token (or MLX_TOKEN)")
182
+ parser.add_argument("--url", default="https://news.ycombinator.com")
183
+ parser.add_argument("--seed", type=int, default=42)
184
+ parser.add_argument("--headed", action="store_true", help="Launcher headless_mode=false")
185
+ args = parser.parse_args()
186
+
187
+ asyncio.run(
188
+ mlx_warmup_flow(
189
+ profile_id=args.profile_id,
190
+ folder_id=args.folder_id,
191
+ token=args.token,
192
+ url=args.url,
193
+ seed=args.seed,
194
+ headless=not args.headed,
195
+ )
196
+ )
197
+
198
+
199
+ if __name__ == "__main__":
200
+ main()
@@ -0,0 +1,23 @@
1
+ """Human-like input helpers for Playwright (sync API by default)."""
2
+
3
+ from human_input_kit import async_ as async_
4
+ from human_input_kit.bezier import Point, bezier_mouse_path
5
+ from human_input_kit.rng import get_rng, seed_rng
6
+ from human_input_kit.sync_ import human_type, idle_jitter, move_mouse_along, random_scroll
7
+ from human_input_kit.sync_ import replay_file as replay_file
8
+ from human_input_kit.sync_ import replay_gestures as replay_gestures
9
+
10
+ __all__ = [
11
+ "Point",
12
+ "async_",
13
+ "bezier_mouse_path",
14
+ "get_rng",
15
+ "human_type",
16
+ "idle_jitter",
17
+ "move_mouse_along",
18
+ "random_scroll",
19
+ "replay_file",
20
+ "replay_gestures",
21
+ "seed_rng",
22
+ ]
23
+ __version__ = "0.2.1"
@@ -0,0 +1,19 @@
1
+ """Playwright async API — human-like input helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from human_input_kit.bezier import bezier_mouse_path, move_mouse_along
6
+ from human_input_kit.idle import idle_jitter
7
+ from human_input_kit.keyboard import human_type
8
+ from human_input_kit.replay import replay_file, replay_gestures
9
+ from human_input_kit.scroll import random_scroll
10
+
11
+ __all__ = [
12
+ "bezier_mouse_path",
13
+ "human_type",
14
+ "idle_jitter",
15
+ "move_mouse_along",
16
+ "random_scroll",
17
+ "replay_file",
18
+ "replay_gestures",
19
+ ]
@@ -0,0 +1,68 @@
1
+ """Bezier mouse path generation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ from collections.abc import Sequence
7
+
8
+ from human_input_kit.rng import get_rng
9
+
10
+ Point = tuple[float, float]
11
+
12
+
13
+ def _cubic_bezier(t: float, p0: Point, p1: Point, p2: Point, p3: Point) -> Point:
14
+ u = 1.0 - t
15
+ x = u**3 * p0[0] + 3 * u**2 * t * p1[0] + 3 * u * t**2 * p2[0] + t**3 * p3[0]
16
+ y = u**3 * p0[1] + 3 * u**2 * t * p1[1] + 3 * u * t**2 * p2[1] + t**3 * p3[1]
17
+ return x, y
18
+
19
+
20
+ def bezier_mouse_path(
21
+ start: Point,
22
+ end: Point,
23
+ *,
24
+ steps: int = 25,
25
+ spread: float = 0.35,
26
+ ) -> list[Point]:
27
+ """Return a human-like curved mouse path between two points."""
28
+ rng = get_rng()
29
+ if steps < 2:
30
+ return [start, end]
31
+
32
+ dx = end[0] - start[0]
33
+ dy = end[1] - start[1]
34
+ distance = math.hypot(dx, dy) or 1.0
35
+
36
+ offset = distance * spread
37
+ cp1 = (
38
+ start[0] + dx * 0.25 + rng.uniform(-offset, offset),
39
+ start[1] + dy * 0.25 + rng.uniform(-offset, offset),
40
+ )
41
+ cp2 = (
42
+ start[0] + dx * 0.75 + rng.uniform(-offset, offset),
43
+ start[1] + dy * 0.75 + rng.uniform(-offset, offset),
44
+ )
45
+
46
+ points: list[Point] = []
47
+ jitter = min(3.0, distance * 0.01)
48
+ for i in range(steps):
49
+ t = i / (steps - 1)
50
+ x, y = _cubic_bezier(t, start, cp1, cp2, end)
51
+ if i in {0, steps - 1}:
52
+ points.append((x, y))
53
+ else:
54
+ points.append((x + rng.uniform(-jitter, jitter), y + rng.uniform(-jitter, jitter)))
55
+ points[0] = start
56
+ points[-1] = end
57
+ return points
58
+
59
+
60
+ async def move_mouse_along(page: object, path: Sequence[Point], *, delay_ms: float = 8.0) -> None:
61
+ """Move Playwright mouse along a bezier path."""
62
+ import asyncio
63
+
64
+ mouse = page.mouse # type: ignore[attr-defined]
65
+ for x, y in path:
66
+ await mouse.move(x, y)
67
+ if delay_ms > 0:
68
+ await asyncio.sleep(delay_ms / 1000.0)
human_input_kit/cli.py ADDED
@@ -0,0 +1,100 @@
1
+ """Click CLI for human-input."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from pathlib import Path
7
+
8
+ import click
9
+ from playwright.async_api import async_playwright
10
+
11
+ from human_input_kit.gestures import load_recording, save_recording
12
+ from human_input_kit.idle import idle_jitter
13
+ from human_input_kit.record import record_gestures
14
+ from human_input_kit.replay import replay_file
15
+ from human_input_kit.rng import seed_rng
16
+ from human_input_kit.scroll import random_scroll
17
+
18
+
19
+ @click.group(invoke_without_command=True)
20
+ @click.option("--show-deal", is_flag=True, help="Print Multilogin coupon info and exit.")
21
+ @click.option("--seed", default=42, show_default=True, help="RNG seed for reproducible motion.")
22
+ @click.version_option(package_name="human-input-kit")
23
+ @click.pass_context
24
+ def main(ctx: click.Context, show_deal: bool, seed: int) -> None:
25
+ """Human-like Playwright input helpers."""
26
+ if show_deal:
27
+ from human_input_kit.deal import print_show_deal
28
+
29
+ print_show_deal()
30
+ ctx.exit(0)
31
+ seed_rng(seed)
32
+ ctx.ensure_object(dict)
33
+ ctx.obj["seed"] = seed
34
+
35
+
36
+ @main.command("demo-scroll")
37
+ @click.option("--url", default="https://news.ycombinator.com", show_default=True)
38
+ @click.option("--headless/--no-headless", default=True, show_default=True)
39
+ @click.option("--bursts", default=5, show_default=True)
40
+ def demo_scroll_cmd(url: str, headless: bool, bursts: int) -> None:
41
+ """Open a page and perform human-like scrolling + idle jitter."""
42
+
43
+ async def _run() -> None:
44
+ async with async_playwright() as playwright:
45
+ browser = await playwright.chromium.launch(headless=headless)
46
+ page = await browser.new_page(viewport={"width": 1280, "height": 720})
47
+ await page.goto(url, wait_until="domcontentloaded")
48
+ await random_scroll(page, bursts=bursts)
49
+ await idle_jitter(page, moves=5)
50
+ await browser.close()
51
+
52
+ asyncio.run(_run())
53
+ click.echo(f"Completed demo scroll on {url}")
54
+
55
+
56
+ @main.command("record")
57
+ @click.option("--url", default="https://news.ycombinator.com", show_default=True)
58
+ @click.option("--duration", default=30.0, show_default=True, help="Seconds to record.")
59
+ @click.option("-o", "--output", "output_path", type=click.Path(path_type=Path), required=True)
60
+ @click.option("--headless/--no-headless", default=True, show_default=True)
61
+ def record_cmd(url: str, duration: float, output_path: Path, headless: bool) -> None:
62
+ """Record gestures to JSON (includes scripted warmup actions)."""
63
+
64
+ async def _run() -> None:
65
+ async with async_playwright() as playwright:
66
+ browser = await playwright.chromium.launch(headless=headless)
67
+ page = await browser.new_page(viewport={"width": 1280, "height": 720})
68
+ await page.goto(url, wait_until="domcontentloaded")
69
+ recording = await record_gestures(page, duration=duration)
70
+ save_recording(output_path, recording)
71
+ await browser.close()
72
+
73
+ asyncio.run(_run())
74
+ click.echo(f"Saved {duration:.0f}s recording -> {output_path}")
75
+
76
+
77
+ @main.command("replay")
78
+ @click.argument("gestures_path", type=click.Path(exists=True, path_type=Path))
79
+ @click.option("--headless/--no-headless", default=True, show_default=True)
80
+ @click.option("--speed", default=1.0, show_default=True, help="Playback speed multiplier.")
81
+ def replay_cmd(gestures_path: Path, headless: bool, speed: float) -> None:
82
+ """Replay a gestures JSON file."""
83
+ try:
84
+ load_recording(gestures_path, validate=True)
85
+ except ValueError as exc:
86
+ raise click.ClickException(str(exc)) from exc
87
+
88
+ async def _run() -> None:
89
+ async with async_playwright() as playwright:
90
+ browser = await playwright.chromium.launch(headless=headless)
91
+ page = await browser.new_page(viewport={"width": 1280, "height": 720})
92
+ await replay_file(page, gestures_path, speed=speed)
93
+ await browser.close()
94
+
95
+ asyncio.run(_run())
96
+ click.echo(f"Replayed {gestures_path}")
97
+
98
+
99
+ if __name__ == "__main__":
100
+ main()
@@ -0,0 +1,25 @@
1
+ """Multilogin partner coupon output for --show-deal."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import click
6
+
7
+ SHOW_DEAL_TEXT = """Partner info (affiliate links — optional, not required for human-input)
8
+
9
+ Human-like scroll and mouse paths help cadence — not fingerprint or IP.
10
+ They matter most inside isolated Launcher profiles at folder scale.
11
+
12
+ Multilogin X — antidetect browser & cloud phone (verify eligibility before checkout)
13
+
14
+ SAAS50 — browser plans (eligible new purchases)
15
+ MIN50 — cloud phone (eligible new purchases)
16
+
17
+ https://multilogin.com/?ref=SAAS50
18
+ Coupons: https://anti-detect.github.io/
19
+ Scripts: https://t.me/Multilogin_Scripts_Bot
20
+
21
+ Disclosure: we may earn a commission. Offers change; confirm on vendor site."""
22
+
23
+
24
+ def print_show_deal() -> None:
25
+ click.echo(SHOW_DEAL_TEXT)
@@ -0,0 +1,90 @@
1
+ """Gesture recording JSON schema validation (no external jsonschema dependency)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ ALLOWED_EVENT_TYPES = frozenset({"move", "scroll", "type", "wait"})
8
+
9
+
10
+ def validate_gesture_payload(payload: Any) -> list[str]:
11
+ """Return a list of validation error messages (empty if valid)."""
12
+ errors: list[str] = []
13
+
14
+ if not isinstance(payload, dict):
15
+ return ["Gesture file must be a JSON object"]
16
+
17
+ version = payload.get("version")
18
+ if version is None:
19
+ errors.append("Missing required field 'version'")
20
+ elif not isinstance(version, int) or version < 1:
21
+ errors.append("'version' must be a positive integer")
22
+
23
+ url = payload.get("url", "about:blank")
24
+ if not isinstance(url, str):
25
+ errors.append("'url' must be a string")
26
+
27
+ events = payload.get("events")
28
+ if events is None:
29
+ errors.append("Missing required field 'events'")
30
+ return errors
31
+ if not isinstance(events, list):
32
+ errors.append("'events' must be an array")
33
+ return errors
34
+
35
+ for index, item in enumerate(events):
36
+ prefix = f"events[{index}]"
37
+ if not isinstance(item, dict):
38
+ errors.append(f"{prefix} must be an object")
39
+ continue
40
+
41
+ event_type = item.get("type")
42
+ if event_type not in ALLOWED_EVENT_TYPES:
43
+ errors.append(f"{prefix}.type must be one of {sorted(ALLOWED_EVENT_TYPES)}")
44
+ continue
45
+
46
+ t = item.get("t")
47
+ if t is None:
48
+ errors.append(f"{prefix} missing required field 't'")
49
+ elif not isinstance(t, (int, float)) or t < 0:
50
+ errors.append(f"{prefix}.t must be a non-negative number")
51
+
52
+ errors.extend(_validate_event_fields(prefix, event_type, item))
53
+
54
+ return errors
55
+
56
+
57
+ def _validate_event_fields(prefix: str, event_type: str, item: dict[str, Any]) -> list[str]:
58
+ errors: list[str] = []
59
+
60
+ if event_type == "move":
61
+ for field in ("x", "y"):
62
+ value = item.get(field)
63
+ if value is None:
64
+ errors.append(f"{prefix} move events require '{field}'")
65
+ elif not isinstance(value, (int, float)):
66
+ errors.append(f"{prefix}.{field} must be a number")
67
+ elif event_type == "scroll":
68
+ if item.get("dx") is None and item.get("dy") is None:
69
+ errors.append(f"{prefix} scroll events require 'dx' and/or 'dy'")
70
+ for field in ("dx", "dy"):
71
+ value = item.get(field)
72
+ if value is not None and not isinstance(value, (int, float)):
73
+ errors.append(f"{prefix}.{field} must be a number")
74
+ elif event_type == "type":
75
+ text = item.get("text")
76
+ if text is None:
77
+ errors.append(f"{prefix} type events require 'text'")
78
+ elif not isinstance(text, str):
79
+ errors.append(f"{prefix}.text must be a string")
80
+ selector = item.get("selector")
81
+ if selector is not None and not isinstance(selector, str):
82
+ errors.append(f"{prefix}.selector must be a string")
83
+ elif event_type == "wait":
84
+ duration = item.get("duration_ms")
85
+ if duration is None:
86
+ errors.append(f"{prefix} wait events require 'duration_ms'")
87
+ elif not isinstance(duration, (int, float)) or duration <= 0:
88
+ errors.append(f"{prefix}.duration_ms must be a positive number")
89
+
90
+ return errors
@@ -0,0 +1,84 @@
1
+ """Gesture recording format."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from dataclasses import asdict, dataclass, field
7
+ from pathlib import Path
8
+ from typing import Any, Literal
9
+
10
+ GestureType = Literal["move", "scroll", "type", "wait"]
11
+
12
+
13
+ @dataclass(slots=True)
14
+ class GestureEvent:
15
+ type: GestureType
16
+ t: float
17
+ x: float | None = None
18
+ y: float | None = None
19
+ dx: float | None = None
20
+ dy: float | None = None
21
+ text: str | None = None
22
+ selector: str | None = None
23
+ duration_ms: float | None = None
24
+
25
+ def to_dict(self) -> dict[str, Any]:
26
+ return {k: v for k, v in asdict(self).items() if v is not None}
27
+
28
+
29
+ @dataclass(slots=True)
30
+ class GestureRecording:
31
+ version: int = 1
32
+ url: str = "about:blank"
33
+ events: list[GestureEvent] = field(default_factory=list)
34
+
35
+ def to_dict(self) -> dict[str, Any]:
36
+ return {
37
+ "version": self.version,
38
+ "url": self.url,
39
+ "events": [event.to_dict() for event in self.events],
40
+ }
41
+
42
+ @classmethod
43
+ def from_dict(cls, payload: dict[str, Any]) -> GestureRecording:
44
+ events = []
45
+ for item in payload.get("events", []):
46
+ if isinstance(item, dict):
47
+ events.append(
48
+ GestureEvent(
49
+ type=item["type"],
50
+ t=float(item.get("t", 0)),
51
+ x=item.get("x"),
52
+ y=item.get("y"),
53
+ dx=item.get("dx"),
54
+ dy=item.get("dy"),
55
+ text=item.get("text"),
56
+ selector=item.get("selector"),
57
+ duration_ms=item.get("duration_ms"),
58
+ )
59
+ )
60
+ return cls(
61
+ version=int(payload.get("version", 1)),
62
+ url=str(payload.get("url", "about:blank")),
63
+ events=events,
64
+ )
65
+
66
+
67
+ def save_recording(path: Path | str, recording: GestureRecording) -> None:
68
+ target = Path(path)
69
+ target.parent.mkdir(parents=True, exist_ok=True)
70
+ target.write_text(json.dumps(recording.to_dict(), indent=2) + "\n", encoding="utf-8")
71
+
72
+
73
+ def load_recording(path: Path | str, *, validate: bool = True) -> GestureRecording:
74
+ payload = json.loads(Path(path).read_text(encoding="utf-8"))
75
+ if validate:
76
+ from human_input_kit.gesture_schema import validate_gesture_payload
77
+
78
+ errors = validate_gesture_payload(payload)
79
+ if errors:
80
+ msg = "Invalid gesture file:\n" + "\n".join(f" - {err}" for err in errors)
81
+ raise ValueError(msg)
82
+ if not isinstance(payload, dict):
83
+ raise ValueError("Gesture file must be a JSON object")
84
+ return GestureRecording.from_dict(payload)