captchakraken 2.9.0 → 3.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 (77) hide show
  1. package/AGENTS.md +112 -0
  2. package/NOTICE +36 -0
  3. package/dist/cli-invocation.d.ts +7 -36
  4. package/dist/cli-invocation.js +8 -49
  5. package/dist/errors.d.ts +7 -27
  6. package/dist/errors.js +6 -25
  7. package/dist/humanize.d.ts +56 -240
  8. package/dist/humanize.js +159 -415
  9. package/dist/index.d.ts +5 -129
  10. package/dist/index.js +4 -98
  11. package/dist/kinds.d.ts +135 -0
  12. package/dist/kinds.js +70 -0
  13. package/dist/limits.d.ts +0 -48
  14. package/dist/limits.js +2 -48
  15. package/dist/model-name.d.ts +4 -19
  16. package/dist/model-name.js +32 -40
  17. package/dist/playwright-types.d.ts +25 -95
  18. package/dist/playwright-types.js +0 -23
  19. package/dist/puppeteer-adapter.d.ts +4 -43
  20. package/dist/puppeteer-adapter.js +22 -58
  21. package/dist/python-command.d.ts +0 -41
  22. package/dist/python-command.js +0 -37
  23. package/dist/selectors.d.ts +54 -0
  24. package/dist/selectors.js +117 -0
  25. package/dist/slide-geometry.d.ts +2 -29
  26. package/dist/slide-geometry.js +18 -44
  27. package/dist/solver.d.ts +94 -712
  28. package/dist/solver.js +923 -3362
  29. package/dist/timing.d.ts +7 -40
  30. package/dist/timing.js +4 -36
  31. package/dist/token-usage.d.ts +1 -7
  32. package/dist/token-usage.js +10 -95
  33. package/dist/types.d.ts +75 -493
  34. package/dist/watcher.d.ts +5 -78
  35. package/dist/watcher.js +0 -56
  36. package/package.json +11 -4
  37. package/python/AGENTS.md +122 -0
  38. package/python/NOTICE +36 -0
  39. package/python/README.md +1 -2
  40. package/python/examples/README.md +10 -0
  41. package/python/examples/_harness.py +4 -73
  42. package/python/examples/demoHcaptcha.py +0 -13
  43. package/python/examples/demoRecaptcha.py +0 -10
  44. package/python/examples/watch_playwright.py +1 -26
  45. package/python/examples/with_local_gguf.py +28 -0
  46. package/python/examples/with_playwright.py +0 -19
  47. package/python/pyproject.toml +68 -19
  48. package/python/src/captchakraken/__init__.py +37 -45
  49. package/python/src/captchakraken/action_types.py +14 -36
  50. package/python/src/captchakraken/cli.py +262 -883
  51. package/python/src/captchakraken/config.py +60 -146
  52. package/python/src/captchakraken/errors.py +17 -95
  53. package/python/src/captchakraken/humanize.py +174 -597
  54. package/python/src/captchakraken/image_processor.py +16 -235
  55. package/python/src/captchakraken/keyframes.py +46 -484
  56. package/python/src/captchakraken/kinds.py +152 -0
  57. package/python/src/captchakraken/models.json +36 -4
  58. package/python/src/captchakraken/overlay.py +65 -498
  59. package/python/src/captchakraken/page_solver.py +1052 -3098
  60. package/python/src/captchakraken/planner.py +50 -487
  61. package/python/src/captchakraken/prompts.py +45 -261
  62. package/python/src/captchakraken/selectors.py +184 -0
  63. package/python/src/captchakraken/server_manager.py +7 -58
  64. package/python/src/captchakraken/solver.py +128 -522
  65. package/python/src/captchakraken/timing.py +20 -74
  66. package/python/src/captchakraken/tool_calls/board_painted.py +47 -0
  67. package/python/src/captchakraken/tool_calls/find_checkbox.py +20 -30
  68. package/python/src/captchakraken/tool_calls/find_grid.py +186 -1339
  69. package/python/src/captchakraken/tool_calls/track_piece.py +81 -57
  70. package/python/src/captchakraken/updater.py +7 -76
  71. package/python/src/captchakraken/watcher.py +12 -101
  72. package/scripts/copy-python.mjs +10 -10
  73. package/scripts/setup-python.js +1 -21
  74. package/dist/trajectory.d.ts +0 -58
  75. package/dist/trajectory.js +0 -266
  76. package/python/src/captchakraken/tool_calls/move_indicator.py +0 -431
  77. package/python/src/captchakraken/trajectory.py +0 -284
