captchakraken 3.2.1 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/AGENTS.md +5 -9
  2. package/LICENSE +165 -145
  3. package/NOTICE +4 -4
  4. package/README.md +10 -11
  5. package/dist/cli-invocation.d.ts +6 -3
  6. package/dist/cli-invocation.js +26 -10
  7. package/dist/humanize.d.ts +37 -1
  8. package/dist/humanize.js +143 -9
  9. package/dist/index.d.ts +13 -12
  10. package/dist/index.js +25 -33
  11. package/dist/kinds.d.ts +15 -2
  12. package/dist/kinds.js +12 -4
  13. package/dist/playwright-types.d.ts +3 -0
  14. package/dist/selectors.d.ts +93 -11
  15. package/dist/selectors.js +226 -116
  16. package/dist/solver.d.ts +92 -12
  17. package/dist/solver.js +562 -203
  18. package/dist/types.d.ts +53 -8
  19. package/dist/verdicts.d.ts +3 -0
  20. package/dist/verdicts.js +6 -0
  21. package/package.json +4 -4
  22. package/python/AGENTS.md +7 -11
  23. package/python/LICENSE +165 -145
  24. package/python/NOTICE +4 -4
  25. package/python/README.md +10 -9
  26. package/python/pyproject.toml +27 -13
  27. package/python/src/captchakraken/__init__.py +43 -46
  28. package/python/src/captchakraken/action_types.py +32 -1
  29. package/python/src/captchakraken/captchakraken.config.json +416 -0
  30. package/python/src/captchakraken/cli.py +66 -30
  31. package/python/src/captchakraken/clipvideo.py +258 -0
  32. package/python/src/captchakraken/humanize.py +141 -10
  33. package/python/src/captchakraken/image_processor.py +3 -7
  34. package/python/src/captchakraken/keyframes.py +96 -14
  35. package/python/src/captchakraken/kinds.py +14 -2
  36. package/python/src/captchakraken/models.json +28 -3
  37. package/python/src/captchakraken/page_solver.py +595 -251
  38. package/python/src/captchakraken/planner.py +298 -47
  39. package/python/src/captchakraken/pointer/__init__.py +20 -0
  40. package/python/src/captchakraken/pointer/detector.py +333 -0
  41. package/python/src/captchakraken/pointer/elements.py +273 -0
  42. package/python/src/captchakraken/pointer/heads.py +163 -0
  43. package/python/src/captchakraken/pointer/layout.py +319 -0
  44. package/python/src/captchakraken/pointer/marks.py +184 -0
  45. package/python/src/captchakraken/pointer/server.py +146 -0
  46. package/python/src/captchakraken/pointer/service.py +553 -0
  47. package/python/src/captchakraken/pointer/vllm_plugin.py +109 -0
  48. package/python/src/captchakraken/prompts.py +111 -34
  49. package/python/src/captchakraken/selectors.py +221 -135
  50. package/python/src/captchakraken/server_manager.py +18 -0
  51. package/python/src/captchakraken/solver.py +169 -88
  52. package/python/src/captchakraken/tool_calls/board_painted.py +11 -6
  53. package/python/src/captchakraken/tool_calls/fill_tracker.py +152 -0
  54. package/python/src/captchakraken/tool_calls/find_checkbox.py +14 -59
  55. package/python/src/captchakraken/tool_calls/find_grid.py +273 -1191
  56. package/python/src/captchakraken/tool_calls/slide_tracker.py +1102 -0
  57. package/python/src/captchakraken/verdicts.py +16 -0
  58. package/dist/slide-geometry.d.ts +0 -6
  59. package/dist/slide-geometry.js +0 -38
  60. package/python/src/captchakraken/tool_calls/track_piece.py +0 -120
