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,70 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.getBundledCliRoot = getBundledCliRoot;
7
+ exports.resolveLoraName = resolveLoraName;
8
+ /**
9
+ * Which served LoRA name this client asks for — the SAME answer the Python
10
+ * client's `config.lora_name()` gives, and for the same reasons.
11
+ *
12
+ * This has to be shared, not re-decided per port. The name is not just routing:
13
+ * the Python CLI feeds it to `prompts.resolve()`, which maps it through
14
+ * models.json to a PROMPT GENERATION. A port that picks a different name sends
15
+ * a different generation of prompts to the same weights, and nothing errors for
16
+ * any family both generations have a prompt for — it just answers worse. That
17
+ * is the failure models.json exists to prevent, and the JS port shipped it:
18
+ * a hardcoded `'captcha'` (CaptchaKraken_v1.1, generation 1) against the
19
+ * generation-2 adapter `latest` has named since v1.2.
20
+ *
21
+ * Precedence mirrors config.py exactly:
22
+ * 1. CAPTCHA_LORA_NAME — an explicit pin always wins, and pinning is opt-in.
23
+ * 2. models.json's `latest` entry, so the default model and the default
24
+ * prompt generation move forward together and cannot get out of step.
25
+ * 3. pinned_model.json, so a hand-edited or older manifest still resolves.
26
+ */
27
+ const node_fs_1 = __importDefault(require("node:fs"));
28
+ const node_path_1 = __importDefault(require("node:path"));
29
+ /**
30
+ * Where the bundled Python engine lives — the copy of `models.json` this client
31
+ * actually answers from.
32
+ *
33
+ * Lives HERE rather than in solver.ts because the registry lookup below is its
34
+ * only remaining caller-visible use, and a second copy of this path logic is
35
+ * how the two ports drifted apart in the first place. solver.ts imports it.
36
+ *
37
+ * When installed from npm this file is in `<pkgRoot>/dist` (compiled) or
38
+ * `<pkgRoot>/src` (dev), and published packages bundle the engine at
39
+ * `<pkgRoot>/python` (copied in by scripts/copy-python.mjs at build time). In
40
+ * the source monorepo it instead lives at the sibling `../python`.
41
+ */
42
+ function getBundledCliRoot() {
43
+ const bundled = node_path_1.default.resolve(__dirname, '..', 'python');
44
+ if (node_fs_1.default.existsSync(bundled))
45
+ return bundled;
46
+ return node_path_1.default.resolve(__dirname, '..', '..', 'python');
47
+ }
48
+ function readJson(cliRoot, name) {
49
+ return JSON.parse(node_fs_1.default.readFileSync(node_path_1.default.join(cliRoot, 'src', 'captchakraken', name), 'utf-8'));
50
+ }
51
+ /**
52
+ * `cliRoot` is optional so a caller that only wants to REPORT the name — a test
53
+ * harness recording which adapter it drove — can ask without reconstructing the
54
+ * engine's layout. Re-deriving it is the same mistake one level down.
55
+ */
56
+ function resolveLoraName({ cliRoot = getBundledCliRoot(), env = process.env } = {}) {
57
+ if (env.CAPTCHA_LORA_NAME)
58
+ return env.CAPTCHA_LORA_NAME;
59
+ try {
60
+ const reg = readJson(cliRoot, 'models.json');
61
+ const name = reg?.models?.[reg?.latest]?.lora_name;
62
+ if (typeof name === 'string' && name)
63
+ return name;
64
+ }
65
+ catch {
66
+ // A missing or broken registry falls through to the pin, never throws —
67
+ // same contract as config.py's `_registry_default`.
68
+ }
69
+ return readJson(cliRoot, 'pinned_model.json').lora_name;
70
+ }
@@ -59,6 +59,13 @@ export interface PlaywrightElementHandle {
59
59
  isVisible(): Promise<boolean>;
60
60
  /** The element's text content, or null. */
61
61
  textContent(): Promise<string | null>;
62
+ /**
63
+ * First matching element WITHIN this element's subtree, or null. The vendors
64
+ * that render into the host page rather than an iframe (GeeTest, Yidun,
65
+ * BotDetect, …) have no Frame to scope against, so the widget element is the
66
+ * boundary that keeps a generic selector off the rest of the document.
67
+ */
68
+ $(selector: string): Promise<PlaywrightElementHandle | null>;
62
69
  }
