captchakraken 2.3.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.
Files changed (54) hide show
  1. package/LICENSE +47 -7
  2. package/README.md +24 -4
  3. package/dist/cli-invocation.d.ts +46 -0
  4. package/dist/cli-invocation.js +68 -0
  5. package/dist/errors.d.ts +45 -0
  6. package/dist/errors.js +74 -0
  7. package/dist/index.d.ts +50 -0
  8. package/dist/index.js +52 -1
  9. package/dist/limits.d.ts +51 -0
  10. package/dist/limits.js +54 -0
  11. package/dist/model-name.d.ts +23 -0
  12. package/dist/model-name.js +70 -0
  13. package/dist/playwright-types.d.ts +28 -0
  14. package/dist/puppeteer-adapter.d.ts +11 -0
  15. package/dist/puppeteer-adapter.js +23 -0
  16. package/dist/python-command.d.ts +50 -0
  17. package/dist/python-command.js +60 -0
  18. package/dist/slide-geometry.d.ts +33 -0
  19. package/dist/slide-geometry.js +64 -0
  20. package/dist/solver.d.ts +214 -12
  21. package/dist/solver.js +1297 -165
  22. package/dist/trajectory.d.ts +31 -0
  23. package/dist/trajectory.js +162 -0
  24. package/dist/types.d.ts +128 -11
  25. package/dist/watcher.d.ts +99 -0
  26. package/dist/watcher.js +129 -0
  27. package/package.json +2 -5
  28. package/python/LICENSE +47 -7
  29. package/python/README.md +25 -8
  30. package/python/examples/README.md +36 -0
  31. package/python/examples/_harness.py +182 -94
  32. package/python/examples/demoHcaptcha.py +2 -1
  33. package/python/examples/demoRecaptcha.py +2 -1
  34. package/python/examples/watch_playwright.py +56 -0
  35. package/python/examples/with_playwright.py +43 -0
  36. package/python/pyproject.toml +4 -3
  37. package/python/src/captchakraken/__init__.py +5 -3
  38. package/python/src/captchakraken/action_types.py +24 -3
  39. package/python/src/captchakraken/cli.py +224 -2
  40. package/python/src/captchakraken/config.py +119 -17
  41. package/python/src/captchakraken/errors.py +195 -0
  42. package/python/src/captchakraken/keyframes.py +690 -0
  43. package/python/src/captchakraken/models.json +120 -0
  44. package/python/src/captchakraken/page_solver.py +1465 -102
  45. package/python/src/captchakraken/pinned_model.json +5 -7
  46. package/python/src/captchakraken/planner.py +328 -73
  47. package/python/src/captchakraken/prompts.py +485 -0
  48. package/python/src/captchakraken/solver.py +182 -11
  49. package/python/src/captchakraken/timing.py +70 -0
  50. package/python/src/captchakraken/tool_calls/find_grid.py +367 -52
  51. package/python/src/captchakraken/tool_calls/track_piece.py +96 -0
  52. package/python/src/captchakraken/trajectory.py +2 -7
  53. package/python/src/captchakraken/watcher.py +193 -0
  54. package/python/src/captchakraken/planner_types.py +0 -74
