captchakraken 2.1.0 → 2.3.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.
@@ -0,0 +1,28 @@
1
+ {
2
+ "_comment": [
3
+ "The model this release of CaptchaKraken is PINNED to, plus the prompts it",
4
+ "was trained to answer. CI reads this file; see tests/test_pinned_model.py.",
5
+ "",
6
+ "Why a file and not just env defaults: the adapter and the serving prompts",
7
+ "are a matched pair. On 2026-07-18 the LoRA was retrained on the content",
8
+ "schema (action: drag / drags[] / lowercase) while planner.py still asked",
9
+ "for the legacy output/PascalCase schema. The model answered in a hybrid,",
10
+ "the parser dropped it, and EVERY drag puzzle failed as 'unsupported' with",
11
+ "no test going red. Recording the prompt hashes here means any edit to a",
12
+ "serving prompt fails CI until someone consciously re-pins — which forces",
13
+ "the question 'does the pinned adapter still expect this prompt?'.",
14
+ "",
15
+ "To ship a new adapter: update lora_adapter/lora_revision, re-run the Tier 2",
16
+ "static-image gate against it, and update prompt_sha256 if the prompts moved."
17
+ ],
18
+
19
+ "base_model": "RedHatAI/Qwen3.5-9B-FP8-dynamic",
20
+ "lora_adapter": "CaptchaKraken/CaptchaKraken_v1.1",
21
+ "lora_revision": "main",
22
+ "lora_name": "captcha",
23
+
24
+ "prompt_sha256": {
25
+ "PIXEL_ACTION_PROMPT": "15d4833135330aa57ec64eb9132d15739cbf4a10bcda2e6d85d2d0ad92e0216a",
26
+ "SELECT_GRID_PROMPT": "f014dbc1cb8c773cc9501ff97aea92ff0281e5687b3d637d1c626ded9212fd3c"
27
+ }
28
+ }
@@ -10,6 +10,7 @@ planner with detect/segment/drag-refine; that code is preserved on the
10
10
  import base64
11
11
  import json
12
12
  import os
13
+ import re
13
14
  import sys
14
15
  from mimetypes import guess_type
15
16
  from typing import Any, Dict, List, Optional
@@ -22,6 +23,78 @@ from .timing import timed
22
23
 
23
24
  DEBUG = os.getenv("CAPTCHA_DEBUG", "0") == "1"
24
25
 