@@ -0,0 +1,258 @@
1
+ """Turn a recorded captcha clip into the frames the model watches as video.
2
+
3
+ Models on prompt generation 3 and later see an animated captcha through the video pathway rather than as
4
+ numbered stills: every two consecutive frames share one temporal patch, so motion reaches the vision encoder
5
+ directly. The frames travel as a JPEG sequence (`data:video/jpeg;base64,f0,f1,...`), so no codec is involved
6
+ and the server does not resample them. The model labels its answer with the time printed before a frame
7
+ pair (`"t": 1.3`), and `time_to_index` turns that back into the recorded frame the driver waits for.
8
+
9
+ A verbatim port of the module the model was trained with: if this copy trimmed, sampled or sized a clip
10
+ differently, the time the model answers with would name a different moment than the one it watched.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import base64
15
+ import io
16
+ import math
17
+ from dataclasses import dataclass, field
18
+ from typing import List, Optional, Sequence, Tuple
19
+
20
+ import numpy as np
21
+ from PIL import Image
22
+
23
+ from .keyframes import to_gray
24
+
25
+ #: Bump when the cutting LOGIC changes in a way the constants below do not show;
26
+ #: cached frame sets carry it and are re-cut on mismatch (`clips.materialize_video`).
27
+ CUT_VERSION = 3
28
+ FPS = 5.0
29
+ MAX_FRAMES = 32
30
+ JPEG_QUALITY = 90
31
+ SIZE_FACTOR = 64
32
+ TEMPORAL_PATCH = 2
33
+ LOOP_RATIO = 0.5
34
+ LOOP_MIN_PERIOD_S = 0.6
35
+
36
+
37
+ @dataclass
38
+ class ClipVideo:
39
+ frames: List[np.ndarray]
40
+ source_indices: List[int]
41
+ source_fps: float
42
+ period: Optional[int] = None
43
+ fps: float = FPS
44
+ timestamps: List[float] = field(default_factory=list)
45
+
46
+ def __post_init__(self) -> None:
47
+ if not self.timestamps:
48
+ self.timestamps = pair_timestamps(len(self.frames), self.fps)
49
+
50
+
51
+ def frame_size(height: int, width: int, max_pixels: int) -> Tuple[int, int]:
52
+ """(H, W): multiples of SIZE_FACTOR, area <= max_pixels.
53
+
54
+ The largest such frame whose aspect is within ~10% of the clip's; the closest
55
+ aspect only when none is. Most area first, because a 64-grid rarely hits the
56
+ exact aspect and a perfect-aspect frame half the size is a worse picture.
57
+ """
58
+ aspect = height / width
59
+ cands = [(h, w) for h in range(SIZE_FACTOR, 4097, SIZE_FACTOR)
60
+ for w in range(SIZE_FACTOR, 4097, SIZE_FACTOR) if h * w <= max_pixels]
61
+ if not cands:
62
+ return SIZE_FACTOR, SIZE_FACTOR
63
+ err = {c: abs(math.log((c[0] / c[1]) / aspect)) for c in cands}
64
+ close = [c for c in cands if err[c] <= 0.1]
65
+ if close:
66
+ return max(close, key=lambda c: (c[0] * c[1], -err[c]))
67
+ return min(cands, key=lambda c: (err[c], -c[0] * c[1]))
68
+
69
+
70
+ def find_period(frames: Sequence[np.ndarray], source_fps: float) -> Optional[int]:
71
+ """The period P (in source frames) at which the WHOLE clip repeats, or None.
72
+
73
+ The frame is split into 8x8 blocks. For every block that moves, frames P apart
74
+ must differ by less than LOOP_RATIO of what neighbouring frames differ by in
75
+ that block. Requiring every moving block (not the frame average) matters: one
76
+ object out of step with the others means the clip does NOT loop, and trimming
77
+ it would lose that object's other phase. A false "no loop" only costs tokens.
78
+ """
79
+ blocks = 8
80
+ g = np.stack([resize_linear(to_gray(f), 128, 128).astype(np.float32) for f in frames])
81
+ n = len(g)
82
+
83
+ def per_block(d: np.ndarray) -> np.ndarray:
84
+ return d.reshape(d.shape[0], blocks, 128 // blocks, blocks, 128 // blocks).mean((2, 4)).mean(0)
85
+
86
+ step = per_block(np.abs(np.diff(g, axis=0)))
87
+ active = step > max(0.25 * float(step.max()), 0.3)
88
+ if not active.any():
89
+ return None
90
+ best_p, best_r = None, LOOP_RATIO
91
+ for p in range(max(2, int(round(LOOP_MIN_PERIOD_S * source_fps))), n - 2):
92
+ r = float((per_block(np.abs(g[p:] - g[:-p]))[active] / step[active]).max())
93
+ if r < best_r:
94
+ best_p, best_r = p, r
95
+ return best_p
96
+
97
+
98
+ def pair_timestamps(n_frames: int, fps: float = FPS) -> List[float]:
99
+ """The `<x.x seconds>` Qwen prints before each frame pair, as floats."""
100
+ n = n_frames + (n_frames % TEMPORAL_PATCH)
101
+ return [round((2 * k + 0.5) / fps, 1) for k in range(n // TEMPORAL_PATCH)]
102
+
103
+
104
+ def sample_indices(n_source: int, source_fps: float, period: Optional[int]) -> List[int]:
105
+ window = period if period else n_source
106
+ step = source_fps / FPS
107
+ idx = [int(round(i * step)) for i in range(int(math.floor((window - 1) / step)) + 1)]
108
+ idx = [i for i in idx if i < window] or [0]
109
+ if len(idx) > MAX_FRAMES:
110
+ idx = [int(round(x)) for x in np.linspace(0, window - 1, MAX_FRAMES)]
111
+ if len(idx) % TEMPORAL_PATCH:
112
+ nxt = min(idx[-1] + max(1, int(round(step))), n_source - 1)
113
+ idx.append(nxt)
114
+ return idx
115
+
116
+
117
+ def clip_video(frames: Sequence[np.ndarray], source_fps: float, *, max_pixels: int) -> ClipVideo:
118
+ """The frames the model watches, resized and in order, plus where each came from."""
119
+ if not frames:
120
+ raise ValueError("empty clip")
121
+ source_fps = float(source_fps) if source_fps and source_fps > 0 else 10.0
122
+ period = find_period(frames, source_fps) if len(frames) > 4 else None
123
+ idx = sample_indices(len(frames), source_fps, period)
124
+ h, w = frame_size(frames[0].shape[0], frames[0].shape[1], max_pixels)
125
+ out = [resize_area(frames[i], w, h) for i in idx]
126
+ return ClipVideo(frames=out, source_indices=idx, source_fps=source_fps, period=period)
127
+
128
+
129
+ # The resizes below reproduce OpenCV's uint8 INTER_LINEAR and INTER_AREA bit for bit, because the model was
130
+ # trained on frames OpenCV cut: the same pixels in means the same period found and the same picture watched.
131
+
132
+ def _linear_taps(src: int, dst: int, area: bool,
133
+ clamp: bool) -> Tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
134
+ """Along one axis: the two source pixels each output pixel blends, and their 11-bit weights.
135
+
136
+ `area` is INTER_AREA's upscaling rule. Only the horizontal pass clamps its weight at the edges; the vertical
137
+ pass blends the clamped edge row with itself, whose rounded weights need not sum to 2048.
138
+ """
139
+ inv = dst / src
140
+ d = np.arange(dst)
141
+ if area:
142
+ s = np.floor(d * (1.0 / inv)).astype(np.int64)
143
+ f = ((d + 1) - (s + 1) * inv).astype(np.float32)
144
+ f = np.where(f <= 0, np.float32(0), f - np.floor(f))
145
+ else:
146
+ f = ((d + 0.5) * (1.0 / inv) - 0.5).astype(np.float32)
147
+ s = np.floor(f).astype(np.int64)
148
+ f = f - s.astype(np.float32)
149
+ if clamp:
150
+ edge = (s < 0) | (s >= src - 1)
151
+ f[edge] = 0
152
+ s = np.clip(s, 0, src - 1)
153
+ w1 = np.rint(f * np.float32(2048)).astype(np.int32)
154
+ w0 = np.rint((np.float32(1) - f) * np.float32(2048)).astype(np.int32)
155
+ return np.clip(s, 0, src - 1), np.clip(s + 1, 0, src - 1), w0, w1
156
+
157
+
158
+ def resize_linear(img: np.ndarray, w: int, h: int, area: bool = False) -> np.ndarray:
159
+ """A uint8 image resized to (w, h) by OpenCV's fixed-point bilinear, with its SIMD rounding."""
160
+ x0, x1, a0, a1 = _linear_taps(img.shape[1], w, area, clamp=True)
161
+ y0, y1, b0, b1 = _linear_taps(img.shape[0], h, area, clamp=False)
162
+ across = (slice(None),) + (None,) * (img.ndim - 2)
163
+ down = (slice(None),) + (None,) * (img.ndim - 1)
164
+ s = img.astype(np.int32)
165
+ row = (np.take(s, x0, 1) * a0[across] + np.take(s, x1, 1) * a1[across]) >> 4
166
+ out = (((np.take(row, y0, 0) * b0[down]) >> 16) + ((np.take(row, y1, 0) * b1[down]) >> 16) + 2) >> 2
167
+ return out.astype(np.uint8)
168
+
169
+
170
+ def _area_taps(src: int, dst: int) -> Tuple[np.ndarray, np.ndarray]:
171
+ """Along one axis: the source pixels each output pixel averages and their float32 weights, in
172
+ OpenCV's order (padded with zero weights), since float32 sums depend on it."""
173
+ scale = src / dst
174
+ taps = []
175
+ for d in range(dst):
176
+ f1 = d * scale
177
+ f2 = f1 + scale
178
+ s2 = min(math.floor(f2), src - 1)
179
+ s1 = min(math.ceil(f1), s2)
180
+ cell = min(scale, src - f1)
181
+ t = [(s1 - 1, (s1 - f1) / cell)] if s1 - f1 > 1e-3 else []
182
+ t += [(x, 1 / cell) for x in range(s1, s2)]
183
+ t += [(s2, min(min(f2 - s2, 1.0), cell) / cell)] if f2 - s2 > 1e-3 else []
184
+ taps.append(t)
185
+ k = max(map(len, taps))
186
+ idx = np.array([[x for x, _ in t] + [0] * (k - len(t)) for t in taps])
187
+ wt = np.array([[a for _, a in t] + [0.0] * (k - len(t)) for t in taps], dtype=np.float32)
188
+ return idx, wt
189
+
190
+
191
+ def _area_pass(img: np.ndarray, axis: int, dst: int) -> np.ndarray:
192
+ idx, wt = _area_taps(img.shape[axis], dst)
193
+ shape = [1] * img.ndim
194
+ shape[axis] = dst
195
+ out = np.take(img, idx[:, 0], axis) * wt[:, 0].reshape(shape)
196
+ for j in range(1, idx.shape[1]):
197
+ out = out + np.take(img, idx[:, j], axis) * wt[:, j].reshape(shape)
198
+ return out
199
+
200
+
201
+ def resize_area(img: np.ndarray, w: int, h: int) -> np.ndarray:
202
+ """A uint8 image resized to (w, h) as OpenCV's INTER_AREA does it: a box average shrinking, bilinear growing."""
203
+ H, W = img.shape[:2]
204
+ if w > W or h > H:
205
+ return resize_linear(img, w, h, area=True)
206
+ if W % w == 0 and H % h == 0:
207
+ sx, sy = W // w, H // h
208
+ s = img.astype(np.int64).reshape(h, sy, w, sx, *img.shape[2:]).sum((1, 3))
209
+ if (sx, sy) == (2, 2):
210
+ return ((s + 2) >> 2).astype(np.uint8)
211
+ return np.rint(s.astype(np.float32) * np.float32(1 / (sx * sy))).astype(np.uint8)
212
+ out = _area_pass(_area_pass(img.astype(np.float32), 1, w), 0, h)
213
+ return np.clip(np.rint(out), 0, 255).astype(np.uint8)
214
+
215
+
216
+ def encode_jpeg(frame: np.ndarray) -> bytes:
217
+ """A BGR frame as JPEG at JPEG_QUALITY with 4:2:0 chroma, the settings the model was trained on."""
218
+ buf = io.BytesIO()
219
+ Image.fromarray(frame[..., ::-1]).save(buf, "JPEG", quality=JPEG_QUALITY, subsampling=2)
220
+ return buf.getvalue()
221
+
222
+
223
+ def data_url(clip: ClipVideo) -> str:
224
+ """vLLM's `video_url` for this clip: a JPEG sequence, no container, no codec."""
225
+ return "data:video/jpeg;base64," + ",".join(
226
+ base64.b64encode(encode_jpeg(f)).decode("ascii") for f in clip.frames)
227
+
228
+
229
+ def media_io_kwargs(fps: float = FPS) -> dict:
230
+ """What vLLM must be started with (`--media-io-kwargs`) to read data_url() as sent."""
231
+ return {"video": {"num_frames": -1, "fps": fps}}
232
+
233
+
234
+ def time_to_index(clip: ClipVideo, t: float) -> int:
235
+ """The SOURCE frame a `"t"` answer names: the first frame of the nearest pair."""
236
+ k = min(range(len(clip.timestamps)), key=lambda i: abs(clip.timestamps[i] - float(t)))
237
+ j = min(TEMPORAL_PATCH * k, len(clip.source_indices) - 1)
238
+ return clip.source_indices[j]
239
+
240
+
241
+ def index_to_time(clip: ClipVideo, source_index: int) -> float:
242
+ """The `"t"` that names a source frame: the FIRST pair lying wholly at or after it.
243
+
244
+ A label names the first moment the answer is known and clear, so it rounds
245
+ FORWARD by whole pairs, never to the nearest pair. Nearest put the bee's last
246
+ landing (frame 47) on a pair still showing the bee in flight. A frame outside
247
+ the kept loop is folded back into it first; a frame after the last pair takes
248
+ the last pair.
249
+ """
250
+ s = int(source_index)
251
+ if clip.period:
252
+ s %= clip.period
253
+ idx = clip.source_indices
254
+ for k, ts in enumerate(clip.timestamps):
255
+ pair = idx[TEMPORAL_PATCH * k:TEMPORAL_PATCH * (k + 1)]
256
+ if pair and min(pair) >= s:
257
+ return ts
258
+ return clip.timestamps[-1]
@@ -8,10 +8,11 @@ touch mode that dispatches no motion between taps still has to say where the nex
8
8
 