@@ -0,0 +1,690 @@
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
+ `src/video/keyframes.py` in the CaptchaKrakenFinetune repo, and
5
+ `scripts/check_prompt_parity.py` 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.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import json
23
+ import os
24
+ from dataclasses import dataclass, field
25
+ from pathlib import Path
26
+ from typing import Any, Dict, List, Optional, Sequence, Tuple
27
+
28
+ import cv2
29
+ import numpy as np
30
+
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).
37
+
38
+ DEFAULT_MAX_KEYFRAMES = 6
39
+
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 cleanSamples/test/raw, 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.
61
+ DEFAULT_STEADY_RATIO = 0.001
62
+
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.
73
+ DEFAULT_DISTINCT_RATIO = 0.02
74
+
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.
77
+ DEFAULT_MIN_HOLD_FRAMES = 3
78
+
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.
82
+ DEFAULT_MIN_STEADY_COVERAGE = 0.5
83
+
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).
87
+ _PIXEL_DELTA = 30
88
+
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.
96
+ MATCH_REGION_HALF = 0.06
97
+
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.
102
+ MATCH_REGION_TOLERANCE = 0.05
103
+
104
+ MANIFEST_NAME = "keyframes.json"
105
+ KEYFRAME_DIR_NAME = "keyframes"
106
+
107
+
108
+ @dataclass(frozen=True)
109
+ 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."""
114
+
115
+ max_keyframes: int = DEFAULT_MAX_KEYFRAMES
116
+ steady_ratio: float = DEFAULT_STEADY_RATIO
117
+ distinct_ratio: float = DEFAULT_DISTINCT_RATIO
118
+ min_hold_frames: int = DEFAULT_MIN_HOLD_FRAMES
119
+ 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
+ dedupe: bool = True
125
+
126
+ def as_dict(self) -> Dict[str, Any]:
127
+ return {
128
+ "max_keyframes": self.max_keyframes,
129
+ "steady_ratio": self.steady_ratio,
130
+ "distinct_ratio": self.distinct_ratio,
131
+ "min_hold_frames": self.min_hold_frames,
132
+ "min_steady_coverage": self.min_steady_coverage,
133
+ "dedupe": self.dedupe,
134
+ }
135
+
136
+
137
+ @dataclass
138
+ 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
+ """
147
+
148
+ number: int
149
+ source_index: int
150
+ timestamp_ms: float
151
+ image: Optional[np.ndarray] = None # BGR uint8; None once written to disk
152
+
153
+
154
+ @dataclass
155
+ class KeyframeSet:
156
+ mode: str # "cycle" | "even" | "static"
157
+ keyframes: List[Keyframe]
158
+ source_frames: int
159
+ fps: float
160
+ 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
+ frame_states: List[int] = field(default_factory=list)
165
+
166
+ def __len__(self) -> int:
167
+ return len(self.keyframes)
168
+
169
+ def number_for_source_index(self, source_index: int) -> int:
170
+ """The keyframe a given source frame belongs to (1-based), by POSITION.
171
+
172
+ In cycle mode this is exact — the frame's own state. Otherwise it is the
173
+ nearest keyframe in time. Prefer `number_for_frame` whenever the pixels are
174
+ available: nearest-in-time is not the same question the solver asks, and a
175
+ transition frame (state -1) has no state of its own to fall back on.
176
+ """
177
+ if not self.keyframes:
178
+ raise ValueError("keyframe set is empty")
179
+ if self.mode == "cycle" and 0 <= source_index < len(self.frame_states):
180
+ state = self.frame_states[source_index]
181
+ if state >= 0:
182
+ return state + 1
183
+ return min(
184
+ self.keyframes, key=lambda k: abs(k.source_index - source_index)
185
+ ).number
186
+
187
+ def number_for_frame(
188
+ self,
189
+ frame: np.ndarray,
190
+ *,
191
+ point_norm: Optional[Tuple[float, float]] = None,
192
+ source_index: Optional[int] = None,
193
+ ) -> int:
194
+ """The keyframe that LOOKS most like `frame`, 1-based.
195
+
196
+ This — not nearest-in-time — is how a label's frame number is chosen,
197
+ because it asks the same question the live solver asks. At solve time the
198
+ driver holds the mouse until the page's neighbourhood around the click
199
+ point matches the chosen keyframe; so the frame we write into the label
200
+ must be the one whose *appearance* matches the moment the answer was read
201
+ off, or the driver ends up waiting for a state where the target isn't
202
+ there.
203
+
204
+ `point_norm` (x, y in 0..1) restricts the comparison to the neighbourhood
205
+ of the action point, again matching the solver. Without it the whole frame
206
+ is compared, which on a multi-sprite puzzle is dominated by sprites that
207
+ have nothing to do with the answer.
208
+
209
+ `source_index` only breaks ties, keeping the choice stable and preferring
210
+ the temporally closest of two equally-similar keyframes.
211
+ """
212
+ if not self.keyframes:
213
+ raise ValueError("keyframe set is empty")
214
+ box = region_box(frame.shape[1::-1], point_norm) if point_norm else None
215
+
216
+ def cost(kf: Keyframe) -> Tuple[float, int]:
217
+ if kf.image is None:
218
+ raise ValueError(f"keyframe {kf.number} has no image to compare")
219
+ d = region_diff_ratio(kf.image, frame, box)
220
+ tie = abs(kf.source_index - source_index) if source_index is not None else 0
221
+ return (d, tie)
222
+
223
+ return min(self.keyframes, key=cost).number
224
+
225
+ def manifest(self, *, stem: str, filenames: Sequence[str]) -> Dict[str, Any]:
226
+ return {
227
+ "stem": stem,
228
+ "mode": self.mode,
229
+ "fps": self.fps,
230
+ "source_frames": self.source_frames,
231
+ "params": self.params.as_dict(),
232
+ "keyframes": [
233
+ {
234
+ "number": kf.number,
235
+ "file": name,
236
+ "source_index": kf.source_index,
237
+ "timestamp_ms": round(kf.timestamp_ms, 1),
238
+ }
239
+ for kf, name in zip(self.keyframes, filenames)
240
+ ],
241
+ }
242
+
243
+
244
+ # ── similarity ──────────────────────────────────────────────────────────────
245
+
246
+
247
+ def region_box(
248
+ size_wh: Tuple[int, int],
249
+ point_norm: Tuple[float, float],
250
+ half: float = MATCH_REGION_HALF,
251
+ ) -> Tuple[int, int, int, int]:
252
+ """`(x1, y1, x2, y2)` pixel box of half-width `half` around a 0..1 point.
253
+
254
+ Clamped to the image and never empty: a point on the very edge of the frame
255
+ (a target flush against the border) would otherwise produce a zero-area box,
256
+ and a comparison over no pixels reads as a perfect match — the gate would open
257
+ immediately on any state at all.
258
+ """
259
+ w, h = int(size_wh[0]), int(size_wh[1])
260
+ cx, cy = float(point_norm[0]) * w, float(point_norm[1]) * h
261
+ rx, ry = max(1.0, half * w), max(1.0, half * h)
262
+ x1 = max(0, min(w - 1, int(round(cx - rx))))
263
+ y1 = max(0, min(h - 1, int(round(cy - ry))))
264
+ x2 = max(x1 + 1, min(w, int(round(cx + rx))))
265
+ y2 = max(y1 + 1, min(h, int(round(cy + ry))))
266
+ return x1, y1, x2, y2
267
+
268
+
269
+ def region_diff_ratio(
270
+ a: np.ndarray, b: np.ndarray, box: Optional[Tuple[int, int, int, int]] = None
271
+ ) -> float:
272
+ """`frame_diff_ratio` over a sub-rectangle (the whole frame when `box` is None).
273
+
274
+ Mismatched shapes short-circuit before cropping, so a resized live screenshot
275
+ reads as "completely different" rather than as a crop of a different geometry.
276
+ """
277
+ if a is None or b is None or a.shape != b.shape:
278
+ return 1.0
279
+ if box is None:
280
+ return frame_diff_ratio(a, b)
281
+ x1, y1, x2, y2 = box
282
+ return frame_diff_ratio(a[y1:y2, x1:x2], b[y1:y2, x1:x2])
283
+
284
+
285
+ def frame_diff_ratio(a: np.ndarray, b: np.ndarray) -> float:
286
+ """Fraction of pixels that differ by more than 30 in any channel.
287
+
288
+ Mismatched shapes short-circuit to 1.0 ("completely different"), matching
289
+ `_collect_common.max_movement_ratio`. Do not swap in a perceptual metric
290
+ without changing the solver's wait gate too — see module docstring.
291
+ """
292
+ if a is None or b is None:
293
+ return 1.0
294
+ if a.shape != b.shape:
295
+ return 1.0
296
+ diff = cv2.absdiff(a, b)
297
+ if diff.ndim == 3:
298
+ diff = cv2.cvtColor(diff, cv2.COLOR_BGR2GRAY)
299
+ _, thr = cv2.threshold(diff, _PIXEL_DELTA, 255, cv2.THRESH_BINARY)
300
+ h, w = thr.shape[:2]
301
+ if h * w == 0:
302
+ return 1.0
303
+ return cv2.countNonZero(thr) / float(h * w)
304
+
305
+
306
+ # ── decoding ────────────────────────────────────────────────────────────────
307
+
308
+
309
+ def decode_video(path: str | os.PathLike) -> Tuple[List[np.ndarray], float]:
310
+ """Every frame of a clip, in order, plus its fps.
311
+
312
+ Reads with `cv2.VideoCapture` rather than trusting CAP_PROP_FRAME_COUNT,
313
+ which lies on the `mp4v` files this project writes (the muxer puts `moov`
314
+ last and the count comes back as 0 often enough to matter).
315
+ """
316
+ cap = cv2.VideoCapture(str(path))
317
+ if not cap.isOpened():
318
+ raise ValueError(f"could not open clip: {path}")
319
+ try:
320
+ fps = float(cap.get(cv2.CAP_PROP_FPS) or 0.0)
321
+ frames: List[np.ndarray] = []
322
+ while True:
323
+ ok, frame = cap.read()
324
+ if not ok:
325
+ break
326
+ frames.append(frame)
327
+ finally:
328
+ cap.release()
329
+ if not frames:
330
+ raise ValueError(f"clip decoded to zero frames: {path}")
331
+ if not fps or fps <= 0 or fps > 240:
332
+ fps = 10.0 # the collector's BURST_FPS; a sane floor beats a NaN
333
+ return frames, fps
334
+
335
+
336
+ # ── extraction ──────────────────────────────────────────────────────────────
337
+
338
+
339
+ def _anchor_runs(frames: Sequence[np.ndarray], steady_ratio: float) -> List[Tuple[int, int]]:
340
+ """Partition the clip into maximal runs that all match the run's FIRST frame.
341
+
342
+ Anchored on the first frame rather than chained frame-to-frame on purpose: a
343
+ slow pan has every consecutive diff under the threshold while the endpoints
344
+ are nothing alike, so a chained walk would call the whole pan one steady
345
+ state. Comparing against the anchor cannot drift.
346
+ """
347
+ runs: List[Tuple[int, int]] = []
348
+ i = 0
349
+ n = len(frames)
350
+ while i < n:
351
+ j = i
352
+ while j + 1 < n and frame_diff_ratio(frames[i], frames[j + 1]) <= steady_ratio:
353
+ j += 1
354
+ runs.append((i, j))
355
+ i = j + 1
356
+ return runs
357
+
358
+
359
+ def _medoid(frames: Sequence[np.ndarray], indices: Sequence[int]) -> int:
360
+ """The index in `indices` whose frame is most typical of the group.
361
+
362
+ For a hold this is nearly arbitrary (the frames are near-identical by
363
+ construction) but it keeps a half-faded edge frame from representing the
364
+ state when a run happens to straddle a transition boundary.
365
+ """
366
+ if len(indices) <= 2:
367
+ return indices[0]
368
+ best_idx, best_cost = indices[0], float("inf")
369
+ for a in indices:
370
+ cost = sum(frame_diff_ratio(frames[a], frames[b]) for b in indices if b != a)
371
+ if cost < best_cost:
372
+ best_idx, best_cost = a, cost
373
+ return best_idx
374
+
375
+
376
+ def _even_indices(n: int, count: int) -> List[int]:
377
+ """`count` indices spread across 0..n-1, endpoints included, deduped."""
378
+ if n <= count:
379
+ return list(range(n))
380
+ if count <= 1:
381
+ return [0]
382
+ out = [int(round(k * (n - 1) / (count - 1))) for k in range(count)]
383
+ seen: List[int] = []
384
+ for i in out:
385
+ if i not in seen:
386
+ seen.append(i)
387
+ return seen
388
+
389
+
390
+ def _distinct_indices(
391
+ frames: Sequence[np.ndarray], candidates: Sequence[int], params: KeyframeParams
392
+ ) -> List[int]:
393
+ """Drop candidates that repeat a picture already kept, then spend the freed
394
+ budget on frames that are genuinely new.
395
+
396
+ WHY THIS IS NEEDED EVEN THOUGH `even` MODE IS ALREADY "CORRECT". Even spacing
397
+ asks a question about TIME, and a clip that rests answers it with duplicates: a
398
+ geetest icon board holding three states for 12/20/8 frames, sampled at
399
+ 0/8/16/23/31/39, returns state A twice and state B three times. Six images, three
400
+ pictures. That is not merely wasteful of context — it makes the `frame` label
401
+ AMBIGUOUS. If frames 1 and 2 are the same picture, an answer naming either is
402
+ equally right, but grading compares against one number and the solver waits for
403
+ one of them. A duplicate is a silently unanswerable question.
404
+
405
+ Why this and not a better `cycle` detector. Distinguishing "three discrete states
406
+ shown once" from "smooth motion coarsely quantised" is not decidable from
407
+ `frame_diff_ratio`: the metric saturates once a sprite clears its old position,
408
+ so both shapes produce equal-distance, high-coverage "holds" and the pairwise
409
+ geometry that would separate them is destroyed (a 2px/frame pan and a real
410
+ 3-state board both score ~0.55 on every path statistic tried). Deduplication
411
+ sidesteps the question entirely, because it is safe in the one direction that
412
+ matters: it only ever removes a picture that is ALREADY in the set, so no frame
413
+ carrying an answer can be lost. Under-sampling — the failure this module exists
414
+ to prevent — is impossible here by construction.
415
+
416
+ The backfill exists because dedup frees budget and a clip can have more states
417
+ than even spacing happened to land on. `symza` holds nine; sampling six and
418
+ deduplicating leaves four, so four of its pictures were never offered. Walking
419
+ the clip in order for anything distinct from everything kept spends the returned
420
+ slots on new pictures instead of retiring them.
421
+
422
+ Returns indices in ascending order, so keyframe numbers stay a time order.
423
+ """
424
+ kept: List[int] = []
425
+
426
+ def is_new(i: int) -> bool:
427
+ return not any(
428
+ frame_diff_ratio(frames[i], frames[k]) <= params.steady_ratio for k in kept
429
+ )
430
+
431
+ for i in candidates:
432
+ if is_new(i):
433
+ kept.append(i)
434
+ if len(kept) < params.max_keyframes:
435
+ for i in range(len(frames)):
436
+ if len(kept) >= params.max_keyframes:
437
+ break
438
+ if i not in kept and is_new(i):
439
+ kept.append(i)
440
+ return sorted(kept)
441
+
442
+
443
+ def _detect_cycle(
444
+ frames: Sequence[np.ndarray], params: KeyframeParams
445
+ ) -> Optional[Tuple[List[int], List[int]]]:
446
+ """`(state_medoid_indices, per_frame_state)` if the clip is a cycle of steady
447
+ pictures, else None.
448
+
449
+ `per_frame_state[i]` is the 0-based state of source frame i, or -1 for a
450
+ transition frame that belongs to no hold.
451
+ """
452
+ n = len(frames)
453
+ runs = _anchor_runs(frames, params.steady_ratio)
454
+ holds = [(s, e) for (s, e) in runs if (e - s + 1) >= params.min_hold_frames]
455
+ if not holds:
456
+ return None
457
+
458
+ covered = sum(e - s + 1 for s, e in holds)
459
+ if covered / float(n) < params.min_steady_coverage:
460
+ return None
461
+
462
+ # Merge holds that show the same picture. Order of first appearance is the
463
+ # order the model will see, so a cycle that revisits state 1 does not mint a
464
+ # fourth image for it.
465
+ state_reps: List[int] = [] # medoid source index per state
466
+ hold_state: List[int] = [] # state id per hold, parallel to `holds`
467
+ for s, e in holds:
468
+ rep = _medoid(frames, list(range(s, e + 1)))
469
+ for k, existing in enumerate(state_reps):
470
+ if frame_diff_ratio(frames[rep], frames[existing]) <= params.steady_ratio:
471
+ hold_state.append(k)
472
+ break
473
+ else:
474
+ if len(state_reps) >= params.max_keyframes:
475
+ return None # too many distinct holds to be a tidy cycle
476
+ state_reps.append(rep)
477
+ hold_state.append(len(state_reps) - 1)
478
+
479
+ if len(state_reps) < 1:
480
+ return None
481
+
482
+ # A cycle REVISITS. More holds than states means some picture came back, which
483
+ # is what "the video is just a shift between N steady images" describes. Equal
484
+ # counts mean every hold was a new picture — a one-way progression (a slow
485
+ # cross-fade reads exactly like this, see module docstring), where collapsing
486
+ # to one frame per hold would discard the frames carrying the answer.
487
+ #
488
+ # The cost of this rule is a genuine cycle whose period is long enough that
489
+ # only ONE pass fits in the recording — every real geetest_v4_svg clip
490
+ # is exactly this — so it falls back to `even`. That used to mean 6 frames of a
491
+ # 3-picture clip; since `even` deduplicates it now means 3, which is the same
492
+ # answer `cycle` would have given, reached without having to decide whether the
493
+ # clip recurs. The rule stays because dropping it WOULD be unsafe: a slow
494
+ # one-way fade also decomposes into 2 long holds at full coverage (verified),
495
+ # and collapsing that to 2 frames discards the middle of the clip. Dedup cannot
496
+ # make that mistake; a permissive cycle detector can.
497
+ if len(holds) <= len(state_reps) and len(state_reps) > 1:
498
+ return None
499
+
500
+ # Every pair of states must be clearly different, or we are calling noise a
501
+ # state and would hand the model two images of the same thing.
502
+ for a in range(len(state_reps)):
503
+ for b in range(a + 1, len(state_reps)):
504
+ if frame_diff_ratio(frames[state_reps[a]], frames[state_reps[b]]) < params.distinct_ratio:
505
+ return None
506
+
507
+ per_frame = [-1] * n
508
+ for (s, e), state in zip(holds, hold_state):
509
+ for i in range(s, e + 1):
510
+ per_frame[i] = state
511
+ return state_reps, per_frame
512
+
513
+
514
+ def extract_keyframes(
515
+ frames: Sequence[np.ndarray],
516
+ *,
517
+ fps: float = 10.0,
518
+ params: Optional[KeyframeParams] = None,
519
+ ) -> KeyframeSet:
520
+ """Slice a decoded clip into the frames the model will be shown.
521
+
522
+ `frames` are BGR uint8 arrays in capture order (what `cv2` hands back and
523
+ what the collector's burst produces).
524
+ """
525
+ if not frames:
526
+ raise ValueError("no frames to extract keyframes from")
527
+ p = params or KeyframeParams()
528
+ n = len(frames)
529
+
530
+ def _ms(i: int) -> float:
531
+ return (i / fps) * 1000.0 if fps > 0 else 0.0
532
+
533
+ cycle = _detect_cycle(frames, p)
534
+ if cycle is not None:
535
+ reps, per_frame = cycle
536
+ mode = "static" if len(reps) == 1 else "cycle"
537
+ return KeyframeSet(
538
+ mode=mode,
539
+ keyframes=[
540
+ Keyframe(number=k + 1, source_index=idx, timestamp_ms=_ms(idx),
541
+ image=frames[idx])
542
+ for k, idx in enumerate(reps)
543
+ ],
544
+ source_frames=n,
545
+ fps=fps,
546
+ params=p,
547
+ frame_states=per_frame,
548
+ )
549
+
550
+ indices = _even_indices(n, p.max_keyframes)
551
+ if p.dedupe:
552
+ # Applied to `even` only. `cycle` states are distinct by construction (the
553
+ # merge in `_detect_cycle` uses this same threshold) and `static` is one
554
+ # frame, so there is nothing to remove; and dropping a cycle keyframe here
555
+ # would desynchronise `frame_states`, which indexes keyframes by position.
556
+ indices = _distinct_indices(frames, indices, p)
557
+ return KeyframeSet(
558
+ mode="even",
559
+ keyframes=[
560
+ Keyframe(number=k + 1, source_index=idx, timestamp_ms=_ms(idx),
561
+ image=frames[idx])
562
+ for k, idx in enumerate(indices)
563
+ ],
564
+ source_frames=n,
565
+ fps=fps,
566
+ params=p,
567
+ )
568
+
569
+
570
+ def extract_keyframes_from_video(
571
+ path: str | os.PathLike, *, params: Optional[KeyframeParams] = None
572
+ ) -> KeyframeSet:
573
+ frames, fps = decode_video(path)
574
+ return extract_keyframes(frames, fps=fps, params=params)
575
+
576
+
577
+ # ── on-disk layout ──────────────────────────────────────────────────────────
578
+ #
579
+ # <media_dir>/keyframes/<stem>/frame_01.png ... frame_0N.png
580
+ # <media_dir>/keyframes/<stem>/keyframes.json
581
+ #
582
+ # Derived, regenerable, and ADDITIVE — the clip stays canonical next to it. That
583
+ # matters most under `cleanSamples/test/`, which is irreplaceable hand-labeled
584
+ # data we never rewrite (CLAUDE.md § Data trees): extraction only ever creates a
585
+ # new sibling directory there.
586
+
587
+
588
+ def keyframe_dir_for(media_path: str | os.PathLike) -> Path:
589
+ """Where this clip's keyframes live. Pure path arithmetic, no I/O."""
590
+ p = Path(media_path)
591
+ return p.parent / KEYFRAME_DIR_NAME / p.stem
592
+
593
+
594
+ def _frame_filename(number: int) -> str:
595
+ return f"frame_{number:02d}.png"
596
+
597
+
598
+ def write_keyframes(
599
+ kfset: KeyframeSet, out_dir: str | os.PathLike, *, stem: str
600
+ ) -> List[Path]:
601
+ """Write `frame_NN.png` + `keyframes.json` into `out_dir`. Returns the PNG
602
+ paths in model order.
603
+
604
+ Stale files from a previous slicing are removed: a clip re-sliced from 6
605
+ keyframes down to 3 would otherwise leave `frame_04..06.png` behind, and
606
+ `read_keyframe_paths` globs the directory — the model would be handed six
607
+ images while the manifest and the label said three.
608
+ """
609
+ out = Path(out_dir)
610
+ out.mkdir(parents=True, exist_ok=True)
611
+ for stale in out.glob("frame_*.png"):
612
+ stale.unlink()
613
+
614
+ paths: List[Path] = []
615
+ names: List[str] = []
616
+ for kf in kfset.keyframes:
617
+ name = _frame_filename(kf.number)
618
+ target = out / name
619
+ if kf.image is None:
620
+ raise ValueError(f"keyframe {kf.number} has no image to write")
621
+ cv2.imwrite(str(target), kf.image)
622
+ paths.append(target)
623
+ names.append(name)
624
+
625
+ with (out / MANIFEST_NAME).open("w") as f:
626
+ json.dump(kfset.manifest(stem=stem, filenames=names), f, indent=2)
627
+ return paths
628
+
629
+
630
+ def load_manifest(kf_dir: str | os.PathLike) -> Optional[Dict[str, Any]]:
631
+ p = Path(kf_dir) / MANIFEST_NAME
632
+ if not p.exists():
633
+ return None
634
+ try:
635
+ with p.open() as f:
636
+ data = json.load(f)
637
+ except (json.JSONDecodeError, OSError):
638
+ return None
639
+ return data if isinstance(data, dict) else None
640
+
641
+
642
+ def read_keyframe_paths(kf_dir: str | os.PathLike) -> List[Path]:
643
+ """The keyframe PNGs in model order, or [] if the directory isn't usable.
644
+
645
+ Order comes from the manifest when present and from the zero-padded filenames
646
+ otherwise; both give frame 1 first. A manifest naming a file that isn't there
647
+ yields [] rather than a short list, because a gap would silently renumber
648
+ every later frame and invalidate the stored `frame` label.
649
+ """
650
+ d = Path(kf_dir)
651
+ if not d.is_dir():
652
+ return []
653
+ manifest = load_manifest(d)
654
+ if manifest and isinstance(manifest.get("keyframes"), list):
655
+ out: List[Path] = []
656
+ for entry in sorted(manifest["keyframes"], key=lambda e: e.get("number", 0)):
657
+ p = d / str(entry.get("file", ""))
658
+ if not p.exists():
659
+ return []
660
+ out.append(p)
661
+ return out
662
+ return sorted(d.glob("frame_*.png"))
663
+
664
+
665
+ def materialize_keyframes(
666
+ media_path: str | os.PathLike,
667
+ *,
668
+ params: Optional[KeyframeParams] = None,
669
+ force: bool = False,
670
+ ) -> List[Path]:
671
+ """The keyframes for a clip, extracting them on first use.
672
+
673
+ Idempotent and cheap on the second call: an existing manifest whose params
674
+ match is reused as-is. A params CHANGE re-slices, because reusing a set cut
675
+ with different thresholds would mix two slicings in one dataset — the failure
676
+ the manifest records params to prevent.
677
+ """
678
+ media = Path(media_path)
679
+ kf_dir = keyframe_dir_for(media)
680
+ p = params or KeyframeParams()
681
+
682
+ if not force:
683
+ manifest = load_manifest(kf_dir)
684
+ if manifest and manifest.get("params") == p.as_dict():
685
+ existing = read_keyframe_paths(kf_dir)
686
+ if existing:
687
+ return existing
688
+
689
+ kfset = extract_keyframes_from_video(media, params=p)
690
+ return write_keyframes(kfset, kf_dir, stem=media.stem)