26
+ # Header the fleet's haproxy front (on the reverse-proxy EC2) routes on. When a
27
+ # caller sets CAPTCHA_REQUEST_PRIORITY to a positive int, every request carries
28
+ # `X-JH-Priority: <n>`; haproxy sends anything above its threshold (5) straight
29
+ # to the backup GPUs so it never competes with production traffic on the main
30
+ # 5090. The tier-2 CI gate sets this — its traffic is throwaway and must stay
31
+ # off the primary.
32
+ #
33
+ # Deliberately a HEADER, not vLLM's request-body `priority` field: that field is
34
+ # lower-is-higher and already means captcha=0 / apply=100 / label=200 on the
35
+ # priority-scheduled primary, so a value like 10 would MISORDER there (it would
36
+ # outrank apply/label). Routing is a fleet concern, kept out of the model's
37
+ # scheduler.
38
+ _PRIORITY_HEADER = "X-JH-Priority"
39
+ _PRIORITY_ENV = "CAPTCHA_REQUEST_PRIORITY"
40
+
41
+ # Hosted-API metadata. Both are OPTIONAL and absent unless deliberately set, so
42
+ # self-hosted users never send them and are unaffected.
43
+ #
44
+ # X-CK-Client — which integration issued this solve (e.g. "camoufox/0.4.11").
45
+ # camoufox sets CAPTCHA_KRAKEN_CLIENT in the env it spawns the solver with, so
46
+ # the gateway can account camoufox-attributed usage separately. Attribution
47
+ # ONLY: it is caller-supplied and therefore never priced on (the gateway
48
+ # derives billable puzzle class from the request body instead).
49
+ # X-CK-Session — groups the 1..N inference rounds of ONE captcha into a single
50
+ # billable attempt. The JS driver mints a UUID per solve() and reuses it for
51
+ # every CLI invocation in that solve, which is what lets the gateway cap an
52
+ # attempt's billable rounds instead of charging per round without limit.
53
+ _CLIENT_HEADER = "X-CK-Client"
54
+ _CLIENT_ENV = "CAPTCHA_KRAKEN_CLIENT"
55
+ _SESSION_HEADER = "X-CK-Session"
56
+ _SESSION_ENV = "CAPTCHA_KRAKEN_SESSION"
57
+
58
+ # These values reach the wire verbatim from the environment, so they are
59
+ # sanitized rather than trusted: a CR/LF would otherwise splice arbitrary
60
+ # headers into the upstream request.
61
+ _HEADER_VALUE_MAX = 128
62
+
63
+
64
+ def _clean_header_value(raw: str) -> str:
65
+ """Printable-ASCII, length-capped header value ("" when nothing survives)."""
66
+ return "".join(c for c in raw.strip() if 0x20 <= ord(c) < 0x7F)[:_HEADER_VALUE_MAX]
67
+
68
+
69
+ def routing_headers(env=None) -> Dict[str, str]:
70
+ """Fleet-routing + hosted-API headers derived from the environment.
71
+
72
+ Empty by default. Split out and env-injectable so it can be unit-tested
73
+ without a live server. A missing or non-integer CAPTCHA_REQUEST_PRIORITY
74
+ yields no header at all — an unset/garbage value must never silently tag
75
+ traffic for the backups.
76
+
77
+ Each header is derived independently: a malformed priority must not suppress
78
+ the attribution headers, or one typo would silently make a camoufox solve
79
+ look like direct traffic and understate the partner's revenue share.
80
+ """
81
+ env = os.environ if env is None else env
82
+ headers: Dict[str, str] = {}
83
+
84
+ raw = (env.get(_PRIORITY_ENV) or "").strip()
85
+ if raw:
86
+ try:
87
+ headers[_PRIORITY_HEADER] = str(int(raw))
88
+ except ValueError:
89
+ pass
90
+
91
+ for header, var in ((_CLIENT_HEADER, _CLIENT_ENV), (_SESSION_HEADER, _SESSION_ENV)):
92
+ value = _clean_header_value(env.get(var) or "")
93
+ if value:
94
+ headers[header] = value
95
+
96
+ return headers
97
+
25
98
 
26
99
  # Matches the training-distribution grid prompts in
27
100
  # cleanSamples/test/test_solutions.json -> grade.synthesize_instruction.
@@ -45,21 +118,21 @@ PIXEL_ACTION_PROMPT = (
45
118
  "Your task is to solve the captcha. Read the instruction at the top of the image carefully.\n\n"
46
119
  "Look at the puzzle and decide what action solves it. All coordinates you return must be on a "
47
120
  "normalized 0–1000 image scale (top-left = (0, 0), bottom-right = (1000, 1000)).\n\n"
121
+ "Name WHAT each object is with a short 1–2 word label, then give its position. "
48
122
  "Choose ONE response:\n\n"
49
123
  "FOR CLICK PUZZLES:\n"
50
- " Identify every position you need to click and emit them as a list of points:\n"
51
- " → \"action\": { \"action\": \"click\", \"points\": [[x1, y1], [x2, y2], ...] }\n\n"
124
+ " Label each thing you click and give its point — subjects[i] names points[i]:\n"
125
+ " → \"action\": \"click\", \"subjects\": [\"<label>\", ...], "
126
+ "\"points\": [[x1, y1], [x2, y2], ...]\n\n"
52
127
  "FOR DRAG PUZZLES:\n"
53
- " Drag ONE item at a time. The source position is the centroid of the piece you are picking up; "
54
- "the destination position is where it should end up. If multiple drags are needed, drag the topmost "
55
- "item first.\n"
56
- " → \"output\": [{ \"Action\": \"simulate_drag\", "
57
- "\"SourceDescription\": \"...\", \"SourcePosition\": { \"x\": 1-1000, \"y\": 1-1000 }, "
58
- "\"DestinationDescription\": \"...\", \"EstimatedPosition\": { \"x\": 1-1000, \"y\": 1-1000 } }]\n\n"
128
+ " Drag ONE item at a time. Label the source (the piece you pick up) and the destination "
129
+ "(where it belongs), each with a short 1–2 word label, and give both points:\n"
130
+ " → \"action\": \"drag\", \"drags\": [{ \"source\": \"<label>\", \"from\": [x, y], "
131
+ "\"destination\": \"<label>\", \"to\": [x, y] }, ...]\n\n"
59
132
  "Respond ONLY with JSON:\n"
60
133
  "{\n"
61
- " \"action\": { ... }\n"
62
- " // OR \"output\": [ ... ]\n"
134
+ " \"action\": \"click\", \"subjects\": [ ... ], \"points\": [ ... ]\n"
135
+ " // OR \"action\": \"drag\", \"drags\": [ ... ]\n"
63
136
  "}"
64
137
  )