@@ -1,20 +1,8 @@
1
- """Reduce a recorded captcha clip to the few frames the model is shown.
2
-
3
- VERBATIM PORT. Everything below the docstring is byte-identical to
4
- the training-side extractor in the training repo, and
5
- `scripts/the parity gate` there fails the build if the two ever diverge.
6
-
7
- That is not tidiness, it is a correctness requirement. The model is trained on
8
- keyframes cut by that code and answers with a frame NUMBER indexing into them. If
9
- this copy sliced a live recording differently — a cycle collapsing here but not
10
- there, a different keyframe budget — the number would name a picture that does not
11
- exist, and the driver would wait for a state the page never reaches. Read that
12
- file's docstring for the algorithm and the reasoning behind every threshold.
13
-
14
- In this repo the module has one extra job: `region_box` / `region_diff_ratio` are
15
- also the solver's wait-for-state gate. The driver holds the mouse until the live
16
- page's neighbourhood around the click point matches the chosen keyframe's, using
17
- the same metric and the same box the label was chosen with.
1
+ """Reduce a recorded clip to the few frames the model is shown.
2
+
3
+ Ported from the training-side extractor; the model answers with a frame NUMBER into this slicing, so a
4
+ divergence names a picture that does not exist (see TRIBAL_KNOWLEDGE.md). `region_box` / `region_diff_ratio`
5
+ are also the driver's wait-for-state gate, with the same box the label was chosen with.
18
6
  """
19
7
 
20
8
  from __future__ import annotations
@@ -28,77 +16,32 @@ from typing import Any, Dict, List, Optional, Sequence, Tuple
28
16
  import cv2
29
17
  import numpy as np
30
18
 
31
- # ── extraction parameters ───────────────────────────────────────────────────
32
- # Defaults are tuned against the collector's burst geometry (10 fps; 4 s by
33
- # default, but `Source.burst_duration_ms` raises it per vendor so a burst covers
34
- # a full animation cycle, so clip length varies). They are all
35
- # fractions or frame counts, so they survive a change of clip length; only
36
- # MIN_HOLD_FRAMES is absolute, and it is deliberately a wall-clock idea (~0.3 s).
19
+ from .kinds import KeyframeMode
20
+
37
21
 
38
22
  DEFAULT_MAX_KEYFRAMES = 6
39
23
 
40
- # Two frames differing in fewer than this fraction of pixels are "the same
41
- # picture". A false "same" merges two distinct states, which loses an answer, so
42
- # this is the most consequential number in the file.
43
- #
44
- # MEASURED, not guessed. Over all 20 real clips in the sample corpus, the
45
- # consecutive-frame differences fall into two clusters with a 64x gap:
46
- #
47
- # same picture (compression + antialiasing) <= 0.000067 (0.067 per mille)
48
- # smallest genuine state change >= 0.004282 (4.28 per mille)
49
- #
50
- # 0.001 sits inside that gap — 15x above the noise floor, 4.3x below the faintest
51
- # real swap — and the slicing is identical anywhere from 0.0005 to 0.002, so this
52
- # is a plateau rather than a knife edge.
53
- #
54
- # It used to be 0.005, which was ABOVE the smallest real state change. That is not
55
- # a tuning nicety: a 340x384 geetest icon board swaps one tile and changes ~0.5% of
56
- # the frame, so half of its real transitions read as "the same picture". One clip
57
- # (ssqr8) merged to a single state and was declared `static` — one still for a clip
58
- # with three pictures — and another (zxpan) emitted 2 of its 3. The old value was
59
- # calibrated against 64x64 fixtures whose patch covers 14% of the frame; a real
60
- # captcha animates a sprite covering under 1% of the widget.
24
+ # Measured over 20 real clips: same picture <= 0.000067, smallest real state change >= 0.004282. The old 0.005
25
+ # sat ABOVE that change and collapsed ssqr8 to one still; it was calibrated on 64x64 fixtures.
61
26
  DEFAULT_STEADY_RATIO = 0.001
62
27
 
63
- # Two states must differ by at least this much for a clip to be called a `cycle`
64
- # and COLLAPSED to one frame per state. Deliberately NOT scaled down alongside
65
- # `steady_ratio`: the two thresholds guard opposite risks. `steady_ratio` decides
66
- # "is this the same picture", where being too generous LOSES a state — so it tracks
67
- # the measured noise floor. This one licenses the aggressive collapse, where being
68
- # too generous discards the middle of a clip, so it stays conservative. Real state
69
- # separations top out around 0.007, well under this, so `cycle` does not fire on any
70
- # real footage collected so far; `even` + dedup reaches the same answer for a rested
71
- # clip without having to decide whether it recurs. Lowering this to reach cycle mode
72
- # would buy nothing and risk the one failure that cannot be detected downstream.
28
+ # Licenses `cycle`'s collapse, where being generous discards the middle of a clip; deliberately not scaled
29
+ # with steady_ratio, and never applied to steady_screens (GeeTest svg screens are 0.005 apart).
73
30
  DEFAULT_DISTINCT_RATIO = 0.02
74
31
 
75
- # A hold has to last long enough to be a deliberate resting state, not a frame
76
- # that happened to repeat mid-animation. 3 frames @ 10 fps ≈ 0.3 s.
32
+ # A hold is a deliberate resting state, not a frame that repeated mid-animation: 3 frames @ 10 fps ~ 0.3 s.
77
33
  DEFAULT_MIN_HOLD_FRAMES = 3
78
34
 
79
- # Holds must account for at least this much of the clip. A 3-state cycle with
80
- # fast swaps is ~0.9 held; a cross-fade is ~0.0. The gap is wide, so this
81
- # threshold is not delicate.
35
+ # A 3-state cycle is ~0.9 held, a cross-fade ~0.0; the gap is wide.
82
36
  DEFAULT_MIN_STEADY_COVERAGE = 0.5
83
37
 
84
- # The pixel-difference threshold inside the similarity metric. Not tunable per
85
- # call on purpose — it is the one number shared with every other movement check
86
- # in the project (see module docstring).
38
+ # The one pixel threshold shared with every other movement check in the project.
87
39
  _PIXEL_DELTA = 30
88
40
 
89
- # Half-width of the neighbourhood around an action point, as a fraction of the
90
- # image, used both to CHOOSE which keyframe a label belongs to and — in the live
91
- # solver — to decide the page now looks like that keyframe. One constant on
92
- # purpose: "the frame this answer is valid in" and "the moment it is safe to
93
- # click" have to be the same question, or the solver waits for a state the label
94
- # never described. 0.06 ≈ a 12%-of-frame box, big enough to contain a sprite and
95
- # small enough that unrelated motion elsewhere doesn't veto the click.
41
+ # One constant for both choosing the label's frame and the live wait: the two must ask the same question.
96
42
  MATCH_REGION_HALF = 0.06
97
43
 
98
- # How different the region may be and still count as "the page looks like this
99
- # keyframe". Deliberately looser than DEFAULT_STEADY_RATIO: the live page carries
100
- # antialiasing and cursor artefacts a rendered keyframe does not, and a gate that
101
- # never opens is worse than one that opens a frame early.
44
+ # Looser than steady_ratio: the live page carries antialiasing and cursor artefacts a keyframe does not.
102
45
  MATCH_REGION_TOLERANCE = 0.05
103
46
 
104
47
  MANIFEST_NAME = "keyframes.json"
@@ -107,20 +50,13 @@ KEYFRAME_DIR_NAME = "keyframes"
107
50
 
108
51
  @dataclass(frozen=True)
109
52
  class KeyframeParams:
110
- """Everything that decides how a clip is sliced. Recorded in the manifest so
111
- a keyframe set on disk can be traced back to the settings that produced it —
112
- a threshold change is otherwise invisible and silently mixes two slicings
113
- into one training set."""
53
+ """Recorded in the manifest so a threshold change re-cuts a set instead of silently mixing two slicings."""
114
54
 
115
55
  max_keyframes: int = DEFAULT_MAX_KEYFRAMES
116
56
  steady_ratio: float = DEFAULT_STEADY_RATIO
117
57
  distinct_ratio: float = DEFAULT_DISTINCT_RATIO
118
58
  min_hold_frames: int = DEFAULT_MIN_HOLD_FRAMES
119
59
  min_steady_coverage: float = DEFAULT_MIN_STEADY_COVERAGE
120
- # Never hand the model two copies of one picture (see `_distinct_indices`).
121
- # Present as a param, not a hard-coded behaviour, for the reason every other
122
- # number here is: it lands in the manifest, so a set cut before this existed
123
- # has params that no longer match and gets re-cut instead of silently reused.
124
60
  dedupe: bool = True
125
61
 
126
62
  def as_dict(self) -> Dict[str, Any]:
@@ -136,101 +72,29 @@ class KeyframeParams:
136
72
 
137
73
  @dataclass
138
74
  class Keyframe:
139
- """One image the model will be shown.
140
-
141
- `number` is 1-based and is what the model returns as `"frame"`. It is NOT the
142
- index in the source clip: a 3-state cycle emits frames 1, 2, 3 even if their
143
- source indices are 4, 17 and 29. `source_index` keeps the provenance so the
144
- generator's ground-truth frame can be snapped onto a keyframe number and so a
145
- human labeller can scrub back to the right moment.
146
- """
75
+ """`number` is 1-based and is what the model returns as `frame`; `source_index` keeps the provenance."""
147
76
 
148
77
  number: int
149
78
  source_index: int
150
79
  timestamp_ms: float
151
- image: Optional[np.ndarray] = None # BGR uint8; None once written to disk
80
+ image: Optional[np.ndarray] = None
152
81
 
153
82
 
154
83
  @dataclass
155
84
  class KeyframeSet:
156
- mode: str # "cycle" | "even" | "static"
85
+ mode: KeyframeMode
157
86
  keyframes: List[Keyframe]
158
87
  source_frames: int
159
88
  fps: float
160
89
  params: KeyframeParams = field(default_factory=KeyframeParams)
161
- # cycle mode only: per-source-frame state id (0-based, aligned with
162
- # `keyframes`). Empty for the other modes. Lets a caller answer "which
163
- # keyframe was the clip showing at source frame i?" without re-diffing.
164
90
  frame_states: List[int] = field(default_factory=list)
165
- # How many distinct steady screens the clip sits on; 0 if it is not that
166
- # kind of clip. INDEPENDENT OF `mode`, and the difference is the whole
167
- # point: `mode` says what the slicer could PROVE about recurrence from one
168
- # burst, this says what the board looks like. A three-screen captcha whose
169
- # loop is longer than the recording is `even` with `steady_screens == 3`.
170
- # The live driver reads this to decide whether waiting for a named screen
171
- # is meaningful; reading `mode` for that put the wait off on 100% of real
172
- # animated captchas. See `steady_screens()`.
91
+ # Independent of `mode`: EVEN says recurrence could not be proved from one burst, this says how many
92
+ # screens the board sits on. Reading `mode` for that put the driver's wait off on 100% of real animated captchas.
173
93
  steady_screens: int = 0
174
94
 
175
95
  def __len__(self) -> int:
176
96
  return len(self.keyframes)
177
97
 
178
- def number_for_source_index(self, source_index: int) -> int:
179
- """The keyframe a given source frame belongs to (1-based), by POSITION.
180
-
181
- In cycle mode this is exact — the frame's own state. Otherwise it is the
182
- nearest keyframe in time. Prefer `number_for_frame` whenever the pixels are
183
- available: nearest-in-time is not the same question the solver asks, and a
184
- transition frame (state -1) has no state of its own to fall back on.
185
- """
186
- if not self.keyframes:
187
- raise ValueError("keyframe set is empty")
188
- if self.mode == "cycle" and 0 <= source_index < len(self.frame_states):
189
- state = self.frame_states[source_index]
190
- if state >= 0:
191
- return state + 1
192
- return min(
193
- self.keyframes, key=lambda k: abs(k.source_index - source_index)
194
- ).number
195
-
196
- def number_for_frame(
197
- self,
198
- frame: np.ndarray,
199
- *,
200
- point_norm: Optional[Tuple[float, float]] = None,
201
- source_index: Optional[int] = None,
202
- ) -> int:
203
- """The keyframe that LOOKS most like `frame`, 1-based.
204
-
205
- This — not nearest-in-time — is how a label's frame number is chosen,
206
- because it asks the same question the live solver asks. At solve time the
207
- driver holds the mouse until the page's neighbourhood around the click
208
- point matches the chosen keyframe; so the frame we write into the label
209
- must be the one whose *appearance* matches the moment the answer was read
210
- off, or the driver ends up waiting for a state where the target isn't
211
- there.
212
-
213
- `point_norm` (x, y in 0..1) restricts the comparison to the neighbourhood
214
- of the action point, again matching the solver. Without it the whole frame
215
- is compared, which on a multi-sprite puzzle is dominated by sprites that
216
- have nothing to do with the answer.
217
-
218
- `source_index` only breaks ties, keeping the choice stable and preferring
219
- the temporally closest of two equally-similar keyframes.
220
- """
221
- if not self.keyframes:
222
- raise ValueError("keyframe set is empty")
223
- box = region_box(frame.shape[1::-1], point_norm) if point_norm else None
224
-
225
- def cost(kf: Keyframe) -> Tuple[float, int]:
226
- if kf.image is None:
227
- raise ValueError(f"keyframe {kf.number} has no image to compare")
228
- d = region_diff_ratio(kf.image, frame, box)
229
- tie = abs(kf.source_index - source_index) if source_index is not None else 0
230
- return (d, tie)
231
-
232
- return min(self.keyframes, key=cost).number
233
-
234
98
  def manifest(self, *, stem: str, filenames: Sequence[str]) -> Dict[str, Any]:
235
99
  return {
236
100
  "stem": stem,
@@ -251,21 +115,12 @@ class KeyframeSet:
251
115
  }
252
116
 
253
117
 
254
- # ── similarity ──────────────────────────────────────────────────────────────
255
-
256
-
257
118
  def region_box(
258
119
  size_wh: Tuple[int, int],
259
120
  point_norm: Tuple[float, float],
260
121
  half: float = MATCH_REGION_HALF,
261
122
  ) -> Tuple[int, int, int, int]:
262
- """`(x1, y1, x2, y2)` pixel box of half-width `half` around a 0..1 point.
263
-
264
- Clamped to the image and never empty: a point on the very edge of the frame
265
- (a target flush against the border) would otherwise produce a zero-area box,
266
- and a comparison over no pixels reads as a perfect match — the gate would open
267
- immediately on any state at all.
268
- """
123
+ """Never empty: a comparison over no pixels reads as a perfect match and the gate would open on any state."""
269
124
  w, h = int(size_wh[0]), int(size_wh[1])
270
125
  cx, cy = float(point_norm[0]) * w, float(point_norm[1]) * h
271
126
  rx, ry = max(1.0, half * w), max(1.0, half * h)
@@ -279,11 +134,6 @@ def region_box(
279
134
  def region_diff_ratio(
280
135
  a: np.ndarray, b: np.ndarray, box: Optional[Tuple[int, int, int, int]] = None
281
136
  ) -> float:
282
- """`frame_diff_ratio` over a sub-rectangle (the whole frame when `box` is None).
283
-
284
- Mismatched shapes short-circuit before cropping, so a resized live screenshot
285
- reads as "completely different" rather than as a crop of a different geometry.
286
- """
287
137
  if a is None or b is None or a.shape != b.shape:
288
138
  return 1.0
289
139
  if box is None:
@@ -293,12 +143,6 @@ def region_diff_ratio(
293
143
 
294
144
 
295
145
  def frame_diff_ratio(a: np.ndarray, b: np.ndarray) -> float:
296
- """Fraction of pixels that differ by more than 30 in any channel.
297
-
298
- Mismatched shapes short-circuit to 1.0 ("completely different"), matching
299
- `_collect_common.max_movement_ratio`. Do not swap in a perceptual metric
300
- without changing the solver's wait gate too — see module docstring.
301
- """
302
146
  if a is None or b is None:
303
147
  return 1.0
304
148
  if a.shape != b.shape:
@@ -313,47 +157,9 @@ def frame_diff_ratio(a: np.ndarray, b: np.ndarray) -> float:
313
157
  return cv2.countNonZero(thr) / float(h * w)
314
158
 
315
159
 
316
- # ── decoding ────────────────────────────────────────────────────────────────
317
-
318
-
319
- def decode_video(path: str | os.PathLike) -> Tuple[List[np.ndarray], float]:
320
- """Every frame of a clip, in order, plus its fps.
321
-
322
- Reads with `cv2.VideoCapture` rather than trusting CAP_PROP_FRAME_COUNT,
323
- which lies on the `mp4v` files this project writes (the muxer puts `moov`
324
- last and the count comes back as 0 often enough to matter).
325
- """
326
- cap = cv2.VideoCapture(str(path))
327
- if not cap.isOpened():
328
- raise ValueError(f"could not open clip: {path}")
329
- try:
330
- fps = float(cap.get(cv2.CAP_PROP_FPS) or 0.0)
331
- frames: List[np.ndarray] = []
332
- while True:
333
- ok, frame = cap.read()
334
- if not ok:
335
- break
336
- frames.append(frame)
337
- finally:
338
- cap.release()
339
- if not frames:
340
- raise ValueError(f"clip decoded to zero frames: {path}")
341
- if not fps or fps <= 0 or fps > 240:
342
- fps = 10.0 # the collector's BURST_FPS; a sane floor beats a NaN
343
- return frames, fps
344
-
345
-
346
- # ── extraction ──────────────────────────────────────────────────────────────
347
-
348
160
 
349
161
  def _anchor_runs(frames: Sequence[np.ndarray], steady_ratio: float) -> List[Tuple[int, int]]:
350
- """Partition the clip into maximal runs that all match the run's FIRST frame.
351
-
352
- Anchored on the first frame rather than chained frame-to-frame on purpose: a
353
- slow pan has every consecutive diff under the threshold while the endpoints
354
- are nothing alike, so a chained walk would call the whole pan one steady
355
- state. Comparing against the anchor cannot drift.
356
- """
162
+ """Anchored on the run's first frame: a slow pan has every consecutive diff under the threshold."""
357
163
  runs: List[Tuple[int, int]] = []