63
70
  /**
64
71
  * Structural subset of Playwright's `Frame` (an iframe's content document).
@@ -96,6 +103,19 @@ export interface PlaywrightPage {
96
103
  clickCount?: number;
97
104
  }): Promise<void>;
98
105
  };
106
+ /**
107
+ * Key input. Used to type a distorted-text captcha's answer character by
108
+ * character — `fill()` would set the value with no keystrokes at all, and
109
+ * these are the vendors that score typing cadence.
110
+ */
111
+ keyboard: {
112
+ type(text: string, options?: {
113
+ delay?: number;
114
+ }): Promise<void>;
115
+ press(key: string, options?: {
116
+ delay?: number;
117
+ }): Promise<void>;
118
+ };
99
119
  /** Sleep `timeout` ms (Playwright's own timer). */
100
120
  waitForTimeout(timeout: number): Promise<void>;
101
121
  /** Wait for a selector to reach the given state; resolves to the handle (or null). */
@@ -109,6 +129,14 @@ export interface PlaywrightPage {
109
129
  $(selector: string): Promise<PlaywrightElementHandle | null>;
110
130
  /** All matching element handles. */
111
131
  $$(selector: string): Promise<PlaywrightElementHandle[]>;
132
+ /**
133
+ * Whether the page has been closed. OPTIONAL because it is the one member
134
+ * here the solver itself never needs — the auto-solve watcher uses it to end
135
+ * its loop when the caller closes the browser out from under it, and falls
136
+ * back to treating the page as open when a launcher does not expose it.
137
+ * Playwright and Puppeteer both do.
138
+ */
139
+ isClosed?(): boolean;
112
140
  /** Run `pageFunction` against the first matching element, in the page context. */
113
141
  $eval<R>(selector: string, pageFunction: (element: Element) => R, arg?: any): Promise<R>;
114
142
  }
@@ -14,6 +14,7 @@
14
14
  * - `handle.getAttribute(name)` → `handle.evaluate((el,n)=>el.getAttribute(n), name)`
15
15
  * - `handle.textContent()` → `handle.evaluate(el=>el.textContent)`
16
16
  * - `handle.scrollIntoViewIfNeeded()`→ `handle.scrollIntoView()`
17
+ * - `page.isClosed()` → same, but must be forwarded explicitly
17
18
  * Everything else (`$`, `$$`, `$eval`, `waitForFunction`, `mouse.*`,
18
19
  * `screenshot({path})`, `boundingBox`, `contentFrame`, `isVisible`) is
19
20
  * call-compatible and passed straight through.
@@ -42,6 +43,7 @@ interface PuppeteerElementHandle {
42
43
  scrollIntoView(): Promise<void>;
43
44
  isVisible(): Promise<boolean>;
44
45
  evaluate(pageFunction: (el: any, ...args: any[]) => any, ...args: any[]): Promise<any>;
46
+ $(selector: string): Promise<PuppeteerElementHandle | null>;
45
47
  }
46
48
  interface PuppeteerFrame {
47
49
  $(selector: string): Promise<PuppeteerElementHandle | null>;
@@ -56,11 +58,20 @@ interface PuppeteerPage {
56
58
  down(options?: any): Promise<void>;
57
59
  up(options?: any): Promise<void>;
58
60
  };
61
+ keyboard: {
62
+ type(text: string, options?: {
63
+ delay?: number;
64
+ }): Promise<void>;
65
+ press(key: string, options?: any): Promise<void>;
66
+ down(key: string, options?: any): Promise<void>;
67
+ up(key: string): Promise<void>;
68
+ };
59
69
  waitForSelector(selector: string, options?: any): Promise<PuppeteerElementHandle | null>;
60
70
  viewport(): ViewportSize | null;
61
71
  $(selector: string): Promise<PuppeteerElementHandle | null>;
62
72
  $$(selector: string): Promise<PuppeteerElementHandle[]>;
63
73
  $eval(selector: string, pageFunction: (element: Element) => any, ...args: any[]): Promise<any>;
74
+ isClosed(): boolean;
64
75
  }
65
76
  /**
66
77
  * Wrap a Puppeteer `Page` so it satisfies the solver's structural Playwright
@@ -15,6 +15,7 @@
15
15
  * - `handle.getAttribute(name)` → `handle.evaluate((el,n)=>el.getAttribute(n), name)`
16
16
  * - `handle.textContent()` → `handle.evaluate(el=>el.textContent)`
17
17
  * - `handle.scrollIntoViewIfNeeded()`→ `handle.scrollIntoView()`
18
+ * - `page.isClosed()` → same, but must be forwarded explicitly
18
19
  * Everything else (`$`, `$$`, `$eval`, `waitForFunction`, `mouse.*`,
19
20
  * `screenshot({path})`, `boundingBox`, `contentFrame`, `isVisible`) is
20
21
  * call-compatible and passed straight through.
@@ -62,6 +63,7 @@ function wrapHandle(h) {
62
63
  getAttribute: (name) => h.evaluate((el, n) => el.getAttribute(n), name),
63
64
  isVisible: () => h.isVisible(),
64
65
  textContent: () => h.evaluate((el) => el.textContent),
66
+ $: async (selector) => wrapHandle(await h.$(selector)),
65
67
  };
66
68
  }
67
69
  function wrapFrame(f) {
@@ -86,11 +88,32 @@ function fromPuppeteer(page) {
86
88
  down: (options) => page.mouse.down(options),
87
89
  up: (options) => page.mouse.up(options),
88
90
  },
91
+ keyboard: {
92
+ type: (text, options) => page.keyboard.type(text, options),
93
+ // Puppeteer has no combo syntax: 'Control+A' must be held, pressed,
94
+ // released. Playwright accepts the combo string directly, which is why
95
+ // the solver speaks Playwright and this adapter translates.
96
+ press: async (key) => {
97
+ const parts = key.split('+');
98
+ const target = parts.pop();
99
+ for (const mod of parts)
100
+ await page.keyboard.down(mod);
101
+ await page.keyboard.press(target);
102
+ for (const mod of parts.reverse())
103
+ await page.keyboard.up(mod);
104
+ },
105
+ },
89
106
  waitForTimeout: (timeout) => new Promise((resolve) => setTimeout(resolve, timeout)),
90
107
  waitForSelector: async (selector, options) => wrapHandle(await page.waitForSelector(selector, toPuppeteerSelectorOptions(options))),
91
108
  viewportSize: () => page.viewport(),
92
109
  $: async (selector) => wrapHandle(await page.$(selector)),
93
110
  $$: async (selector) => (await page.$$(selector)).map((h) => wrapHandle(h)).filter(Boolean),
94
111
  $eval: (selector, pageFunction, arg) => page.$eval(selector, pageFunction, arg),
112
+ // Same name and shape on both libraries, but it still has to be forwarded:
113
+ // the wrapper is a fresh object literal, so anything not listed here is
114
+ // simply absent. The auto-solve watcher reads it to end its loop when the
115
+ // caller closes the browser, and an undefined `isClosed` reads as "still
116
+ // open" — a watcher that polls a dead page forever.
117
+ isClosed: () => page.isClosed(),
95
118
  };
96
119
  }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Which interpreter runs the bundled CaptchaKraken CLI.
3
+ *
4
+ * This used to be `pythonCommand = 'python'`, inline in solver.ts. On
5
+ * Debian/Ubuntu — the most common Linux deployment target, and what CI runs —
6
+ * there is no `python` on PATH at all, only `python3`. So every JS solve died
7
+ * with `/bin/sh: 1: python: not found` before it ever reached the model, unless
8
+ * a `.venv` happened to exist next to the package. The type doc claimed the
9
+ * default was "'python' or 'python3'"; the code never tried `python3`.
10
+ *
11
+ * That is a straight violation of the rule that the Python and JS clients
12
+ * behave identically: the Python client obviously ran, so the divergence looked
13
+ * like a model or endpoint problem rather than a missing binary.
14
+ *
15
+ * Kept in its own module, with the probe injected, so the resolution order is
16
+ * testable without spawning anything.
17
+ */
18
+ /** Env var that overrides everything below it. */
19
+ export declare const PYTHON_ENV_VAR = "CAPTCHA_KRAKEN_PYTHON";
20
+ /**
21
+ * Candidates tried in order when nothing is configured.
22
+ *
23
+ * `python3` FIRST. On Debian-family systems `python` is absent; where both
24
+ * exist they are the same interpreter in practice, and PEP 394 makes `python3`
25
+ * the portable spelling. `python` stays as the fallback for Windows and for
26
+ * venvs that only expose the unsuffixed name.
27
+ */
28
+ export declare const PYTHON_CANDIDATES: readonly ["python3", "python"];
29
+ export interface ResolveOptions {
30
+ /** `config.pythonCommand`, if the caller set one. Wins outright. */
31
+ configured?: string;
32
+ /** The venv interpreter, when one was found next to the CLI. Wins over probing. */
33
+ venvPython?: string | null;
34
+ /** Process env, injected for testability. */
35
+ env?: NodeJS.ProcessEnv;
36
+ /** Returns true if the command is runnable. Injected so tests never spawn. */
37
+ exists?: (command: string) => boolean;
38
+ }
39
+ /**
40
+ * Resolve the interpreter, most specific first:
41
+ *
42
+ * 1. an explicitly configured `pythonCommand`
43
+ * 2. `CAPTCHAKRAKEN_PYTHON`
44
+ * 3. the venv interpreter beside the bundled CLI
45
+ * 4. the first of `python3`, `python` that actually exists
46
+ *
47
+ * Falls back to `python3` rather than `python` when nothing can be probed: on
48
+ * the platform where the two differ, `python3` is the one that is present.
49
+ */
50
+ export declare function resolvePythonCommand(options?: ResolveOptions): string;
@@ -0,0 +1,60 @@
1
+ "use strict";
2
+ /**
3
+ * Which interpreter runs the bundled CaptchaKraken CLI.
4
+ *
5
+ * This used to be `pythonCommand = 'python'`, inline in solver.ts. On
6
+ * Debian/Ubuntu — the most common Linux deployment target, and what CI runs —
7
+ * there is no `python` on PATH at all, only `python3`. So every JS solve died
8
+ * with `/bin/sh: 1: python: not found` before it ever reached the model, unless
9
+ * a `.venv` happened to exist next to the package. The type doc claimed the
10
+ * default was "'python' or 'python3'"; the code never tried `python3`.
11
+ *
12
+ * That is a straight violation of the rule that the Python and JS clients
13
+ * behave identically: the Python client obviously ran, so the divergence looked
14
+ * like a model or endpoint problem rather than a missing binary.
15
+ *
16
+ * Kept in its own module, with the probe injected, so the resolution order is
17
+ * testable without spawning anything.
18
+ */
19
+ Object.defineProperty(exports, "__esModule", { value: true });
20
+ exports.PYTHON_CANDIDATES = exports.PYTHON_ENV_VAR = void 0;
21
+ exports.resolvePythonCommand = resolvePythonCommand;
22
+ /** Env var that overrides everything below it. */
23
+ exports.PYTHON_ENV_VAR = 'CAPTCHA_KRAKEN_PYTHON';
24
+ /**
25
+ * Candidates tried in order when nothing is configured.
26
+ *
27
+ * `python3` FIRST. On Debian-family systems `python` is absent; where both
28
+ * exist they are the same interpreter in practice, and PEP 394 makes `python3`
29
+ * the portable spelling. `python` stays as the fallback for Windows and for
30
+ * venvs that only expose the unsuffixed name.
31
+ */
32
+ exports.PYTHON_CANDIDATES = ['python3', 'python'];
33
+ /**
34
+ * Resolve the interpreter, most specific first:
35
+ *
36
+ * 1. an explicitly configured `pythonCommand`
37
+ * 2. `CAPTCHAKRAKEN_PYTHON`
38
+ * 3. the venv interpreter beside the bundled CLI
39
+ * 4. the first of `python3`, `python` that actually exists
40
+ *
41
+ * Falls back to `python3` rather than `python` when nothing can be probed: on
42
+ * the platform where the two differ, `python3` is the one that is present.
43
+ */
44
+ function resolvePythonCommand(options = {}) {
45
+ const { configured, venvPython, env = process.env, exists } = options;
46
+ if (configured)
47
+ return configured;
48
+ const fromEnv = env[exports.PYTHON_ENV_VAR];
49
+ if (fromEnv)
50
+ return fromEnv;
51
+ if (venvPython)
52
+ return venvPython;
53
+ if (exists) {
54
+ for (const candidate of exports.PYTHON_CANDIDATES) {
55
+ if (exists(candidate))
56
+ return candidate;
57
+ }
58
+ }
59
+ return exports.PYTHON_CANDIDATES[0];
60
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * The algebra behind a puzzle-piece slider's closed loop.
3
+ *
4
+ * Split out of solver.ts because it is the one part of this feature that is
5
+ * pure arithmetic, duplicated across the two ports, and impossible to notice
6
+ * drifting: a wrong ratio here does not throw, it just releases the piece in the
7
+ * wrong place, and the puzzle fails the way an unsolved puzzle fails. Keeping it
8
+ * dependency-free means it can be unit-tested with `node --test` alongside
9
+ * limits.ts, against the same cases as
10
+ * `PageSolver._solve_slide_geometry` in the Python port.
11
+ */
12
+ /**
13
+ * Piece width and handle-to-piece travel ratio, from probe measurements.
14
+ *
15
+ * Each measurement is `[handle offset, width of what changed]`, where the width
16
+ * spans the piece's ORIGINAL left edge to its CURRENT right edge — so
17
+ * `width = pieceWidth + ratio x offset`. Two of them determine both unknowns.
18
+ *
19
+ * With only one usable measurement the system is underdetermined, so `ratio` is
20
+ * ASSUMED to be 1 — true of every vendor observed, and the assumption is stated
21
+ * here rather than buried as a default. A ratio solved from implausible
22
+ * measurements (a redraw, a piece that hit the wall between probes) is rejected
23
+ * the same way: better a 1:1 guess that overshoots and gets corrected than a
24
+ * ratio of 0.02 that sends the handle off the track and, under camoufox, into a
25
+ * mouse move that never returns.
26
+ *
27
+ * `pieceWidth: null` means "could not measure" — the caller falls back to
28
+ * steering by the handle's own travel.
29
+ */
30
+ export declare function solveSlideGeometry(widths: Array<[number, number]>, widgetWidth: number): {
31
+ pieceWidth: number | null;
32
+ ratio: number;
33
+ };
@@ -0,0 +1,64 @@
1
+ "use strict";
2
+ /**
3
+ * The algebra behind a puzzle-piece slider's closed loop.
4
+ *
5
+ * Split out of solver.ts because it is the one part of this feature that is
6
+ * pure arithmetic, duplicated across the two ports, and impossible to notice
7
+ * drifting: a wrong ratio here does not throw, it just releases the piece in the
8
+ * wrong place, and the puzzle fails the way an unsolved puzzle fails. Keeping it
9
+ * dependency-free means it can be unit-tested with `node --test` alongside
10
+ * limits.ts, against the same cases as
11
+ * `PageSolver._solve_slide_geometry` in the Python port.
12
+ */
13
+ Object.defineProperty(exports, "__esModule", { value: true });
14
+ exports.solveSlideGeometry = solveSlideGeometry;
15
+ /**
16
+ * Piece width and handle-to-piece travel ratio, from probe measurements.
17
+ *
18
+ * Each measurement is `[handle offset, width of what changed]`, where the width
19
+ * spans the piece's ORIGINAL left edge to its CURRENT right edge — so
20
+ * `width = pieceWidth + ratio x offset`. Two of them determine both unknowns.
21
+ *
22
+ * With only one usable measurement the system is underdetermined, so `ratio` is
23
+ * ASSUMED to be 1 — true of every vendor observed, and the assumption is stated
24
+ * here rather than buried as a default. A ratio solved from implausible
25
+ * measurements (a redraw, a piece that hit the wall between probes) is rejected
26
+ * the same way: better a 1:1 guess that overshoots and gets corrected than a
27
+ * ratio of 0.02 that sends the handle off the track and, under camoufox, into a
28
+ * mouse move that never returns.
29
+ *
30
+ * `pieceWidth: null` means "could not measure" — the caller falls back to
31
+ * steering by the handle's own travel.
32
+ */
33
+ function solveSlideGeometry(widths, widgetWidth) {
34
+ if (!widths.length)
35
+ return { pieceWidth: null, ratio: 1 };
36
+ let ratio = 1;
37
+ let pieceWidth = null;
38
+ if (widths.length >= 2) {
39
+ const [o1, w1] = widths[0];
40
+ const [o2, w2] = widths[widths.length - 1];
41
+ if (o2 !== o1) {
42
+ const candidate = (w2 - w1) / (o2 - o1);
43
+ if (candidate >= MIN_RATIO && candidate <= MAX_RATIO) {
44
+ ratio = candidate;
45
+ pieceWidth = w1 - ratio * o1;
46
+ }
47
+ }
48
+ }
49
+ if (pieceWidth === null) {
50
+ const [o, w] = widths[widths.length - 1];
51
+ pieceWidth = w - ratio * o;
52
+ }
53
+ // A piece narrower than a few pixels, or wider than half the widget, is a
54
+ // measurement of something else — a wholesale redraw, a fade, a spinner.
55
+ if (pieceWidth < MIN_PIECE_PX || pieceWidth > widgetWidth * MAX_PIECE_FRACTION) {
56
+ return { pieceWidth: null, ratio };
57
+ }
58
+ return { pieceWidth, ratio };
59
+ }
60
+ // Keep these identical to the literals in page_solver.py::_solve_slide_geometry.
61
+ const MIN_RATIO = 0.2;
62
+ const MAX_RATIO = 3.0;
63
+ const MIN_PIECE_PX = 3.0;
64
+ const MAX_PIECE_FRACTION = 0.6;