65
138
 
@@ -128,6 +201,10 @@ class ActionPlanner:
128
201
  headers = {
129
202
  "Authorization": f"Bearer {self.api_key}",
130
203
  "Content-Type": "application/json",
204
+ # Fleet routing: absent unless CAPTCHA_REQUEST_PRIORITY is set (see
205
+ # routing_headers). Low-priority batch traffic is steered to the
206
+ # backup GPUs by the haproxy front on this header.
207
+ **routing_headers(),
131
208
  }
132
209
 
133
210
  # Hands-off server: before the very first request, make sure something
@@ -198,8 +275,13 @@ class ActionPlanner:
198
275
  else:
199
276
  return None
200
277
 
278
+ # strict=False tolerates unescaped control chars INSIDE strings — the
279
+ # model routinely emits coordinates as pretty-printed strings with a
280
+ # literal newline, e.g. "click": ["277,\n 728", ...], which strict JSON
281
+ # rejects. Without this the whole response fails to parse and a solvable
282
+ # click puzzle is dropped as "unsupported".
201
283
  try:
202
- return json.loads(text[start:end])
284
+ return json.loads(text[start:end], strict=False)
203
285
  except json.JSONDecodeError:
204
286
  # The model sometimes truncates (hit max_tokens) or over-nests its
205
287
  # pretty-printed JSON, leaving brackets unclosed. Repair by balancing
@@ -207,7 +289,7 @@ class ActionPlanner:
207
289
  repaired = ActionPlanner._balance_json(text[start:])
208
290
  if repaired is not None:
209
291
  try:
210
- return json.loads(repaired)
292
+ return json.loads(repaired, strict=False)
211
293
  except json.JSONDecodeError:
212
294
  return None
213
295
  return None
@@ -284,12 +366,31 @@ class ActionPlanner:
284
366
  "including any matches you may have overlooked."
285
367
  )
286
368
  raw = self._chat_with_image(prompt, image_path, max_tokens=128)
287
- data = self._parse_json(raw)
369
+ out = self._normalize_grid(self._parse_json(raw), total)
370
+ self._log(f"grid selection -> {out}")
371
+ return out
372
+
373
+ @staticmethod
374
+ def _normalize_grid(data: Any, total: int) -> List[int]:
375
+ """Map the model's grid JSON to a list of 1-indexed cell numbers.
376
+
377
+ Accepts the trained shape (a bare JSON array) plus the wrapped forms the
378
+ model drifts into: {"target_ids": [...]} and {"action": {"target_ids":
379
+ [...]}}. Out-of-range and non-numeric entries are dropped rather than
380
+ raising — a single junk element must not cost the whole selection.
288
381
 
382
+ Split out of get_grid_selection so the parse is testable without a
383
+ model, mirroring _normalize_pixel.
384
+ """
289
385
  if isinstance(data, list):
290
386
  ids = data
291
387
  elif isinstance(data, dict):
292
- ids = data.get("target_ids") or data.get("action", {}).get("target_ids") or []
388
+ # `action` is a dict on the grid path but a bare string ("drag") on
389
+ # the pixel path — guard so a mis-routed response returns [] rather
390
+ # than raising AttributeError.
391
+ nested = data.get("action")
392
+ nested_ids = nested.get("target_ids") if isinstance(nested, dict) else None
393
+ ids = data.get("target_ids") or nested_ids or []
293
394
  else:
294
395
  ids = []
295
396
 
@@ -297,11 +398,10 @@ class ActionPlanner:
297
398
  for v in ids:
298
399
  try:
299
400
  iv = int(v)
300
- if 1 <= iv <= total:
301
- out.append(iv)
302
401
  except (TypeError, ValueError):
303
402
  continue
304
- self._log(f"grid selection -> {out}")
403
+ if 1 <= iv <= total:
404
+ out.append(iv)
305
405
  return out
306
406
 