358
164
  i = 0
359
165
  n = len(frames)
@@ -367,12 +173,6 @@ def _anchor_runs(frames: Sequence[np.ndarray], steady_ratio: float) -> List[Tupl
367
173
 
368
174
 
369
175
  def _medoid(frames: Sequence[np.ndarray], indices: Sequence[int]) -> int:
370
- """The index in `indices` whose frame is most typical of the group.
371
-
372
- For a hold this is nearly arbitrary (the frames are near-identical by
373
- construction) but it keeps a half-faded edge frame from representing the
374
- state when a run happens to straddle a transition boundary.
375
- """
376
176
  if len(indices) <= 2:
377
177
  return indices[0]
378
178
  best_idx, best_cost = indices[0], float("inf")
@@ -384,7 +184,6 @@ def _medoid(frames: Sequence[np.ndarray], indices: Sequence[int]) -> int:
384
184
 
385
185
 
386
186
  def _even_indices(n: int, count: int) -> List[int]:
387
- """`count` indices spread across 0..n-1, endpoints included, deduped."""
388
187
  if n <= count:
389
188
  return list(range(n))
390
189
  if count <= 1:
@@ -400,36 +199,10 @@ def _even_indices(n: int, count: int) -> List[int]:
400
199
  def _distinct_indices(
401
200
  frames: Sequence[np.ndarray], candidates: Sequence[int], params: KeyframeParams
402
201
  ) -> List[int]:
403
- """Drop candidates that repeat a picture already kept, then spend the freed
404
- budget on frames that are genuinely new.
405
-
406
- WHY THIS IS NEEDED EVEN THOUGH `even` MODE IS ALREADY "CORRECT". Even spacing
407
- asks a question about TIME, and a clip that rests answers it with duplicates: a
408
- geetest icon board holding three states for 12/20/8 frames, sampled at
409
- 0/8/16/23/31/39, returns state A twice and state B three times. Six images, three
410
- pictures. That is not merely wasteful of context — it makes the `frame` label
411
- AMBIGUOUS. If frames 1 and 2 are the same picture, an answer naming either is
412
- equally right, but grading compares against one number and the solver waits for
413
- one of them. A duplicate is a silently unanswerable question.
414
-
415
- Why this and not a better `cycle` detector. Distinguishing "three discrete states
416
- shown once" from "smooth motion coarsely quantised" is not decidable from
417
- `frame_diff_ratio`: the metric saturates once a sprite clears its old position,
418
- so both shapes produce equal-distance, high-coverage "holds" and the pairwise
419
- geometry that would separate them is destroyed (a 2px/frame pan and a real
420
- 3-state board both score ~0.55 on every path statistic tried). Deduplication
421
- sidesteps the question entirely, because it is safe in the one direction that
422
- matters: it only ever removes a picture that is ALREADY in the set, so no frame
423
- carrying an answer can be lost. Under-sampling — the failure this module exists
424
- to prevent — is impossible here by construction.
425
-
426
- The backfill exists because dedup frees budget and a clip can have more states
427
- than even spacing happened to land on. `symza` holds nine; sampling six and
428
- deduplicating leaves four, so four of its pictures were never offered. Walking
429
- the clip in order for anything distinct from everything kept spends the returned
430
- slots on new pictures instead of retiring them.
431
-
432
- Returns indices in ascending order, so keyframe numbers stay a time order.
202
+ """Drop repeats, then backfill: a duplicate makes the `frame` label ambiguous, a silently unanswerable question.
203
+
204
+ Safer than a better cycle detector (a 2px/frame pan and a real 3-state board score alike on every path
205
+ statistic tried); the backfill exists because symza holds nine screens and six samples found four.
433
206
  """
434
207
  kept: List[int] = []
435
208
 
@@ -453,20 +226,6 @@ def _distinct_indices(
453
226
  def _steady_screens(
454
227
  frames: Sequence[np.ndarray], params: KeyframeParams
455
228
  ) -> Optional[Tuple[List[Tuple[int, int]], List[int], List[int]]]:
456
- """`(holds, state_medoid_indices, state_per_hold)` if the clip is a set of
457
- steady held pictures, else None.
458
-
459
- Split out of `_detect_cycle` because the answer is wanted TWICE and the two
460
- questions are not the same one:
461
-
462
- - "is this a cycle?" — needs a state to be seen coming BACK, which takes
463
- more than one pass through the loop.
464
- - "does this board sit on a few stable screens?" — needs only one pass,
465
- and is what the live driver has to know in order to decide whether
466
- waiting for a particular screen is meaningful.
467
-
468
- Answering only the first left the second unasked. See `steady_screens`.
469
- """
470
229
  n = len(frames)
471
230
  runs = _anchor_runs(frames, params.steady_ratio)
472
231
  holds = [(s, e) for (s, e) in runs if (e - s + 1) >= params.min_hold_frames]
@@ -477,11 +236,8 @@ def _steady_screens(
477
236
  if covered / float(n) < params.min_steady_coverage:
478
237
  return None
479
238
 
480
- # Merge holds that show the same picture. Order of first appearance is the
481
- # order the model will see, so a cycle that revisits state 1 does not mint a
482
- # fourth image for it.
483
- state_reps: List[int] = [] # medoid source index per state
484
- hold_state: List[int] = [] # state id per hold, parallel to `holds`
239
+ state_reps: List[int] = []
240
+ hold_state: List[int] = []
485
241
  for s, e in holds:
486
242
  rep = _medoid(frames, list(range(s, e + 1)))
487
243
  for k, existing in enumerate(state_reps):
@@ -490,43 +246,18 @@ def _steady_screens(
490
246
  break
491
247
  else:
492
248
  if len(state_reps) >= params.max_keyframes:
493
- return None # too many distinct holds to be a tidy cycle
249
+ return None
494
250
  state_reps.append(rep)
495
251
  hold_state.append(len(state_reps) - 1)
496
252
 
497
253
  if len(state_reps) < 1:
498
254
  return None
499
255
 
500
- # NO `distinct_ratio` HERE, deliberately. The merge above already separated
501
- # these states by more than `steady_ratio`, which is the measured noise
502
- # floor — that is the whole of "are these different pictures".
503
- #
504
- # `distinct_ratio` (0.02) is twenty times coarser and exists to license
505
- # `cycle`'s COLLAPSE to one frame per state, where being too generous throws
506
- # away the middle of a clip. Applying it here instead answers a question
507
- # nobody asked and answers it wrongly: measured on a live GeeTest svg burst,
508
- # the three screens are separated by 0.0056 and 0.0050 — thin line art, a
509
- # few glyph strokes change — so a 0.02 bar reports a board with three
510
- # obvious screens as having none. keyframes.py's own comment already said
511
- # so ("real state separations top out around 0.007, well under this") and
512
- # read it as evidence the check was harmless.
256
+ # No distinct_ratio here: the merge above already separated states by the measured noise floor.
513
257
  return holds, state_reps, hold_state
514
258
 
515
259
 
516
260
  def steady_screens(frames: Sequence[np.ndarray], params: KeyframeParams) -> int:
517
- """How many distinct steady screens this clip sits on. 0 if it does not.
518
-
519
- THE LIVE DRIVER'S QUESTION, and it is not `mode`. A board that advances
520
- through three held pictures gives a burst that contains ONE pass, so
521
- `_detect_cycle` cannot prove it recurs and the clip is sliced `even` — which
522
- is right for slicing (dedup reaches the same stills) and was being read by
523
- the driver as "nothing recurs, do not wait for a screen". Every real
524
- geetest_v4_svg clip is exactly this shape: 2-3 holds covering 95-100% of the
525
- burst, mutually distinct, and the page absolutely does come back around.
526
-
527
- A one-way animation — the case the no-wait path exists for — is nearly all
528
- transition, so it has no qualifying holds and this returns 0.
529
- """
530
261
  got = _steady_screens(frames, params)
531
262
  return len(got[1]) if got else 0
532
263
 
@@ -534,40 +265,17 @@ def steady_screens(frames: Sequence[np.ndarray], params: KeyframeParams) -> int:
534
265
  def _detect_cycle(
535
266
  frames: Sequence[np.ndarray], params: KeyframeParams
536
267
  ) -> Optional[Tuple[List[int], List[int]]]:
537
- """`(state_medoid_indices, per_frame_state)` if the clip is a cycle of steady
538
- pictures, else None.
539
-
540
- `per_frame_state[i]` is the 0-based state of source frame i, or -1 for a
541
- transition frame that belongs to no hold.
542
- """
543
268
  n = len(frames)
544
269
  got = _steady_screens(frames, params)
545
270
  if got is None:
546
271
  return None
547
272
  holds, state_reps, hold_state = got
548
273
 
549
- # A cycle REVISITS. More holds than states means some picture came back, which
550
- # is what "the video is just a shift between N steady images" describes. Equal
551
- # counts mean every hold was a new picture — a one-way progression (a slow
552
- # cross-fade reads exactly like this, see module docstring), where collapsing
553
- # to one frame per hold would discard the frames carrying the answer.
554
- #
555
- # The cost of this rule is a genuine cycle whose period is long enough that
556
- # only ONE pass fits in the recording — every real geetest_v4_svg clip
557
- # is exactly this — so it falls back to `even`. That used to mean 6 frames of a
558
- # 3-picture clip; since `even` deduplicates it now means 3, which is the same
559
- # answer `cycle` would have given, reached without having to decide whether the
560
- # clip recurs. The rule stays because dropping it WOULD be unsafe: a slow
561
- # one-way fade also decomposes into 2 long holds at full coverage (verified),
562
- # and collapsing that to 2 frames discards the middle of the clip. Dedup cannot
563
- # make that mistake; a permissive cycle detector can.
274
+ # A cycle REVISITS. Equal counts is a one-way progression: a slow fade also decomposes into 2 long holds
275
+ # (verified), and collapsing that to 2 frames discards the middle of the clip.
564
276
  if len(holds) <= len(state_reps) and len(state_reps) > 1:
565
277
  return None
566
278
 
567
- # Every pair of states must be clearly different before the clip is
568
- # COLLAPSED to one frame per state — that collapse is what this threshold
569
- # licenses, and being too generous with it discards the middle of a clip.
570
- # `steady_screens` deliberately does not apply it; see `_steady_screens`.
571
279
  for a in range(len(state_reps)):
572
280
  for b in range(a + 1, len(state_reps)):
573
281
  if frame_diff_ratio(frames[state_reps[a]], frames[state_reps[b]]) < params.distinct_ratio:
@@ -583,55 +291,16 @@ def _detect_cycle(
583
291
  def _drop_smeared(
584
292
  frames: Sequence[np.ndarray], indices: Sequence[int], params: KeyframeParams
585
293
  ) -> List[int]:
586
- """Drop an index caught BETWEEN two holds, when both of them are already
587
- represented in the cut.
588
-
589
- These clips hold a picture, swap, hold the next. An evenly spaced sample can
590
- land on the instant of the swap — a board part-way between two states, which
591
- is a picture the puzzle never actually shows. If the model already has the
592
- board before the swap AND the board after it, that frame adds nothing but a
593
- smear; if either endpoint is missing, it is the only thing standing in for a
594
- state and must stay.
595
-
596
- THE MACHINERY ALREADY EXISTED AND THIS PATH NEVER ASKED IT. `_anchor_runs`
597
- plus `min_hold_frames` is how `_detect_cycle` separates holds from
598
- transitions (it labels the latter -1 in `frame_states`), and
599
- `min_hold_frames` had exactly one reader — inside `_detect_cycle`. On the
600
- `even` path the parameter was declared, serialised into every manifest, and
601
- inert.
602
-
603
- WHY THE TEST IS "BOTH NEIGHBOURING HOLDS ARE KEPT" AND NOT A PIXEL DIFF.
604
- Two earlier versions were measured against both trees and both were wrong:
605
-
606
- * *Snap to the nearest held frame* turned `[0, 16, 39]` into `[0, 16]` on
607
- six eval clips, dropping a SCREEN. A page that never reaches the model is
608
- unanswerable whenever the target lived on it.
609
- * *Drop when some other still is within `distinct_ratio`* cut
610
- an hCaptcha tile-flip animation from 6 stills to 3, because two boards of that
611
- puzzle differ by ONE TILE — well inside `distinct_ratio` — so a diff
612
- threshold cannot tell "the same picture again" from "the next board".
613
- EXPECTED_SLICING says it directly: fewer than 6 "means dedup merged two
614
- boards that differ only by the tile mid-flip, and that tile is frequently
615
- the answer".
616
-
617
- Anchoring on the HOLDS either side asks the question that actually matters —
618
- does the model already see both ends of this swap — and needs no threshold of
619
- its own.
620
-
621
- A clip with no holds at all (a rotation, a continuous fade, anything in
622
- permanent motion) has no transitions to speak of and is returned untouched.
294
+ """Drop a sample caught mid-swap when both neighbouring holds are already kept.
295
+
296
+ Not a pixel diff: snapping to the nearest hold turned [0, 16, 39] into [0, 16] on six clips, and a
297
+ distinct_ratio test cut an hCaptcha tile-flip from 6 stills to 3 (two boards differ by one tile).
623
298
  """
624
299
  runs = _anchor_runs(frames, params.steady_ratio)
625
300
  holds = [(s, e) for (s, e) in runs if (e - s + 1) >= params.min_hold_frames]
626
301
  if not holds:
627
- return list(indices) # nothing holds; nothing is a smear
628
-
629
- # Holds showing the same picture are ONE state — the same merge
630
- # `_detect_cycle` does, at the same threshold. It matters here because a
631
- # cycle revisits: on a real `icon_2x2` clip the hold at 53..74 is the
632
- # opening board coming back, so the still at index 0 already covers it.
633
- # Counting holds instead of states kept a smear whose neighbouring picture
634
- # was on screen the whole time.
302
+ return list(indices)
303
+
635
304
  state_of_hold: List[int] = []
636
305
  reps: List[int] = []
637
306
  for start, end in holds:
@@ -659,9 +328,6 @@ def _drop_smeared(
659
328
  continue
660
329
  before = max((k for k, (s, e) in enumerate(holds) if e < i), default=None)
661
330
  after = min((k for k, (s, e) in enumerate(holds) if s > i), default=None)
662
- # Only a swap BETWEEN two kept holds is redundant. A transition at the
663
- # very start or end of a clip has an endpoint that was never recorded,
664
- # so it is evidence of a state and stays.
665
331
  if before is not None and after is not None \
666
332
  and state_of_hold[before] in covered \
667
333
  and state_of_hold[after] in covered:
@@ -676,11 +342,6 @@ def extract_keyframes(
676
342
  fps: float = 10.0,
677
343
  params: Optional[KeyframeParams] = None,
678
344
  ) -> KeyframeSet:
679
- """Slice a decoded clip into the frames the model will be shown.
680
-
681
- `frames` are BGR uint8 arrays in capture order (what `cv2` hands back and
682
- what the collector's burst produces).
683
- """
684
345
  if not frames:
685
346
  raise ValueError("no frames to extract keyframes from")
686
347
  p = params or KeyframeParams()
@@ -692,7 +353,7 @@ def extract_keyframes(
692
353
  cycle = _detect_cycle(frames, p)
693
354
  if cycle is not None:
694
355
  reps, per_frame = cycle
695
- mode = "static" if len(reps) == 1 else "cycle"
356
+ mode = KeyframeMode.STATIC if len(reps) == 1 else KeyframeMode.CYCLE
696
357
  return KeyframeSet(
697
358
  steady_screens=len(reps),
698
359
  mode=mode,
@@ -708,26 +369,15 @@ def extract_keyframes(
708
369
  )
709
370
 
710
371
  indices = _even_indices(n, p.max_keyframes)
711
- # BEFORE dedup, so a smear does not spend budget a real state could use...
372
+ # Before dedup so a smear does not spend budget, and after because the backfill re-picks smears
373
+ # (measured: filtering only before returned [0, 10, 12, 21, 25] with 10 and 21 the two smears).
712
374
  indices = _drop_smeared(frames, indices, p)
713
375
  if p.dedupe:
714
- # Applied to `even` only. `cycle` states are distinct by construction (the
715
- # merge in `_detect_cycle` uses this same threshold) and `static` is one
716
- # frame, so there is nothing to remove; and dropping a cycle keyframe here
717
- # would desynchronise `frame_states`, which indexes keyframes by position.
718
376
  indices = _distinct_indices(frames, indices, p)
719
- # ...AND AFTER, because `_distinct_indices` BACKFILLS. Dedup frees
720
- # budget and the backfill walks the clip for anything not already
721
- # represented — which is exactly what a smeared frame is, so it gets
722
- # picked straight back up. Measured on a 3-hold fixture: filtering only
723
- # before dedup returned [0, 10, 12, 21, 25] with 10 and 21 the two
724
- # smears the first pass had just removed.
725
377
  indices = _drop_smeared(frames, indices, p)
726
378
  return KeyframeSet(
727
- # Asked even though the cycle test refused: the two questions differ,
728
- # and this is the one the driver needs answered.
729
379
  steady_screens=steady_screens(frames, p),
730
- mode="even",
380
+ mode=KeyframeMode.EVEN,
731
381
  keyframes=[
732
382
  Keyframe(number=k + 1, source_index=idx, timestamp_ms=_ms(idx),
733
383
  image=frames[idx])
@@ -739,47 +389,19 @@ def extract_keyframes(
739
389
  )
740
390
 
741
391
 
742
- def extract_keyframes_from_video(
743
- path: str | os.PathLike, *, params: Optional[KeyframeParams] = None
744
- ) -> KeyframeSet:
745
- frames, fps = decode_video(path)
746
- return extract_keyframes(frames, fps=fps, params=params)
747
-
748
-
749
- # ── on-disk layout ──────────────────────────────────────────────────────────
750
- #
751
- # <media_dir>/keyframes/<stem>/frame_01.png ... frame_0N.png
752
- # <media_dir>/keyframes/<stem>/keyframes.json
753
- #
754
- # Derived, regenerable, and ADDITIVE — the clip stays canonical next to it. That
755
- # matters most under the held-out sample corpus, which is irreplaceable hand-labeled
756
- # data we never rewrite (CLAUDE.md § Data trees): extraction only ever creates a
757
- # new sibling directory there.
758
-
759
-
760
- def keyframe_dir_for(media_path: str | os.PathLike) -> Path:
761
- """Where this clip's keyframes live. Pure path arithmetic, no I/O."""
762
- p = Path(media_path)
763
- return p.parent / KEYFRAME_DIR_NAME / p.stem
764
392
 
765
393
 
766
394
  def _frame_filename(number: int) -> str:
395
+ """Zero-padded because readers sort by name."""
767
396
  return f"frame_{number:02d}.png"
768
397
 
769
398
 
770
399
  def write_keyframes(
771
400
  kfset: KeyframeSet, out_dir: str | os.PathLike, *, stem: str
772
401
  ) -> List[Path]:
773
- """Write `frame_NN.png` + `keyframes.json` into `out_dir`. Returns the PNG
774
- paths in model order.
775
-
776
- Stale files from a previous slicing are removed: a clip re-sliced from 6
777
- keyframes down to 3 would otherwise leave `frame_04..06.png` behind, and
778
- `read_keyframe_paths` globs the directory — the model would be handed six
779
- images while the manifest and the label said three.
780
- """
781
402
  out = Path(out_dir)
782
403
  out.mkdir(parents=True, exist_ok=True)
404
+ # A re-slice from 6 to 3 would otherwise leave frame_04..06 for a glob to hand the model.
783
405
  for stale in out.glob("frame_*.png"):
784
406
  stale.unlink()
785
407
 
@@ -799,64 +421,4 @@ def write_keyframes(
799
421
  return paths
800
422
 
801
423
 
802
- def load_manifest(kf_dir: str | os.PathLike) -> Optional[Dict[str, Any]]:
803
- p = Path(kf_dir) / MANIFEST_NAME
804
- if not p.exists():
805
- return None
806
- try:
807
- with p.open() as f:
808
- data = json.load(f)
809
- except (json.JSONDecodeError, OSError):
810
- return None
811
- return data if isinstance(data, dict) else None
812
-
813
-
814
- def read_keyframe_paths(kf_dir: str | os.PathLike) -> List[Path]:
815
- """The keyframe PNGs in model order, or [] if the directory isn't usable.
816
-
817
- Order comes from the manifest when present and from the zero-padded filenames
818
- otherwise; both give frame 1 first. A manifest naming a file that isn't there
819
- yields [] rather than a short list, because a gap would silently renumber
820
- every later frame and invalidate the stored `frame` label.
821
- """
822
- d = Path(kf_dir)
823
- if not d.is_dir():
824
- return []
825
- manifest = load_manifest(d)
826
- if manifest and isinstance(manifest.get("keyframes"), list):
827
- out: List[Path] = []
828
- for entry in sorted(manifest["keyframes"], key=lambda e: e.get("number", 0)):
829
- p = d / str(entry.get("file", ""))
830
- if not p.exists():
831
- return []
832
- out.append(p)
833
- return out
834
- return sorted(d.glob("frame_*.png"))
835
-
836
-
837
- def materialize_keyframes(
838
- media_path: str | os.PathLike,
839
- *,
840
- params: Optional[KeyframeParams] = None,
841
- force: bool = False,
842
- ) -> List[Path]:
843
- """The keyframes for a clip, extracting them on first use.
844
-
845
- Idempotent and cheap on the second call: an existing manifest whose params
846
- match is reused as-is. A params CHANGE re-slices, because reusing a set cut
847
- with different thresholds would mix two slicings in one dataset — the failure
848
- the manifest records params to prevent.
849
- """
850
- media = Path(media_path)
851
- kf_dir = keyframe_dir_for(media)
852
- p = params or KeyframeParams()
853
-
854
- if not force:
855
- manifest = load_manifest(kf_dir)
856
- if manifest and manifest.get("params") == p.as_dict():
857
- existing = read_keyframe_paths(kf_dir)
858
- if existing:
859
- return existing
860
424
 
861
- kfset = extract_keyframes_from_video(media, params=p)
862
- return write_keyframes(kfset, kf_dir, stem=media.stem)