captchakraken 2.8.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.
- package/AGENTS.md +112 -0
- package/NOTICE +36 -0
- package/dist/cli-invocation.d.ts +9 -40
- package/dist/cli-invocation.js +16 -49
- package/dist/errors.d.ts +7 -27
- package/dist/errors.js +6 -25
- package/dist/humanize.d.ts +56 -240
- package/dist/humanize.js +159 -415
- package/dist/index.d.ts +5 -129
- package/dist/index.js +4 -98
- package/dist/kinds.d.ts +135 -0
- package/dist/kinds.js +70 -0
- package/dist/limits.d.ts +0 -48
- package/dist/limits.js +2 -48
- package/dist/model-name.d.ts +4 -19
- package/dist/model-name.js +32 -40
- package/dist/playwright-types.d.ts +25 -95
- package/dist/playwright-types.js +0 -23
- package/dist/puppeteer-adapter.d.ts +4 -43
- package/dist/puppeteer-adapter.js +22 -58
- package/dist/python-command.d.ts +0 -41
- package/dist/python-command.js +0 -37
- package/dist/selectors.d.ts +54 -0
- package/dist/selectors.js +117 -0
- package/dist/slide-geometry.d.ts +2 -29
- package/dist/slide-geometry.js +18 -44
- package/dist/solver.d.ts +112 -539
- package/dist/solver.js +1088 -2950
- package/dist/timing.d.ts +7 -40
- package/dist/timing.js +4 -36
- package/dist/token-usage.d.ts +1 -7
- package/dist/token-usage.js +10 -95
- package/dist/types.d.ts +77 -483
- package/dist/watcher.d.ts +5 -78
- package/dist/watcher.js +0 -56
- package/package.json +12 -4
- package/python/AGENTS.md +122 -0
- package/python/NOTICE +36 -0
- package/python/README.md +1 -2
- package/python/examples/README.md +10 -0
- package/python/examples/_harness.py +4 -73
- package/python/examples/demoHcaptcha.py +0 -13
- package/python/examples/demoRecaptcha.py +0 -10
- package/python/examples/watch_playwright.py +1 -26
- package/python/examples/with_local_gguf.py +28 -0
- package/python/examples/with_playwright.py +0 -19
- package/python/pyproject.toml +68 -19
- package/python/src/captchakraken/__init__.py +37 -45
- package/python/src/captchakraken/action_types.py +14 -36
- package/python/src/captchakraken/cli.py +262 -879
- package/python/src/captchakraken/config.py +60 -146
- package/python/src/captchakraken/errors.py +17 -95
- package/python/src/captchakraken/humanize.py +174 -597
- package/python/src/captchakraken/image_processor.py +16 -235
- package/python/src/captchakraken/keyframes.py +69 -434
- package/python/src/captchakraken/kinds.py +152 -0
- package/python/src/captchakraken/models.json +79 -21
- package/python/src/captchakraken/overlay.py +65 -498
- package/python/src/captchakraken/page_solver.py +1123 -2564
- package/python/src/captchakraken/planner.py +70 -428
- package/python/src/captchakraken/prompts.py +45 -261
- package/python/src/captchakraken/selectors.py +184 -0
- package/python/src/captchakraken/server_manager.py +7 -58
- package/python/src/captchakraken/solver.py +128 -522
- package/python/src/captchakraken/timing.py +20 -74
- package/python/src/captchakraken/tool_calls/board_painted.py +47 -0
- package/python/src/captchakraken/tool_calls/find_checkbox.py +20 -30
- package/python/src/captchakraken/tool_calls/find_grid.py +186 -1339
- package/python/src/captchakraken/tool_calls/track_piece.py +81 -57
- package/python/src/captchakraken/updater.py +7 -76
- package/python/src/captchakraken/watcher.py +12 -101
- package/scripts/copy-python.mjs +10 -10
- package/scripts/setup-python.js +1 -21
- package/dist/trajectory.d.ts +0 -58
- package/dist/trajectory.js +0 -266
- package/python/src/captchakraken/tool_calls/move_indicator.py +0 -431
- package/python/src/captchakraken/trajectory.py +0 -284
|
@@ -1,20 +1,8 @@
|
|
|
1
|
-
"""Reduce a recorded
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
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
|
-
|
|
32
|
-
|
|
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
|
-
#
|
|
41
|
-
#
|
|
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.
|
|
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
|
-
#
|
|
64
|
-
# and
|
|
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
|
|
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
|
-
#
|
|
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
|
|
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
|
-
#
|
|
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
|
-
#
|
|
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
|
-
"""
|
|
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,96 +72,34 @@ class KeyframeParams:
|
|
|
136
72
|
|
|
137
73
|
@dataclass
|
|
138
74
|
class Keyframe:
|
|
139
|
-
"""
|
|
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
|
|
80
|
+
image: Optional[np.ndarray] = None
|
|
152
81
|
|
|
153
82
|
|
|
154
83
|
@dataclass
|
|
155
84
|
class KeyframeSet:
|
|
156
|
-
mode:
|
|
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)
|
|
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.
|
|
93
|
+
steady_screens: int = 0
|
|
165
94
|
|
|
166
95
|
def __len__(self) -> int:
|
|
167
96
|
return len(self.keyframes)
|
|
168
97
|
|
|
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
98
|
def manifest(self, *, stem: str, filenames: Sequence[str]) -> Dict[str, Any]:
|
|
226
99
|
return {
|
|
227
100
|
"stem": stem,
|
|
228
101
|
"mode": self.mode,
|
|
102
|
+
"steady_screens": self.steady_screens,
|
|
229
103
|
"fps": self.fps,
|
|
230
104
|
"source_frames": self.source_frames,
|
|
231
105
|
"params": self.params.as_dict(),
|
|
@@ -241,21 +115,12 @@ class KeyframeSet:
|
|
|
241
115
|
}
|
|
242
116
|
|
|
243
117
|
|
|
244
|
-
# ── similarity ──────────────────────────────────────────────────────────────
|
|
245
|
-
|
|
246
|
-
|
|
247
118
|
def region_box(
|
|
248
119
|
size_wh: Tuple[int, int],
|
|
249
120
|
point_norm: Tuple[float, float],
|
|
250
121
|
half: float = MATCH_REGION_HALF,
|
|
251
122
|
) -> Tuple[int, int, int, int]:
|
|
252
|
-
"""
|
|
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
|
-
"""
|
|
123
|
+
"""Never empty: a comparison over no pixels reads as a perfect match and the gate would open on any state."""
|
|
259
124
|
w, h = int(size_wh[0]), int(size_wh[1])
|
|
260
125
|
cx, cy = float(point_norm[0]) * w, float(point_norm[1]) * h
|
|
261
126
|
rx, ry = max(1.0, half * w), max(1.0, half * h)
|
|
@@ -269,11 +134,6 @@ def region_box(
|
|
|
269
134
|
def region_diff_ratio(
|
|
270
135
|
a: np.ndarray, b: np.ndarray, box: Optional[Tuple[int, int, int, int]] = None
|
|
271
136
|
) -> 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
137
|
if a is None or b is None or a.shape != b.shape:
|
|
278
138
|
return 1.0
|
|
279
139
|
if box is None:
|
|
@@ -283,12 +143,6 @@ def region_diff_ratio(
|
|
|
283
143
|
|
|
284
144
|
|
|
285
145
|
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
146
|
if a is None or b is None:
|
|
293
147
|
return 1.0
|
|
294
148
|
if a.shape != b.shape:
|
|
@@ -303,47 +157,9 @@ def frame_diff_ratio(a: np.ndarray, b: np.ndarray) -> float:
|
|
|
303
157
|
return cv2.countNonZero(thr) / float(h * w)
|
|
304
158
|
|
|
305
159
|
|
|
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
160
|
|
|
339
161
|
def _anchor_runs(frames: Sequence[np.ndarray], steady_ratio: float) -> List[Tuple[int, int]]:
|
|
340
|
-
"""
|
|
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
|
-
"""
|
|
162
|
+
"""Anchored on the run's first frame: a slow pan has every consecutive diff under the threshold."""
|
|
347
163
|
runs: List[Tuple[int, int]] = []
|
|
348
164
|
i = 0
|
|
349
165
|
n = len(frames)
|
|
@@ -357,12 +173,6 @@ def _anchor_runs(frames: Sequence[np.ndarray], steady_ratio: float) -> List[Tupl
|
|
|
357
173
|
|
|
358
174
|
|
|
359
175
|
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
176
|
if len(indices) <= 2:
|
|
367
177
|
return indices[0]
|
|
368
178
|
best_idx, best_cost = indices[0], float("inf")
|
|
@@ -374,7 +184,6 @@ def _medoid(frames: Sequence[np.ndarray], indices: Sequence[int]) -> int:
|
|
|
374
184
|
|
|
375
185
|
|
|
376
186
|
def _even_indices(n: int, count: int) -> List[int]:
|
|
377
|
-
"""`count` indices spread across 0..n-1, endpoints included, deduped."""
|
|
378
187
|
if n <= count:
|
|
379
188
|
return list(range(n))
|
|
380
189
|
if count <= 1:
|
|
@@ -390,36 +199,10 @@ def _even_indices(n: int, count: int) -> List[int]:
|
|
|
390
199
|
def _distinct_indices(
|
|
391
200
|
frames: Sequence[np.ndarray], candidates: Sequence[int], params: KeyframeParams
|
|
392
201
|
) -> List[int]:
|
|
393
|
-
"""Drop
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
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.
|
|
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.
|
|
423
206
|
"""
|
|
424
207
|
kept: List[int] = []
|
|
425
208
|
|
|
@@ -440,15 +223,9 @@ def _distinct_indices(
|
|
|
440
223
|
return sorted(kept)
|
|
441
224
|
|
|
442
225
|
|
|
443
|
-
def
|
|
226
|
+
def _steady_screens(
|
|
444
227
|
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
|
-
"""
|
|
228
|
+
) -> Optional[Tuple[List[Tuple[int, int]], List[int], List[int]]]:
|
|
452
229
|
n = len(frames)
|
|
453
230
|
runs = _anchor_runs(frames, params.steady_ratio)
|
|
454
231
|
holds = [(s, e) for (s, e) in runs if (e - s + 1) >= params.min_hold_frames]
|
|
@@ -459,11 +236,8 @@ def _detect_cycle(
|
|
|
459
236
|
if covered / float(n) < params.min_steady_coverage:
|
|
460
237
|
return None
|
|
461
238
|
|
|
462
|
-
|
|
463
|
-
|
|
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`
|
|
239
|
+
state_reps: List[int] = []
|
|
240
|
+
hold_state: List[int] = []
|
|
467
241
|
for s, e in holds:
|
|
468
242
|
rep = _medoid(frames, list(range(s, e + 1)))
|
|
469
243
|
for k, existing in enumerate(state_reps):
|
|
@@ -472,33 +246,36 @@ def _detect_cycle(
|
|
|
472
246
|
break
|
|
473
247
|
else:
|
|
474
248
|
if len(state_reps) >= params.max_keyframes:
|
|
475
|
-
return None
|
|
249
|
+
return None
|
|
476
250
|
state_reps.append(rep)
|
|
477
251
|
hold_state.append(len(state_reps) - 1)
|
|
478
252
|
|
|
479
253
|
if len(state_reps) < 1:
|
|
480
254
|
return None
|
|
481
255
|
|
|
482
|
-
#
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
256
|
+
# No distinct_ratio here: the merge above already separated states by the measured noise floor.
|
|
257
|
+
return holds, state_reps, hold_state
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def steady_screens(frames: Sequence[np.ndarray], params: KeyframeParams) -> int:
|
|
261
|
+
got = _steady_screens(frames, params)
|
|
262
|
+
return len(got[1]) if got else 0
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _detect_cycle(
|
|
266
|
+
frames: Sequence[np.ndarray], params: KeyframeParams
|
|
267
|
+
) -> Optional[Tuple[List[int], List[int]]]:
|
|
268
|
+
n = len(frames)
|
|
269
|
+
got = _steady_screens(frames, params)
|
|
270
|
+
if got is None:
|
|
271
|
+
return None
|
|
272
|
+
holds, state_reps, hold_state = got
|
|
273
|
+
|
|
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.
|
|
497
276
|
if len(holds) <= len(state_reps) and len(state_reps) > 1:
|
|
498
277
|
return None
|
|
499
278
|
|
|
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
279
|
for a in range(len(state_reps)):
|
|
503
280
|
for b in range(a + 1, len(state_reps)):
|
|
504
281
|
if frame_diff_ratio(frames[state_reps[a]], frames[state_reps[b]]) < params.distinct_ratio:
|
|
@@ -514,55 +291,16 @@ def _detect_cycle(
|
|
|
514
291
|
def _drop_smeared(
|
|
515
292
|
frames: Sequence[np.ndarray], indices: Sequence[int], params: KeyframeParams
|
|
516
293
|
) -> List[int]:
|
|
517
|
-
"""Drop
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
land on the instant of the swap — a board part-way between two states, which
|
|
522
|
-
is a picture the puzzle never actually shows. If the model already has the
|
|
523
|
-
board before the swap AND the board after it, that frame adds nothing but a
|
|
524
|
-
smear; if either endpoint is missing, it is the only thing standing in for a
|
|
525
|
-
state and must stay.
|
|
526
|
-
|
|
527
|
-
THE MACHINERY ALREADY EXISTED AND THIS PATH NEVER ASKED IT. `_anchor_runs`
|
|
528
|
-
plus `min_hold_frames` is how `_detect_cycle` separates holds from
|
|
529
|
-
transitions (it labels the latter -1 in `frame_states`), and
|
|
530
|
-
`min_hold_frames` had exactly one reader — inside `_detect_cycle`. On the
|
|
531
|
-
`even` path the parameter was declared, serialised into every manifest, and
|
|
532
|
-
inert.
|
|
533
|
-
|
|
534
|
-
WHY THE TEST IS "BOTH NEIGHBOURING HOLDS ARE KEPT" AND NOT A PIXEL DIFF.
|
|
535
|
-
Two earlier versions were measured against both trees and both were wrong:
|
|
536
|
-
|
|
537
|
-
* *Snap to the nearest held frame* turned `[0, 16, 39]` into `[0, 16]` on
|
|
538
|
-
six eval clips, dropping a SCREEN. A page that never reaches the model is
|
|
539
|
-
unanswerable whenever the target lived on it.
|
|
540
|
-
* *Drop when some other still is within `distinct_ratio`* cut
|
|
541
|
-
`hcaptcha_tile_flip_video` from 6 stills to 3, because two boards of that
|
|
542
|
-
puzzle differ by ONE TILE — well inside `distinct_ratio` — so a diff
|
|
543
|
-
threshold cannot tell "the same picture again" from "the next board".
|
|
544
|
-
EXPECTED_SLICING says it directly: fewer than 6 "means dedup merged two
|
|
545
|
-
boards that differ only by the tile mid-flip, and that tile is frequently
|
|
546
|
-
the answer".
|
|
547
|
-
|
|
548
|
-
Anchoring on the HOLDS either side asks the question that actually matters —
|
|
549
|
-
does the model already see both ends of this swap — and needs no threshold of
|
|
550
|
-
its own.
|
|
551
|
-
|
|
552
|
-
A clip with no holds at all (a rotation, a continuous fade, anything in
|
|
553
|
-
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).
|
|
554
298
|
"""
|
|
555
299
|
runs = _anchor_runs(frames, params.steady_ratio)
|
|
556
300
|
holds = [(s, e) for (s, e) in runs if (e - s + 1) >= params.min_hold_frames]
|
|
557
301
|
if not holds:
|
|
558
|
-
return list(indices)
|
|
559
|
-
|
|
560
|
-
# Holds showing the same picture are ONE state — the same merge
|
|
561
|
-
# `_detect_cycle` does, at the same threshold. It matters here because a
|
|
562
|
-
# cycle revisits: on a real `icon_2x2` clip the hold at 53..74 is the
|
|
563
|
-
# opening board coming back, so the still at index 0 already covers it.
|
|
564
|
-
# Counting holds instead of states kept a smear whose neighbouring picture
|
|
565
|
-
# was on screen the whole time.
|
|
302
|
+
return list(indices)
|
|
303
|
+
|
|
566
304
|
state_of_hold: List[int] = []
|
|
567
305
|
reps: List[int] = []
|
|
568
306
|
for start, end in holds:
|
|
@@ -590,9 +328,6 @@ def _drop_smeared(
|
|
|
590
328
|
continue
|
|
591
329
|
before = max((k for k, (s, e) in enumerate(holds) if e < i), default=None)
|
|
592
330
|
after = min((k for k, (s, e) in enumerate(holds) if s > i), default=None)
|
|
593
|
-
# Only a swap BETWEEN two kept holds is redundant. A transition at the
|
|
594
|
-
# very start or end of a clip has an endpoint that was never recorded,
|
|
595
|
-
# so it is evidence of a state and stays.
|
|
596
331
|
if before is not None and after is not None \
|
|
597
332
|
and state_of_hold[before] in covered \
|
|
598
333
|
and state_of_hold[after] in covered:
|
|
@@ -607,11 +342,6 @@ def extract_keyframes(
|
|
|
607
342
|
fps: float = 10.0,
|
|
608
343
|
params: Optional[KeyframeParams] = None,
|
|
609
344
|
) -> KeyframeSet:
|
|
610
|
-
"""Slice a decoded clip into the frames the model will be shown.
|
|
611
|
-
|
|
612
|
-
`frames` are BGR uint8 arrays in capture order (what `cv2` hands back and
|
|
613
|
-
what the collector's burst produces).
|
|
614
|
-
"""
|
|
615
345
|
if not frames:
|
|
616
346
|
raise ValueError("no frames to extract keyframes from")
|
|
617
347
|
p = params or KeyframeParams()
|
|
@@ -623,8 +353,9 @@ def extract_keyframes(
|
|
|
623
353
|
cycle = _detect_cycle(frames, p)
|
|
624
354
|
if cycle is not None:
|
|
625
355
|
reps, per_frame = cycle
|
|
626
|
-
mode =
|
|
356
|
+
mode = KeyframeMode.STATIC if len(reps) == 1 else KeyframeMode.CYCLE
|
|
627
357
|
return KeyframeSet(
|
|
358
|
+
steady_screens=len(reps),
|
|
628
359
|
mode=mode,
|
|
629
360
|
keyframes=[
|
|
630
361
|
Keyframe(number=k + 1, source_index=idx, timestamp_ms=_ms(idx),
|
|
@@ -638,23 +369,15 @@ def extract_keyframes(
|
|
|
638
369
|
)
|
|
639
370
|
|
|
640
371
|
indices = _even_indices(n, p.max_keyframes)
|
|
641
|
-
#
|
|
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).
|
|
642
374
|
indices = _drop_smeared(frames, indices, p)
|
|
643
375
|
if p.dedupe:
|
|
644
|
-
# Applied to `even` only. `cycle` states are distinct by construction (the
|
|
645
|
-
# merge in `_detect_cycle` uses this same threshold) and `static` is one
|
|
646
|
-
# frame, so there is nothing to remove; and dropping a cycle keyframe here
|
|
647
|
-
# would desynchronise `frame_states`, which indexes keyframes by position.
|
|
648
376
|
indices = _distinct_indices(frames, indices, p)
|
|
649
|
-
# ...AND AFTER, because `_distinct_indices` BACKFILLS. Dedup frees
|
|
650
|
-
# budget and the backfill walks the clip for anything not already
|
|
651
|
-
# represented — which is exactly what a smeared frame is, so it gets
|
|
652
|
-
# picked straight back up. Measured on a 3-hold fixture: filtering only
|
|
653
|
-
# before dedup returned [0, 10, 12, 21, 25] with 10 and 21 the two
|
|
654
|
-
# smears the first pass had just removed.
|
|
655
377
|
indices = _drop_smeared(frames, indices, p)
|
|
656
378
|
return KeyframeSet(
|
|
657
|
-
|
|
379
|
+
steady_screens=steady_screens(frames, p),
|
|
380
|
+
mode=KeyframeMode.EVEN,
|
|
658
381
|
keyframes=[
|
|
659
382
|
Keyframe(number=k + 1, source_index=idx, timestamp_ms=_ms(idx),
|
|
660
383
|
image=frames[idx])
|
|
@@ -666,47 +389,19 @@ def extract_keyframes(
|
|
|
666
389
|
)
|
|
667
390
|
|
|
668
391
|
|
|
669
|
-
def extract_keyframes_from_video(
|
|
670
|
-
path: str | os.PathLike, *, params: Optional[KeyframeParams] = None
|
|
671
|
-
) -> KeyframeSet:
|
|
672
|
-
frames, fps = decode_video(path)
|
|
673
|
-
return extract_keyframes(frames, fps=fps, params=params)
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
# ── on-disk layout ──────────────────────────────────────────────────────────
|
|
677
|
-
#
|
|
678
|
-
# <media_dir>/keyframes/<stem>/frame_01.png ... frame_0N.png
|
|
679
|
-
# <media_dir>/keyframes/<stem>/keyframes.json
|
|
680
|
-
#
|
|
681
|
-
# Derived, regenerable, and ADDITIVE — the clip stays canonical next to it. That
|
|
682
|
-
# matters most under `cleanSamples/test/`, which is irreplaceable hand-labeled
|
|
683
|
-
# data we never rewrite (CLAUDE.md § Data trees): extraction only ever creates a
|
|
684
|
-
# new sibling directory there.
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
def keyframe_dir_for(media_path: str | os.PathLike) -> Path:
|
|
688
|
-
"""Where this clip's keyframes live. Pure path arithmetic, no I/O."""
|
|
689
|
-
p = Path(media_path)
|
|
690
|
-
return p.parent / KEYFRAME_DIR_NAME / p.stem
|
|
691
392
|
|
|
692
393
|
|
|
693
394
|
def _frame_filename(number: int) -> str:
|
|
395
|
+
"""Zero-padded because readers sort by name."""
|
|
694
396
|
return f"frame_{number:02d}.png"
|
|
695
397
|
|
|
696
398
|
|
|
697
399
|
def write_keyframes(
|
|
698
400
|
kfset: KeyframeSet, out_dir: str | os.PathLike, *, stem: str
|
|
699
401
|
) -> List[Path]:
|
|
700
|
-
"""Write `frame_NN.png` + `keyframes.json` into `out_dir`. Returns the PNG
|
|
701
|
-
paths in model order.
|
|
702
|
-
|
|
703
|
-
Stale files from a previous slicing are removed: a clip re-sliced from 6
|
|
704
|
-
keyframes down to 3 would otherwise leave `frame_04..06.png` behind, and
|
|
705
|
-
`read_keyframe_paths` globs the directory — the model would be handed six
|
|
706
|
-
images while the manifest and the label said three.
|
|
707
|
-
"""
|
|
708
402
|
out = Path(out_dir)
|
|
709
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.
|
|
710
405
|
for stale in out.glob("frame_*.png"):
|
|
711
406
|
stale.unlink()
|
|
712
407
|
|
|
@@ -726,64 +421,4 @@ def write_keyframes(
|
|
|
726
421
|
return paths
|
|
727
422
|
|
|
728
423
|
|
|
729
|
-
def load_manifest(kf_dir: str | os.PathLike) -> Optional[Dict[str, Any]]:
|
|
730
|
-
p = Path(kf_dir) / MANIFEST_NAME
|
|
731
|
-
if not p.exists():
|
|
732
|
-
return None
|
|
733
|
-
try:
|
|
734
|
-
with p.open() as f:
|
|
735
|
-
data = json.load(f)
|
|
736
|
-
except (json.JSONDecodeError, OSError):
|
|
737
|
-
return None
|
|
738
|
-
return data if isinstance(data, dict) else None
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
def read_keyframe_paths(kf_dir: str | os.PathLike) -> List[Path]:
|
|
742
|
-
"""The keyframe PNGs in model order, or [] if the directory isn't usable.
|
|
743
|
-
|
|
744
|
-
Order comes from the manifest when present and from the zero-padded filenames
|
|
745
|
-
otherwise; both give frame 1 first. A manifest naming a file that isn't there
|
|
746
|
-
yields [] rather than a short list, because a gap would silently renumber
|
|
747
|
-
every later frame and invalidate the stored `frame` label.
|
|
748
|
-
"""
|
|
749
|
-
d = Path(kf_dir)
|
|
750
|
-
if not d.is_dir():
|
|
751
|
-
return []
|
|
752
|
-
manifest = load_manifest(d)
|
|
753
|
-
if manifest and isinstance(manifest.get("keyframes"), list):
|
|
754
|
-
out: List[Path] = []
|
|
755
|
-
for entry in sorted(manifest["keyframes"], key=lambda e: e.get("number", 0)):
|
|
756
|
-
p = d / str(entry.get("file", ""))
|
|
757
|
-
if not p.exists():
|
|
758
|
-
return []
|
|
759
|
-
out.append(p)
|
|
760
|
-
return out
|
|
761
|
-
return sorted(d.glob("frame_*.png"))
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
def materialize_keyframes(
|
|
765
|
-
media_path: str | os.PathLike,
|
|
766
|
-
*,
|
|
767
|
-
params: Optional[KeyframeParams] = None,
|
|
768
|
-
force: bool = False,
|
|
769
|
-
) -> List[Path]:
|
|
770
|
-
"""The keyframes for a clip, extracting them on first use.
|
|
771
|
-
|
|
772
|
-
Idempotent and cheap on the second call: an existing manifest whose params
|
|
773
|
-
match is reused as-is. A params CHANGE re-slices, because reusing a set cut
|
|
774
|
-
with different thresholds would mix two slicings in one dataset — the failure
|
|
775
|
-
the manifest records params to prevent.
|
|
776
|
-
"""
|
|
777
|
-
media = Path(media_path)
|
|
778
|
-
kf_dir = keyframe_dir_for(media)
|
|
779
|
-
p = params or KeyframeParams()
|
|
780
|
-
|
|
781
|
-
if not force:
|
|
782
|
-
manifest = load_manifest(kf_dir)
|
|
783
|
-
if manifest and manifest.get("params") == p.as_dict():
|
|
784
|
-
existing = read_keyframe_paths(kf_dir)
|
|
785
|
-
if existing:
|
|
786
|
-
return existing
|
|
787
424
|
|
|
788
|
-
kfset = extract_keyframes_from_video(media, params=p)
|
|
789
|
-
return write_keyframes(kfset, kf_dir, stem=media.stem)
|