307
407
  def get_pixel_actions(self, image_path: str) -> List[Dict[str, Any]]:
@@ -347,10 +447,73 @@ class ActionPlanner:
347
447
  fx, fy = min(max(fx, 0.0), 1.0), min(max(fy, 0.0), 1.0)
348
448
  return (fx, fy)
349
449
 
450
+ def flat_numbers(v: Any) -> List[float]:
451
+ """Every number anywhere inside v, in order. Handles nested lists,
452
+ {x,y} dicts, bare numbers, and numeric strings like "277, 728" (the
453
+ model sometimes emits coordinates as strings, occasionally even split
454
+ across separate array elements)."""
455
+ nums: List[float] = []
456
+ if isinstance(v, bool):
457
+ return nums
458
+ if isinstance(v, (list, tuple)):
459
+ for e in v:
460
+ nums.extend(flat_numbers(e))
461
+ elif isinstance(v, dict):
462
+ for k in ("x", "y", "X", "Y"):
463
+ if k in v:
464
+ nums.extend(flat_numbers(v[k]))
465
+ elif isinstance(v, (int, float)):
466
+ nums.append(float(v))
467
+ elif isinstance(v, str):
468
+ nums.extend(float(m) for m in re.findall(r"-?\d+(?:\.\d+)?", v))
469
+ return nums
470
+
471
+ def coordish(v: Any) -> bool:
472
+ """True when v is a non-empty list of COORDINATES (numbers, [x,y]
473
+ pairs, {x,y} dicts, or numeric strings) — not text labels like
474
+ "dog". Used to decide whether a "click"/"coordinates" array carries
475
+ points we should salvage."""
476
+ if not isinstance(v, (list, tuple)) or not v:
477
+ return False
478
+ for e in v:
479
+ if isinstance(e, bool):
480
+ return False
481
+ if isinstance(e, (int, float, list, tuple, dict)):
482
+ continue
483
+ if isinstance(e, str) and re.search(r"\d", e):
484
+ continue
485
+ return False
486
+ return True
487
+
350
488
  out: List[Dict[str, Any]] = []
351
489
  if not isinstance(data, dict):
352
490
  return out
353
491
 
492
+ # ---- drag (CANONICAL — the schema PIXEL_ACTION_PROMPT asks for and the
493
+ # LoRA is trained on, see instructions.py::ACTION_INSTRUCTION):
494
+ # {"action":"drag","drags":[{"source","from":[x,y],"destination","to":[x,y]}]}
495
+ # Note "action" here is the STRING "drag", not a dict, so the click path
496
+ # below never sees it. The legacy/simulate_drag branches that follow stay
497
+ # as fallbacks for older adapters.
498
+ content_drags = data.get("drags")
499
+ if content_drags is None and isinstance(data.get("action"), dict):
500
+ content_drags = data["action"].get("drags")
501
+ if isinstance(content_drags, dict):
502
+ content_drags = [content_drags]
503
+ if isinstance(content_drags, list) and content_drags:
504
+ for d in content_drags:
505
+ if not isinstance(d, dict):
506
+ continue
507
+ snums = flat_numbers(d.get("from"))
508
+ dnums = flat_numbers(d.get("to"))
509
+ if len(snums) >= 2 and len(dnums) >= 2:
510
+ src = norm_xy(snums[0], snums[1])
511
+ dst = norm_xy(dnums[0], dnums[1])
512
+ if src and dst:
513
+ out.append({"kind": "drag", "src": src, "dst": dst})
514
+ if out:
515
+ return out
516
+
354
517
  # ---- drag: {"output": [ {simulate_drag ...}, ... ]} ----
355
518
  drags = data.get("output")
356
519
  if isinstance(drags, list) and drags:
@@ -366,6 +529,54 @@ class ActionPlanner:
366
529
  if out:
367
530
  return out
368
531
 