9
9
  from __future__ import annotations
10
10
 
11
+ import math
11
12
  import os
12
13
  import random
13
14
  import time
14
- from typing import Any, Dict, List, Optional, Sequence, Tuple
15
+ from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple
15
16
 
16
17
  from cursory import generate_trajectory
17
18
 
@@ -22,6 +23,8 @@ Point = Tuple[float, float]
22
23
  # Each device supplies its own table. An unknown kind yields no wait rather than raising, so adding
23
24
  # a pause site cannot break a custom humanizer written against an older release.
24
25
  PAUSE_KINDS = tuple(PauseKind)
26
+ # How often a held press looks at the widget, in ms: a frame hash per look, so not every frame.
27
+ HOLD_POLL_MS = (180.0, 320.0)
25
28
 
26
29
 
27
30
  def _delay(ms: float) -> None:
@@ -38,10 +41,57 @@ def _same_point(a: Point, b: Point) -> bool:
38
41
  return abs(float(a[0]) - float(b[0])) < 1e-6 and abs(float(a[1]) - float(b[1])) < 1e-6
39
42
 
40
43
 
44
+ # What one mouse event may cover. cursory's samples are up to 250 ms and 560 px apart, which the page sees as a
45
+ # cursor that vanishes and reappears: a teleport. A real mouse reports every 8 ms (125 Hz).
46
+ MAX_EVENT_GAP_MS = 8.0
47
+ MAX_EVENT_STEP_PX = 16.0
48
+ # How far a reach may swing off the straight line between its endpoints: a fraction of its length, at least a few
49
+ # pixels. cursory morphs a long recorded movement onto any pair of points, so a 5 px move swung 61 px wide and a
50
+ # 140 px one 300 px.
51
+ MAX_DEVIATION = 0.15
52
+ MIN_DEVIATION_PX = 3.0
53
+
54
+
55
+ def _nearest_on_segment(p: Point, a: Point, b: Point) -> Point:
56
+ dx, dy = b[0] - a[0], b[1] - a[1]
57
+ length2 = dx * dx + dy * dy
58
+ if length2 == 0.0:
59
+ return a
60
+ k = max(0.0, min(1.0, ((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / length2))
61
+ return a[0] + k * dx, a[1] + k * dy
62
+
63
+
64
+ def _tame(points: List[Point], start: Point, end: Point) -> List[Point]:
65
+ """Shrink the path's swing off the segment, shape kept, until it stays within MAX_DEVIATION of it."""
66
+ limit = max(MIN_DEVIATION_PX, MAX_DEVIATION * ((end[0] - start[0]) ** 2 + (end[1] - start[1]) ** 2) ** 0.5)
67
+ near = [_nearest_on_segment(p, start, end) for p in points]
68
+ worst = max((((p[0] - q[0]) ** 2 + (p[1] - q[1]) ** 2) ** 0.5 for p, q in zip(points, near)), default=0.0)
69
+ if worst <= limit:
70
+ return points
71
+ k = limit / worst
72
+ return [(q[0] + (p[0] - q[0]) * k, q[1] + (p[1] - q[1]) * k) for p, q in zip(points, near)]
73
+
74
+
75
+ def _densify(points: List[Point], timings: List[float]) -> Tuple[List[Point], List[float]]:
76
+ """Fill every gap wider than one mouse report with evenly timed samples along it."""
77
+ out_p, out_t = points[:1], timings[:1]
78
+ for (a, ta), (b, tb) in zip(zip(points, timings), zip(points[1:], timings[1:])):
79
+ dist = ((b[0] - a[0]) ** 2 + (b[1] - a[1]) ** 2) ** 0.5
80
+ n = max(1, int(-(-(tb - ta) // MAX_EVENT_GAP_MS)), int(-(-dist // MAX_EVENT_STEP_PX)))
81
+ for i in range(1, n + 1):
82
+ f = i / n
83
+ out_p.append((a[0] + (b[0] - a[0]) * f, a[1] + (b[1] - a[1]) * f))
84
+ out_t.append(ta + (tb - ta) * f)
85
+ return out_p, out_t
86
+
87
+
41
88
  def trajectory(start: Point, end: Point, frequency: int = 60) -> Tuple[List[Point], List[float]]:
42
- """A recorded human movement morphed onto the endpoints; timings are cumulative ms."""
89
+ """A recorded human movement morphed onto the endpoints, held near the line between them and sampled at
90
+ least once per mouse report; timings are cumulative ms."""
43
91
  points, timings = generate_trajectory(start, end, frequency=frequency)
44
- return [(float(x), float(y)) for x, y in points], [float(t) for t in timings]
92
+ start, end = (float(start[0]), float(start[1])), (float(end[0]), float(end[1]))
93
+ pts = _tame([(float(x), float(y)) for x, y in points], start, end)
94
+ return _densify(pts, [float(t) for t in timings])
45
95
 
46
96
 
47
97
  class Humanizer:
@@ -60,6 +110,11 @@ class Humanizer:
60
110
  def move(self, page: Any, to: Point) -> None:
61
111
  raise NotImplementedError
62
112
 
113
+ def drift(self, page: Any, to: Point) -> None:
114
+ """A held hand's pixel of wander. `move` is a reach, and a reach overshoots: on a 1px move it wandered up to
115
+ 34px, off a thin bar, which the widget takes for a release."""
116
+ self.move(page, to)
117
+
63
118
  def press(self, page: Any) -> None:
64
119
  raise NotImplementedError
65
120
 
@@ -91,6 +146,36 @@ class Humanizer:
91
146
  self.release(page)
92
147
 
93
148
 
149
+ def hold(human: "Humanizer", page: Any, to: Point, done: Callable[[], bool], max_ms: float) -> bool:
150
+ """Press at `to` until `done()` says the widget completed, or `max_ms` passes; True when it completed.
151
+
152
+ Built on move/press/release so any humanizer can hold. The hand drifts a pixel or so meanwhile, because a
153
+ press that does not move for seconds is a synthetic one, and the button is released whatever `done` raises.
154
+ """
155
+ human.move(page, to)
156
+ anchor = human.at
157
+ human.press(page)
158
+ t0 = time.monotonic()
159
+ try:
160
+ while True:
161
+ _delay(random.uniform(*HOLD_POLL_MS))
162
+ try:
163
+ getattr(human, "drift", human.move)(page, (anchor[0] + random.gauss(0.0, 0.8),
164
+ anchor[1] + random.gauss(0.0, 0.8)))
165
+ except Exception:
166
+ pass
167
+ if done():
168
+ return True
169
+ if (time.monotonic() - t0) * 1000.0 >= max_ms:
170
+ return False
171
+ finally:
172
+ human.release(page)
173
+
174
+
175
+ # How much longer than the bare round trip a step takes when the browser re-draws it as a path of its own.
176
+ BROWSER_PATH_MS = 100.0
177
+
178
+
94
179
  class MouseHumanizer(Humanizer):
95
180
  name = HumanizationMode.MOUSE
96
181
  hovers = True
@@ -104,11 +189,13 @@ class MouseHumanizer(Humanizer):
104
189
  self._viewport_cache: Optional[Dict[str, float]] = None
105
190
  self._down = False
106
191
  self._cursor_seeded = False
192
+ self._browser_draws_path: Optional[bool] = None
107
193
 
108
194
  def reset(self, page: Any) -> None:
109
195
  self._viewport_cache = None
110
196
  self._cursor_seeded = False
111
197
  self._down = False
198
+ self._browser_draws_path = None
112
199
 
113
200
  def _viewport(self, page: Any) -> Optional[Dict[str, float]]:
114
201
  """The window to keep the cursor inside; None under camoufox, which reports no viewport."""
@@ -140,22 +227,60 @@ class MouseHumanizer(Humanizer):
140
227
  except Exception:
141
228
  pass
142
229
 
230
+ def _clamp(self, page: Any, point: Point) -> Point:
231
+ # Clamp only when the viewport is known: a guessed edge coordinate deadlocks camoufox's juggler.
232
+ x, y = float(point[0]), float(point[1])
233
+ viewport = self._viewport(page)
234
+ if viewport is None:
235
+ return x, y
236
+ return max(1.0, min(x, viewport["width"] - 1.0)), max(1.0, min(y, viewport["height"] - 1.0))
237
+
238
+ def _measure_browser_path(self, page: Any, to: Point) -> bool:
239
+ """Whether the browser draws its own path for every mouse.move(), as camoufox's humanize does.
240
+
241
+ Ours on top has every sample re-drawn: a 1px step took 190ms there against 1ms, and one GeeTest slider 82s
242
+ against 13s. One short step towards `to`, timed against a zero-length move (the bare round trip, so a remote
243
+ browser is not mistaken for one), tells them apart with no movement the page could single out.
244
+ """
245
+ x0, y0 = self._clamp(page, self.at)
246
+ d = math.hypot(to[0] - x0, to[1] - y0)
247
+ step = self._clamp(page, to if d <= 3 else (x0 + 3 * (to[0] - x0) / d, y0 + 3 * (to[1] - y0) / d))
248
+ try:
249
+ page.mouse.move(x0, y0)
250
+ t = time.monotonic()
251
+ page.mouse.move(x0, y0)
252
+ round_trip = time.monotonic() - t
253
+ t = time.monotonic()
254
+ page.mouse.move(*step)
255
+ except Exception as exc:
256
+ if "Target closed" in str(exc) or "Session closed" in str(exc):
257
+ _log("could not move mouse; page or session closed")
258
+ return False
259
+ self.at = step
260
+ drawn = (time.monotonic() - t - round_trip) * 1000.0 > BROWSER_PATH_MS
261
+ if drawn:
262
+ _log("the browser draws its own pointer paths (camoufox humanize); handing it one move per gesture")
263
+ return drawn
264
+
143
265
  def move(self, page: Any, to: Point) -> None:
144
266
  self._seed_cursor(page)
145
267
  if _same_point(self.at, to):
146
268
  return
269
+ if self._browser_draws_path is None:
270
+ self._browser_draws_path = self._measure_browser_path(page, to)
271
+ if self._browser_draws_path:
272
+ self._trace(page, [to], [0.0])
273
+ return
147
274
  points, timings = trajectory(self.at, to, self._frequency)
148
275
  self._trace(page, points, timings)
149
276
 
277
+ def drift(self, page: Any, to: Point) -> None:
278
+ self._trace(page, [to], [0.0])
279
+
150
280
  def _trace(self, page: Any, points: Sequence[Point], timings: Sequence[float]) -> None:
151
- # Clamp only when the viewport is known: a guessed edge coordinate deadlocks camoufox's juggler.
152
- viewport = self._viewport(page)
153
281
  start = time.monotonic() * 1000.0
154
- for (x, y), t in zip(points, timings):
155
- x, y = float(x), float(y)
156
- if viewport is not None:
157
- x = max(1.0, min(x, viewport["width"] - 1.0))
158
- y = max(1.0, min(y, viewport["height"] - 1.0))
282
+ for point, t in zip(points, timings):
283
+ x, y = self._clamp(page, point)
159
284
  try:
160
285
  page.mouse.move(x, y)
161
286
  except Exception as exc:
@@ -395,6 +520,12 @@ class MobileHumanizer(Humanizer):
395
520
  for i, (x, y) in enumerate(points)])
396
521
  self.at = to
397
522
 
523
+ def drift(self, page: Any, to: Point) -> None:
524
+ to = (float(to[0]), float(to[1]))
525
+ if self._down:
526
+ self._touch(page).move([(to[0], to[1], 0.0)])
527
+ self.at = to
528
+
398
529
  def press(self, page: Any) -> None:
399
530
  self._touch(page).down(*self.at)
400
531
  self._down = True
@@ -1,6 +1,6 @@
1
1
  """The frame-diff primitive every movement check in both ports shares."""
2
2
 
3
- import cv2
3
+ from .keyframes import frame_diff_ratio, read_frame
4
4
 
5
5
 
6
6
  class ImageProcessor:
@@ -11,14 +11,10 @@ class ImageProcessor:
11
11
  Split from `detect_movement` so callers can report the number: GeeTest's permanent background shimmer
12
12
  sits close enough to the default threshold to fire the freshness re-solve on nearly every inference.
13
13
  """
14
- img1, img2 = cv2.imread(image1_path), cv2.imread(image2_path)
14
+ img1, img2 = read_frame(image1_path), read_frame(image2_path)
15
15
  if img1 is None or img2 is None:
16
16
  return 0.0
17
- if img1.shape != img2.shape:
18
- return 1.0
19
- gray = cv2.cvtColor(cv2.absdiff(img1, img2), cv2.COLOR_BGR2GRAY)
20
- _, thresh = cv2.threshold(gray, 30, 255, cv2.THRESH_BINARY)
21
- return cv2.countNonZero(thresh) / float(thresh.shape[0] * thresh.shape[1])
17
+ return frame_diff_ratio(img1, img2)
22
18
 
23
19
  @staticmethod
24
20
  def detect_movement(image1_path: str, image2_path: str, threshold: float = 0.005) -> bool: