captchakraken 2.4.0 → 2.6.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.
- package/LICENSE +47 -7
- package/README.md +24 -4
- package/dist/cli-invocation.d.ts +46 -0
- package/dist/cli-invocation.js +68 -0
- package/dist/index.d.ts +27 -0
- package/dist/index.js +29 -1
- package/dist/limits.d.ts +51 -0
- package/dist/limits.js +54 -0
- package/dist/model-name.d.ts +23 -0
- package/dist/model-name.js +70 -0
- package/dist/playwright-types.d.ts +28 -0
- package/dist/puppeteer-adapter.d.ts +11 -0
- package/dist/puppeteer-adapter.js +23 -0
- package/dist/python-command.d.ts +50 -0
- package/dist/python-command.js +60 -0
- package/dist/slide-geometry.d.ts +33 -0
- package/dist/slide-geometry.js +64 -0
- package/dist/solver.d.ts +214 -12
- package/dist/solver.js +1285 -165
- package/dist/trajectory.d.ts +31 -0
- package/dist/trajectory.js +162 -0
- package/dist/types.d.ts +128 -11
- package/dist/watcher.d.ts +99 -0
- package/dist/watcher.js +129 -0
- package/package.json +2 -5
- package/python/LICENSE +47 -7
- package/python/README.md +25 -8
- package/python/examples/README.md +36 -0
- package/python/examples/_harness.py +182 -94
- package/python/examples/demoHcaptcha.py +2 -1
- package/python/examples/demoRecaptcha.py +2 -1
- package/python/examples/watch_playwright.py +56 -0
- package/python/examples/with_playwright.py +43 -0
- package/python/pyproject.toml +4 -3
- package/python/src/captchakraken/__init__.py +5 -3
- package/python/src/captchakraken/action_types.py +24 -3
- package/python/src/captchakraken/cli.py +212 -2
- package/python/src/captchakraken/config.py +39 -5
- package/python/src/captchakraken/keyframes.py +690 -0
- package/python/src/captchakraken/models.json +120 -0
- package/python/src/captchakraken/page_solver.py +1465 -102
- package/python/src/captchakraken/pinned_model.json +5 -7
- package/python/src/captchakraken/planner.py +253 -59
- package/python/src/captchakraken/prompts.py +485 -0
- package/python/src/captchakraken/solver.py +182 -11
- package/python/src/captchakraken/timing.py +70 -0
- package/python/src/captchakraken/tool_calls/find_grid.py +367 -52
- package/python/src/captchakraken/tool_calls/track_piece.py +96 -0
- package/python/src/captchakraken/trajectory.py +2 -7
- package/python/src/captchakraken/watcher.py +193 -0
- package/python/src/captchakraken/planner_types.py +0 -74
|
@@ -9,20 +9,18 @@
|
|
|
9
9
|
"for the legacy output/PascalCase schema. The model answered in a hybrid,",
|
|
10
10
|
"the parser dropped it, and EVERY drag puzzle failed as 'unsupported' with",
|
|
11
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
|
|
12
|
+
"serving prompt fails CI until someone consciously re-pins \u2014 which forces",
|
|
13
13
|
"the question 'does the pinned adapter still expect this prompt?'.",
|
|
14
14
|
"",
|
|
15
15
|
"To ship a new adapter: update lora_adapter/lora_revision, re-run the Tier 2",
|
|
16
16
|
"static-image gate against it, and update prompt_sha256 if the prompts moved."
|
|
17
17
|
],
|
|
18
|
-
|
|
19
18
|
"base_model": "RedHatAI/Qwen3.5-9B-FP8-dynamic",
|
|
20
|
-
"lora_adapter": "CaptchaKraken/
|
|
19
|
+
"lora_adapter": "CaptchaKraken/CaptchaKraken-Lora-v1.2",
|
|
21
20
|
"lora_revision": "main",
|
|
22
|
-
"lora_name": "captcha",
|
|
23
|
-
|
|
21
|
+
"lora_name": "captcha-v12",
|
|
24
22
|
"prompt_sha256": {
|
|
25
|
-
"PIXEL_ACTION_PROMPT": "
|
|
26
|
-
"SELECT_GRID_PROMPT": "
|
|
23
|
+
"PIXEL_ACTION_PROMPT": "21afc1def6faaa706ba4a8afc2e243f949fc6bb8936e624ecf8be0b82baeaae1",
|
|
24
|
+
"SELECT_GRID_PROMPT": "b259d39ac78adc92c4fe4e78aee248d073342aa4a797e9708d37dcea7532367d"
|
|
27
25
|
}
|
|
28
26
|
}
|
|
@@ -8,7 +8,9 @@ planner with detect/segment/drag-refine; that code is preserved on the
|
|
|
8
8
|
"""
|
|
9
9
|
|
|
10
10
|
import base64
|
|
11
|
+
import io
|
|
11
12
|
import json
|
|
13
|
+
import math
|
|
12
14
|
import os
|
|
13
15
|
import re
|
|
14
16
|
import sys
|
|
@@ -16,13 +18,80 @@ from mimetypes import guess_type
|
|
|
16
18
|
from typing import Any, Dict, List, Optional
|
|
17
19
|
|
|
18
20
|
import requests
|
|
21
|
+
from PIL import Image
|
|
19
22
|
|
|
20
|
-
from . import config, errors
|
|
23
|
+
from . import config, errors, prompts
|
|
21
24
|
from .server_manager import ensure_server
|
|
22
25
|
from .timing import timed
|
|
23
26
|
|
|
24
27
|
DEBUG = os.getenv("CAPTCHA_DEBUG", "0") == "1"
|
|
25
28
|
|
|
29
|
+
# The pixel floor the adapters are TRAINED at, and therefore the floor an image
|
|
30
|
+
# has to clear before it is worth sending.
|
|
31
|
+
#
|
|
32
|
+
# Training exports `MIN_PIXELS=200704` (448², in the finetune repo's
|
|
33
|
+
# scripts/train_unified.sh), so anything smaller is enlarged before the vision
|
|
34
|
+
# encoder sees it. Serving did none of that: vLLM runs with no
|
|
35
|
+
# `--mm-processor-kwargs` and this client used to re-encode the file unchanged,
|
|
36
|
+
# so a small captcha arrived at a geometry the model was never tuned on. It
|
|
37
|
+
# fails as plausible-but-wrong coordinates, never as an error — measured on real
|
|
38
|
+
# geetest_v3_slide captures (277x285), predictions landed 80-105 px from the
|
|
39
|
+
# hand label at native size and 1-4 px away once upscaled.
|
|
40
|
+
#
|
|
41
|
+
# A FLOOR, NOT A RESIZE. Images already above it are passed through byte-for-byte;
|
|
42
|
+
# re-encoding every screenshot would spend time and fidelity on the types that
|
|
43
|
+
# were never affected.
|
|
44
|
+
#
|
|
45
|
+
# Unconditional as a DEFAULT, because the deployed v1.1 adapter improves under
|
|
46
|
+
# it too (mean error ~40 px native vs ~4 px upscaled). But it is no longer a
|
|
47
|
+
# constant: `models.json` may declare a `pixel_budget` per model, because the
|
|
48
|
+
# band an adapter TRAINED under is a property of that adapter, exactly like its
|
|
49
|
+
# prompt generation. See prompts.pixel_budget.
|
|
50
|
+
MIN_PIXELS = 448 * 448
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _encode_image(path: str,
|
|
54
|
+
budget: "Optional[prompts.PixelBudget]" = None) -> tuple:
|
|
55
|
+
"""`(mime, base64)` for one image, fitted into `budget`'s pixel band.
|
|
56
|
+
|
|
57
|
+
Area is clamped, never dimensions: aspect ratio is the geometry of a grid
|
|
58
|
+
puzzle, and squashing it moves every tile centre — fatal when the answer is
|
|
59
|
+
a coordinate on a normalized 0-1000 scale.
|
|
60
|
+
|
|
61
|
+
Anything Pillow cannot open is passed through untouched — this is a
|
|
62
|
+
preprocessing nicety, and it must never be the reason a solve fails.
|
|
63
|
+
"""
|
|
64
|
+
budget = budget or prompts.pixel_budget(None)
|
|
65
|
+
with open(path, "rb") as f:
|
|
66
|
+
raw = f.read()
|
|
67
|
+
mime = guess_type(path)[0] or "image/png"
|
|
68
|
+
try:
|
|
69
|
+
im = Image.open(io.BytesIO(raw))
|
|
70
|
+
width, height = im.size
|
|
71
|
+
except Exception: # noqa: BLE001 — unreadable/animated: send it as-is
|
|
72
|
+
return mime, base64.b64encode(raw).decode()
|
|
73
|
+
cap = budget.maximum
|
|
74
|
+
if cap and width * height > cap:
|
|
75
|
+
# floor, not ceil: rounding up here can land back OVER the cap, which
|
|
76
|
+
# is the mirror of the bug the floor branch below guards against.
|
|
77
|
+
scale = math.sqrt(cap / (width * height))
|
|
78
|
+
im = im.convert("RGB").resize(
|
|
79
|
+
(max(1, int(width * scale)), max(1, int(height * scale))),
|
|
80
|
+
Image.BICUBIC)
|
|
81
|
+
buf = io.BytesIO()
|
|
82
|
+
im.save(buf, "PNG")
|
|
83
|
+
return "image/png", base64.b64encode(buf.getvalue()).decode()
|
|
84
|
+
if width * height >= budget.minimum:
|
|
85
|
+
return mime, base64.b64encode(raw).decode()
|
|
86
|
+
# ceil, not round: rounding both sides down lands just under the floor
|
|
87
|
+
# (442x454 = 200,668 for a 277x285 capture) and defeats the whole point.
|
|
88
|
+
scale = math.sqrt(budget.minimum / (width * height))
|
|
89
|
+
im = im.convert("RGB").resize(
|
|
90
|
+
(math.ceil(width * scale), math.ceil(height * scale)), Image.BICUBIC)
|
|
91
|
+
buf = io.BytesIO()
|
|
92
|
+
im.save(buf, "PNG")
|
|
93
|
+
return "image/png", base64.b64encode(buf.getvalue()).decode()
|
|
94
|
+
|
|
26
95
|
# Header the fleet's haproxy front (on the reverse-proxy EC2) routes on. When a
|
|
27
96
|
# caller sets CAPTCHA_REQUEST_PRIORITY to a positive int, every request carries
|
|
28
97
|
# `X-JH-Priority: <n>`; haproxy sends anything above its threshold (5) straight
|
|
@@ -160,45 +229,50 @@ def routing_headers(env=None) -> Dict[str, str]:
|
|
|
160
229
|
return headers
|
|
161
230
|
|
|
162
231
|
|
|
163
|
-
#
|
|
164
|
-
#
|
|
165
|
-
#
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
#
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
"
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
232
|
+
# ── Prompt constants ────────────────────────────────────────────────────────
|
|
233
|
+
#
|
|
234
|
+
# These are ALIASES for the newest generation's built-ins, kept so that code and
|
|
235
|
+
# tests written against the old module-level constants keep working. They are
|
|
236
|
+
# NOT what a solve sends. The prompt a model gets is resolved per model
|
|
237
|
+
# (`prompts.resolve`), because a model answers in whatever schema its prompt
|
|
238
|
+
# asks for — sending a version-1 model the version-2 prompt does not error, it
|
|
239
|
+
# silently degrades every puzzle. See prompts.py and models.json.
|
|
240
|
+
#
|
|
241
|
+
# The text itself lives in prompts.py and nowhere else. Two copies of a prompt
|
|
242
|
+
# is the drift this module already shipped once (2026-07-18).
|
|
243
|
+
_LATEST = prompts.builtin(prompts.LATEST_PROMPT_VERSION)
|
|
244
|
+
|
|
245
|
+
SELECT_GRID_PROMPT = _LATEST.grid_template
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
# Non-grid click/drag puzzles, newest generation. Alias — see the note above.
|
|
249
|
+
PIXEL_ACTION_PROMPT = _LATEST.action_prompt
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
# Animated puzzles: the challenge is recorded, sliced into keyframes
|
|
253
|
+
# (`keyframes.py`) and sent as an ORDINARY MULTI-IMAGE request — one image per
|
|
254
|
+
# keyframe, all in one context so the model can compare them. The answer carries
|
|
255
|
+
# a `frame` naming which keyframe it acted on, and the page driver waits for the
|
|
256
|
+
# live widget to look like that frame before pressing the mouse down.
|
|
257
|
+
#
|
|
258
|
+
# Alias for the newest generation — see the note above. Generation 1 has NO
|
|
259
|
+
# animated prompt (the family did not exist), which is why resolution is per
|
|
260
|
+
# model rather than per client.
|
|
261
|
+
VIDEO_ACTION_PROMPT_TEMPLATE = _LATEST.video_template
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def video_action_prompt(n_keyframes: int) -> str:
|
|
265
|
+
"""The prompt for a challenge served as `n_keyframes` stills.
|
|
266
|
+
|
|
267
|
+
The count is in the text because the model has no other way to know how many
|
|
268
|
+
images arrived — frame identities live in the prompt, not in the image payload.
|
|
269
|
+
Mirrors `instructions.video_instruction` in the finetune repo exactly.
|
|
270
|
+
"""
|
|
271
|
+
n = int(n_keyframes)
|
|
272
|
+
if n < 1:
|
|
273
|
+
raise ValueError(f"a keyframe request needs at least one frame, got {n_keyframes}")
|
|
274
|
+
listing = ", ".join(f"frame {i}" for i in range(1, n + 1))
|
|
275
|
+
return VIDEO_ACTION_PROMPT_TEMPLATE.format(n=n, listing=listing)
|
|
202
276
|
|
|
203
277
|
|
|
204
278
|
class ActionPlanner:
|
|
@@ -220,6 +294,21 @@ class ActionPlanner:
|
|
|
220
294
|
self.model = model or config.lora_name()
|
|
221
295
|
self.base_url = base_url or config.base_url()
|
|
222
296
|
self.api_key = api_key or config.api_key()
|
|
297
|
+
# Prompts follow the MODEL, not this client's release. Resolve by the
|
|
298
|
+
# served name first (a hosted endpoint serves `captcha`, not a repo id,
|
|
299
|
+
# and models.json maps the alias back), falling back to the adapter repo
|
|
300
|
+
# id for a self-hosted deploy that serves under its own name.
|
|
301
|
+
#
|
|
302
|
+
# Doing this ONCE per planner rather than per request: resolution can
|
|
303
|
+
# touch the Hub, and a per-request lookup would put a network call in
|
|
304
|
+
# front of every round of every solve.
|
|
305
|
+
_prompt_key = (self.model if prompts.canonical_model_id(self.model)
|
|
306
|
+
else config.lora_adapter())
|
|
307
|
+
self.prompts = prompts.resolve(_prompt_key)
|
|
308
|
+
# Resolution follows the MODEL for the same reason prompts do, and off
|
|
309
|
+
# the same key: an adapter reads a puzzle at the pixel band it trained
|
|
310
|
+
# under. Resolved once per planner — see the note above.
|
|
311
|
+
self.pixel_budget = prompts.pixel_budget(_prompt_key)
|
|
223
312
|
# Auto-start a local vLLM server on the first request if one isn't up
|
|
224
313
|
# (no-op for a healthy or remote endpoint). Guarded so we only try once.
|
|
225
314
|
self._server_ensured = False
|
|
@@ -231,11 +320,29 @@ class ActionPlanner:
|
|
|
231
320
|
self.debug_callback(f"[Planner] {message}")
|
|
232
321
|
|
|
233
322
|
def _chat_with_image(self, prompt: str, image_path: str, max_tokens: int = 512) -> str:
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
323
|
+
return self._chat_with_images(prompt, [image_path], max_tokens=max_tokens)
|
|
324
|
+
|
|
325
|
+
def _chat_with_images(
|
|
326
|
+
self, prompt: str, image_paths: List[str], max_tokens: int = 512
|
|
327
|
+
) -> str:
|
|
328
|
+
"""One request carrying N images followed by the prompt.
|
|
329
|
+
|
|
330
|
+
The images go in ONE message, before the text, in the order given. That
|
|
331
|
+
ordering is what makes an animated challenge answerable: the model is told
|
|
332
|
+
"you are given N keyframes, in order: frame 1, frame 2, …" and matches those
|
|
333
|
+
names to the images positionally. Splitting them across requests, or putting
|
|
334
|
+
the text first, would break the correspondence between the numbers in the
|
|
335
|
+
prompt and the pictures — and the frame number is the part the page driver
|
|
336
|
+
acts on.
|
|
337
|
+
"""
|
|
338
|
+
if not image_paths:
|
|
339
|
+
raise ValueError("no images to send")
|
|
340
|
+
|
|
341
|
+
parts: List[Dict[str, Any]] = []
|
|
342
|
+
for p in image_paths:
|
|
343
|
+
mime, b64 = _encode_image(p, self.pixel_budget)
|
|
344
|
+
parts.append({"type": "image_url",
|
|
345
|
+
"image_url": {"url": f"data:{mime};base64,{b64}"}})
|
|
239
346
|
|
|
240
347
|
messages = [
|
|
241
348
|
{
|
|
@@ -244,10 +351,7 @@ class ActionPlanner:
|
|
|
244
351
|
},
|
|
245
352
|
{
|
|
246
353
|
"role": "user",
|
|
247
|
-
"content": [
|
|
248
|
-
{"type": "image_url", "image_url": {"url": f"data:{mime};base64,{b64}"}},
|
|
249
|
-
{"type": "text", "text": prompt},
|
|
250
|
-
],
|
|
354
|
+
"content": [*parts, {"type": "text", "text": prompt}],
|
|
251
355
|
},
|
|
252
356
|
]
|
|
253
357
|
|
|
@@ -283,7 +387,8 @@ class ActionPlanner:
|
|
|
283
387
|
self._server_ensured = True
|
|
284
388
|
|
|
285
389
|
url = f"{self.base_url}/chat/completions"
|
|
286
|
-
self._log(f"POST {url} model={self.model} max_tokens={max_tokens}"
|
|
390
|
+
self._log(f"POST {url} model={self.model} max_tokens={max_tokens} "
|
|
391
|
+
f"images={len(parts)}")
|
|
287
392
|
|
|
288
393
|
with timed("planner.chat"):
|
|
289
394
|
resp = requests.post(url, headers=headers, json=payload, timeout=120)
|
|
@@ -414,8 +519,8 @@ class ActionPlanner:
|
|
|
414
519
|
else:
|
|
415
520
|
grid_hint = "Hint: Separate images. Select only clear matches."
|
|
416
521
|
|
|
417
|
-
prompt =
|
|
418
|
-
rows=rows, cols=cols,
|
|
522
|
+
prompt = self.prompts.grid_prompt(
|
|
523
|
+
rows=rows, cols=cols, grid_hint=grid_hint
|
|
419
524
|
)
|
|
420
525
|
if retry_mode == "missed-tiles":
|
|
421
526
|
prompt = (
|
|
@@ -465,8 +570,8 @@ class ActionPlanner:
|
|
|
465
570
|
out.append(iv)
|
|
466
571
|
return out
|
|
467
572
|
|
|
468
|
-
def get_pixel_actions(self, image_path: str) -> List[Dict[str, Any]]:
|
|
469
|
-
"""Solve a non-grid click/drag puzzle.
|
|
573
|
+
def get_pixel_actions(self, image_path: str, text_mode: bool = False) -> List[Dict[str, Any]]:
|
|
574
|
+
"""Solve a non-grid click/drag/slide/text puzzle.
|
|
470
575
|
|
|
471
576
|
Sends the trained action prompt + image, parses the model's JSON, and
|
|
472
577
|
returns a list of normalized actions with all coordinates on a 0–1
|
|
@@ -474,16 +579,75 @@ class ActionPlanner:
|
|
|
474
579
|
|
|
475
580
|
{"kind": "click", "points": [(x, y), ...]}
|
|
476
581
|
{"kind": "drag", "src": (x, y), "dst": (x, y)}
|
|
582
|
+
{"kind": "slide", "dst": (x, y)} — puzzle-piece slider
|
|
583
|
+
{"kind": "type", "text": "<the code>"} — distorted text
|
|
584
|
+
|
|
585
|
+
`text_mode` swaps in the distorted-text prompt. It is chosen by the
|
|
586
|
+
DRIVER, from the widget's DOM (a visible text box in the challenge),
|
|
587
|
+
because nothing about the picture reliably says "this one is typed" —
|
|
588
|
+
BotDetect's warped letters and hCaptcha's "click the matching letter"
|
|
589
|
+
look alike to a pixel classifier, and the two want opposite answers.
|
|
477
590
|
|
|
478
591
|
Returns [] if the model produced nothing usable. The solver turns these
|
|
479
|
-
into ClickAction / DragAction
|
|
592
|
+
into ClickAction / DragAction / TypeAction.
|
|
480
593
|
"""
|
|
481
|
-
|
|
594
|
+
prompt = self.prompts.text_prompt() if text_mode else self.prompts.action_prompt
|
|
595
|
+
raw = self._chat_with_image(prompt, image_path, max_tokens=512)
|
|
482
596
|
data = self._parse_json(raw)
|
|
483
597
|
actions = self._normalize_pixel(data)
|
|
484
598
|
self._log(f"pixel actions -> {actions}")
|
|
485
599
|
return actions
|
|
486
600
|
|
|
601
|
+
def get_keyframe_actions(self, keyframe_paths: List[str]) -> List[Dict[str, Any]]:
|
|
602
|
+
"""Solve an ANIMATED challenge from its keyframes.
|
|
603
|
+
|
|
604
|
+
Same normalized actions as `get_pixel_actions`, each additionally carrying
|
|
605
|
+
`"frame"`: the 1-based keyframe the model chose to act on, or None if it did
|
|
606
|
+
not name a usable one.
|
|
607
|
+
|
|
608
|
+
The frame is not decoration. On these puzzles the target is only there part
|
|
609
|
+
of the time, so the coordinates are only correct while the widget looks like
|
|
610
|
+
the frame they were read off. The page driver holds the mouse until it does.
|
|
611
|
+
An action with `frame=None` is still returned — the caller decides whether to
|
|
612
|
+
click blind or give up, and dropping it here would look identical to "the
|
|
613
|
+
model had nothing to say".
|
|
614
|
+
"""
|
|
615
|
+
if not keyframe_paths:
|
|
616
|
+
return []
|
|
617
|
+
# Per model, not per client: generation 1 has no animated prompt at all,
|
|
618
|
+
# so this raises a clear error there instead of sending a v1 model a
|
|
619
|
+
# keyframe request it was never trained to read.
|
|
620
|
+
prompt = self.prompts.video_prompt(len(keyframe_paths))
|
|
621
|
+
raw = self._chat_with_images(prompt, list(keyframe_paths), max_tokens=512)
|
|
622
|
+
data = self._parse_json(raw)
|
|
623
|
+
actions = self._normalize_pixel(data)
|
|
624
|
+
frame = self._normalize_frame(data, len(keyframe_paths))
|
|
625
|
+
for a in actions:
|
|
626
|
+
a["frame"] = frame
|
|
627
|
+
self._log(f"keyframe actions (frame={frame}) -> {actions}")
|
|
628
|
+
return actions
|
|
629
|
+
|
|
630
|
+
@staticmethod
|
|
631
|
+
def _normalize_frame(data: Any, n_keyframes: int) -> Optional[int]:
|
|
632
|
+
"""The keyframe number the model chose, or None.
|
|
633
|
+
|
|
634
|
+
Out-of-range is treated as "did not choose" rather than clamped. Clamping a
|
|
635
|
+
7 to 6 would invent an intent the model never had, and the driver would then
|
|
636
|
+
wait for a picture the answer was not about — a slow failure that looks like
|
|
637
|
+
a flaky page. Tolerates the nested shape the model drifts into
|
|
638
|
+
({"action": {"frame": n}}), the same way the click/drag parsing does.
|
|
639
|
+
"""
|
|
640
|
+
if not isinstance(data, dict):
|
|
641
|
+
return None
|
|
642
|
+
raw = data.get("frame")
|
|
643
|
+
if raw is None and isinstance(data.get("action"), dict):
|
|
644
|
+
raw = data["action"].get("frame")
|
|
645
|
+
try:
|
|
646
|
+
n = int(raw)
|
|
647
|
+
except (TypeError, ValueError):
|
|
648
|
+
return None
|
|
649
|
+
return n if 1 <= n <= n_keyframes else None
|
|
650
|
+
|
|
487
651
|
@staticmethod
|
|
488
652
|
def _normalize_pixel(data: Any) -> List[Dict[str, Any]]:
|
|
489
653
|
"""Map the model's 0–1000 click/drag JSON to 0–1 normalized actions.
|
|
@@ -550,6 +714,23 @@ class ActionPlanner:
|
|
|
550
714
|
if not isinstance(data, dict):
|
|
551
715
|
return out
|
|
552
716
|
|
|
717
|
+
# ---- type (CANONICAL — the schema TEXT_INSTRUCTION asks for on the
|
|
718
|
+
# distorted-text captchas: {"action": "type", "text": "<the code>"}).
|
|
719
|
+
# First, because it is the one answer family with NO coordinate in it:
|
|
720
|
+
# every branch below keys off a number, so a typed answer that fell
|
|
721
|
+
# through reached the end and normalized to nothing.
|
|
722
|
+
# The code is passed through VERBATIM — case and spacing are part of what
|
|
723
|
+
# the model read off the image, and "tidying" them submits a different
|
|
724
|
+
# answer than the one it gave.
|
|
725
|
+
act = data.get("action")
|
|
726
|
+
if isinstance(act, dict) and act.get("action") == "type":
|
|
727
|
+
act = act.get("action") # unwrap {"action": {"action": "type", ...}}
|
|
728
|
+
if act == "type" or (isinstance(data.get("text"), str) and "drags" not in data):
|
|
729
|
+
container = data if isinstance(data.get("text"), str) else data.get("action")
|
|
730
|
+
text = container.get("text") if isinstance(container, dict) else None
|
|
731
|
+
if isinstance(text, str) and text:
|
|
732
|
+
return [{"kind": "type", "text": text}]
|
|
733
|
+
|
|
553
734
|
# ---- drag (CANONICAL — the schema PIXEL_ACTION_PROMPT asks for and the
|
|
554
735
|
# LoRA is trained on, see instructions.py::ACTION_INSTRUCTION):
|
|
555
736
|
# {"action":"drag","drags":[{"source","from":[x,y],"destination","to":[x,y]}]}
|
|
@@ -567,11 +748,24 @@ class ActionPlanner:
|
|
|
567
748
|
continue
|
|
568
749
|
snums = flat_numbers(d.get("from"))
|
|
569
750
|
dnums = flat_numbers(d.get("to"))
|
|
570
|
-
if len(
|
|
751
|
+
if len(dnums) < 2:
|
|
752
|
+
continue # a piece with nowhere to go is not actionable
|
|
753
|
+
dst = norm_xy(dnums[0], dnums[1])
|
|
754
|
+
if not dst:
|
|
755
|
+
continue
|
|
756
|
+
if len(snums) >= 2:
|
|
571
757
|
src = norm_xy(snums[0], snums[1])
|
|
572
|
-
|
|
573
|
-
if src and dst:
|
|
758
|
+
if src:
|
|
574
759
|
out.append({"kind": "drag", "src": src, "dst": dst})
|
|
760
|
+
else:
|
|
761
|
+
# SOURCELESS — a puzzle-piece slider. The prompt's "FOR
|
|
762
|
+
# PUZZLE PIECE SLIDER PUZZLES" clause tells the model to
|
|
763
|
+
# leave the source empty precisely because the piece is not
|
|
764
|
+
# what you pick up: the handle is, somewhere else entirely,
|
|
765
|
+
# and how far it has to travel is not knowable from the
|
|
766
|
+
# picture. Requiring both ends here dropped the one answer
|
|
767
|
+
# shape the prompt asks for on every slide puzzle.
|
|
768
|
+
out.append({"kind": "slide", "dst": dst})
|
|
575
769
|
if out:
|
|
576
770
|
return out
|
|
577
771
|
|