532
+ # ---- drag: {"action": {"simulate_drag": [{source_position,
533
+ # destination_position}]}} ----
534
+ # The full-puzzle LoRA actually emits drags in this snake_case shape
535
+ # (not the prompt's {"output":[{"Action":"simulate_drag",...}]}), and
536
+ # packs each coordinate as {"x": [x, y]} — flat_numbers() pulls the pair
537
+ # out of that. Without this branch a correctly-solved drag (e.g. hCaptcha
538
+ # "drag ONE character to the matching character") is dropped as
539
+ # "unsupported".
540
+ sd = data.get("simulate_drag")
541
+ if sd is None and isinstance(action := data.get("action"), dict):
542
+ sd = action.get("simulate_drag")
543
+ # The model emits simulate_drag as EITHER a list of drags OR a single
544
+ # drag object; normalize the single-object form to a one-element list so
545
+ # both parse (the object form was silently dropped as "unsupported").
546
+ if isinstance(sd, dict):
547
+ sd = [sd]
548
+ if isinstance(sd, list) and sd:
549
+ def _drag_coords(obj: Any, *roles: str) -> List[float]:
550
+ # Pull the source/destination coordinate pair regardless of key
551
+ # casing or separators — the LoRA freely varies between
552
+ # source_position / sourcePosition / SourcePosition and
553
+ # destination_position / destinationPosition / EstimatedPosition.
554
+ # The coord value may be {"x": [x, y]}, [x, y], {x, y}, or a
555
+ # string; flat_numbers() handles all of those.
556
+ if not isinstance(obj, dict):
557
+ return []
558
+ for k, v in obj.items():
559
+ kn = str(k).lower().replace("_", "").replace("-", "")
560
+ if "pos" not in kn:
561
+ continue
562
+ if any(r in kn for r in roles):
563
+ nums = flat_numbers(v)
564
+ if len(nums) >= 2:
565
+ return nums
566
+ return []
567
+ for d in sd:
568
+ if not isinstance(d, dict):
569
+ continue
570
+ snums = _drag_coords(d, "source", "src")
571
+ dnums = _drag_coords(d, "destination", "dest", "estimated", "target")
572
+ if len(snums) >= 2 and len(dnums) >= 2:
573
+ src = norm_xy(snums[0], snums[1])
574
+ dst = norm_xy(dnums[0], dnums[1])
575
+ if src and dst:
576
+ out.append({"kind": "drag", "src": src, "dst": dst})
577
+ if out:
578
+ return out
579
+
369
580
  # ---- click: {"action": {"action":"click","points":[...]}} ----
370
581
  action = data.get("action")
371
582
  # The model sometimes over-wraps: {"action": {"action": {"action":
@@ -392,6 +603,26 @@ class ActionPlanner:
392
603
  return [{"kind": "drag", "src": src, "dst": dst}]
393
604
  if points is None:
394
605
  points = data.get("points")
606
+ # Salvage: some responses carry the click coordinates under "click" (or
607
+ # "coordinates") instead of "points", as [x,y] pairs, {x,y} dicts, or
608
+ # "x, y" strings (sometimes split across elements), with no "points" key
609
+ # at all. Pull every number out of a coordinate-like value and pair them
610
+ # into points so a well-intentioned answer isn't dropped as "unsupported".
611
+ # Skipped when the value is text labels ("dog", "duck") rather than coords.
612
+ if not (isinstance(points, list) and points):
613
+ for container in (action if isinstance(action, dict) else None, data):
614
+ if not isinstance(container, dict):
615
+ continue
616
+ cand = container.get("click")
617
+ if cand is None:
618
+ cand = container.get("coordinates")
619
+ if coordish(cand):
620
+ nums = flat_numbers(cand)
621
+ if len(nums) >= 2:
622
+ points = [
623
+ [nums[i], nums[i + 1]] for i in range(0, len(nums) - 1, 2)
624
+ ]
625
+ break
395
626
  if isinstance(points, list) and points:
396
627
  pts = []
397
628
  for p in points:
@@ -29,8 +29,9 @@ import requests
29
29
 
30
30
  from . import config
31
31
 
32
- # One shared state dir for the pidfile, lockfile, and server log.
33
- STATE_DIR = Path(os.getenv("CAPTCHA_KRAKEN_STATE_DIR", str(Path.home() / ".captchakraken")))
32
+ # One shared state dir for the pidfile, lockfile, server log, and the hosted-API
33
+ # credentials file. Defined in `config` so the path has a single definition.
34
+ STATE_DIR = config.state_dir()
34
35
  LOCK_FILE = STATE_DIR / "vllm.lock"
35
36
  PID_FILE = STATE_DIR / "vllm.pid"
36
37
  LOG_FILE = STATE_DIR / "vllm.log"
@@ -0,0 +1,172 @@
1
+ """
2
+ Human-like mouse trajectories for the page driver.
3
+
4
+ MIRRORS, DOES NOT PORT, `cursory-ts`. The TypeScript driver calls
5
+ `generate_trajectory()` from cursory-ts, which selects and warps a real recorded
6
+ human trace out of a bundled ~MB corpus (`data/trajectories.json.gz`). That
7
+ corpus is the package's own asset, so the Python port synthesises comparable
8
+ motion instead of shipping a second copy of someone else's data.
9
+
10
+ The CONTRACT is identical, which is what matters for keeping the two drivers in
11
+ step: given a start point, an end point and a sampling frequency, return
12
+ `(points, timings)` where `timings[i]` is the cumulative milliseconds from the
13
+ start of the gesture at which `points[i]` should be delivered. The caller
14
+ (`page_solver`) sleeps against those cumulative timings exactly as the TS driver
15
+ does, so movement pacing is driver-independent.
16
+
17
+ What makes the output human rather than a lerp:
18
+ - a Bezier arc, not a straight line (hand motion bows)
19
+ - Fitts's-law duration: distance and precision set the time, not a constant
20
+ - an ease-in-out velocity profile, so the cursor accelerates then brakes
21
+ - sub-pixel jitter that scales with speed (fast motion is sloppier)
22
+ - a small overshoot-and-correct on longer moves, which is the single most
23
+ recognisable human tell and the one a linear interpolation never produces
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import math
29
+ import random
30
+ from typing import List, Sequence, Tuple
31
+
32
+ Point = Tuple[float, float]
33
+
34
+ # Fitts's law: MT = a + b * log2(distance / width + 1). The constants are the
35
+ # usual empirical range for a mouse; `_WIDTH` stands in for target precision.
36
+ _FITTS_A_MS = 90.0
37
+ _FITTS_B_MS = 105.0
38
+ _WIDTH_PX = 28.0
39
+
40
+ # Below this, a move is a nudge inside one element: no overshoot, no arc.
41
+ _SHORT_MOVE_PX = 24.0
42
+ # Overshooting a 30px hop looks like a twitch, not a human.
43
+ _OVERSHOOT_MIN_DISTANCE_PX = 220.0
44
+
45
+
46
+ def _ease_in_out(t: float) -> float:
47
+ """Cubic ease. Real pointer motion is roughly bell-shaped in velocity."""
48
+ if t < 0.5:
49
+ return 4.0 * t * t * t
50
+ return 1.0 - ((-2.0 * t + 2.0) ** 3) / 2.0
51
+
52
+
53
+ def _cubic_bezier(p0: Point, p1: Point, p2: Point, p3: Point, t: float) -> Point:
54
+ u = 1.0 - t
55
+ x = u * u * u * p0[0] + 3 * u * u * t * p1[0] + 3 * u * t * t * p2[0] + t * t * t * p3[0]
56
+ y = u * u * u * p0[1] + 3 * u * u * t * p1[1] + 3 * u * t * t * p2[1] + t * t * t * p3[1]
57
+ return (x, y)
58
+
59
+
60
+ def _control_points(start: Point, end: Point, distance: float) -> Tuple[Point, Point]:
61
+ """
62
+ Two control points offset PERPENDICULAR to the travel direction, so the path
63
+ bows to one side. The sign is random per gesture: always bowing the same way
64
+ would itself be a fingerprint.
65
+ """
66
+ dx, dy = end[0] - start[0], end[1] - start[1]
67
+ if distance == 0:
68
+ return start, end
69
+ # Unit normal to the direction of travel.
70
+ nx, ny = -dy / distance, dx / distance
71
+ # Bow proportional to distance, but capped — long drags don't arc absurdly.
72
+ bow = min(distance * random.uniform(0.06, 0.16), 140.0) * random.choice((-1.0, 1.0))
73
+ # Control points at roughly 1/3 and 2/3 along, each pushed off-axis.
74
+ c1 = (
75
+ start[0] + dx * random.uniform(0.20, 0.40) + nx * bow,
76
+ start[1] + dy * random.uniform(0.20, 0.40) + ny * bow,
77
+ )
78
+ c2 = (
79
+ start[0] + dx * random.uniform(0.60, 0.80) + nx * bow * random.uniform(0.4, 0.9),
80
+ start[1] + dy * random.uniform(0.60, 0.80) + ny * bow * random.uniform(0.4, 0.9),
81
+ )
82
+ return c1, c2
83
+
84
+
85
+ def generate_trajectory(
86
+ target_start: Sequence[float],
87
+ target_end: Sequence[float],
88
+ frequency: int = 60,
89
+ frequency_randomizer: float = 0.12,
90
+ ) -> Tuple[List[Point], List[float]]:
91
+ """
92
+ Mirror of cursory-ts `generate_trajectory`.
93
+
94
+ Returns `(points, timings)`. `timings` is CUMULATIVE milliseconds from the
95
+ gesture start — not per-step deltas — because that is what the TS driver's
96
+ `tracePath` sleeps against, and the two must pace identically.
97
+
98
+ `frequency` is samples per second (the TS driver passes 60).
99
+ `frequency_randomizer` jitters each frame's timestamp so the sample interval
100
+ is not a metronome; a perfectly periodic mousemove stream is trivially
101
+ detectable.
102
+ """
103
+ start: Point = (float(target_start[0]), float(target_start[1]))
104
+ end: Point = (float(target_end[0]), float(target_end[1]))
105
+
106
+ dx, dy = end[0] - start[0], end[1] - start[1]
107
+ distance = math.hypot(dx, dy)
108
+
109
+ if distance < 1e-6:
110
+ return [end], [0.0]
111
+
112
+ duration_ms = _FITTS_A_MS + _FITTS_B_MS * math.log2(distance / _WIDTH_PX + 1.0)
113
+ duration_ms *= random.uniform(0.85, 1.20)
114
+
115
+ steps = max(2, int(round(duration_ms / 1000.0 * max(1, frequency))))
116
+
117
+ # A short hop is a straight ease with jitter — bowing a 15px move looks worse
118
+ # than not bowing it.
119
+ if distance < _SHORT_MOVE_PX:
120
+ c1, c2 = start, end
121
+ else:
122
+ c1, c2 = _control_points(start, end, distance)
123
+
124
+ # Overshoot: on a long move the hand arrives past the target and corrects.
125
+ # Modelled as a longer primary gesture to a point beyond the target, then a
126
+ # short settle back — the settle is appended as extra samples below.
127
+ overshoot = distance >= _OVERSHOOT_MIN_DISTANCE_PX and random.random() < 0.55
128
+ aim: Point = end
129
+ if overshoot:
130
+ over = random.uniform(0.01, 0.035) * distance
131
+ aim = (end[0] + dx / distance * over, end[1] + dy / distance * over)
132
+
133
+ points: List[Point] = []
134
+ timings: List[float] = []
135
+ elapsed = 0.0
136
+ per_step = duration_ms / steps
137
+
138
+ for i in range(steps + 1):
139
+ t = _ease_in_out(i / steps)
140
+ px, py = _cubic_bezier(start, c1, c2, aim, t)
141
+
142
+ # Jitter scales with instantaneous speed: the faster the cursor is
143
+ # moving, the less precisely a human tracks the intended path.
144
+ speed = abs(_ease_in_out(min(1.0, (i + 1) / steps)) - _ease_in_out(i / steps))
145
+ jitter = min(1.6, 0.25 + speed * steps * 0.5)
146
+ px += random.gauss(0.0, jitter * 0.5)
147
+ py += random.gauss(0.0, jitter * 0.5)
148
+
149
+ points.append((px, py))
150
+ timings.append(elapsed)
151
+ elapsed += per_step * random.uniform(1.0 - frequency_randomizer, 1.0 + frequency_randomizer)
152
+
153
+ if overshoot:
154
+ # Correction: a couple of small samples back onto the true target, at the
155
+ # slower pace of a deliberate fine adjustment.
156
+ settle_steps = random.randint(2, 4)
157
+ origin = points[-1]
158
+ for i in range(1, settle_steps + 1):
159
+ t = i / settle_steps
160
+ points.append(
161
+ (
162
+ origin[0] + (end[0] - origin[0]) * t + random.gauss(0.0, 0.35),
163
+ origin[1] + (end[1] - origin[1]) * t + random.gauss(0.0, 0.35),
164
+ )
165
+ )
166
+ timings.append(elapsed)
167
+ elapsed += random.uniform(14.0, 30.0)
168
+
169
+ # Land exactly on the requested pixel. Everything above is texture; the final
170
+ # sample must be the point the caller asked for or clicks drift off-target.
171
+ points[-1] = end
172
+ return points, timings