hypruse 0.1.0__py3-none-any.whl

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.
hypruse/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """hypruse — computer use for Hyprland over MCP."""
2
+
3
+ __version__ = "0.1.0"
hypruse/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from hypruse.server import main
2
+
3
+ main()
hypruse/hyprctl.py ADDED
@@ -0,0 +1,125 @@
1
+ """Thin layer over Hyprland's hyprctl IPC.
2
+
3
+ Everything hypruse knows about the desktop comes through here, and every
4
+ workspace/window action goes back out through dispatch(). It shells out to
5
+ the hyprctl binary rather than opening the .socket directly so behaviour
6
+ always matches what the user's own shell would do.
7
+
8
+ Coordinates everywhere in hypruse are Hyprland's global *logical* layout
9
+ coordinates — the same space `hyprctl cursorpos`, client `at`, and
10
+ `dispatch movecursor` use.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import shutil
17
+ import subprocess
18
+ from typing import Any
19
+
20
+
21
+ class HyprctlError(RuntimeError):
22
+ """hyprctl failed, returned an error, or is unreachable."""
23
+
24
+
25
+ def _run(*args: str) -> str:
26
+ if shutil.which("hyprctl") is None:
27
+ raise HyprctlError("hyprctl not found — hypruse needs a running Hyprland session")
28
+ try:
29
+ proc = subprocess.run(
30
+ ["hyprctl", *args], capture_output=True, text=True, timeout=5
31
+ )
32
+ except subprocess.TimeoutExpired as exc:
33
+ raise HyprctlError(f"hyprctl {' '.join(args)} timed out") from exc
34
+ out = proc.stdout.strip()
35
+ if proc.returncode != 0:
36
+ raise HyprctlError(f"hyprctl {' '.join(args)}: {proc.stderr.strip() or out}")
37
+ return out
38
+
39
+
40
+ def query(command: str) -> Any:
41
+ """Run a JSON query: monitors, workspaces, clients, activewindow, cursorpos, ..."""
42
+ out = _run("-j", command)
43
+ try:
44
+ return json.loads(out)
45
+ except json.JSONDecodeError as exc:
46
+ raise HyprctlError(f"unparseable hyprctl -j {command} output: {out[:200]!r}") from exc
47
+
48
+
49
+ def dispatch(name: str, *args: str) -> None:
50
+ """Run a dispatcher; Hyprland answers 'ok' on success, an error string otherwise."""
51
+ out = _run("dispatch", name, *args)
52
+ if out != "ok":
53
+ raise HyprctlError(f"dispatch {name} {' '.join(args)}: {out}")
54
+
55
+
56
+ def cursor_pos() -> tuple[int, int]:
57
+ pos = query("cursorpos")
58
+ return int(pos["x"]), int(pos["y"])
59
+
60
+
61
+ def _window(c: dict[str, Any]) -> dict[str, Any]:
62
+ """Trim a hyprctl client to what a model needs to reason and act."""
63
+ win: dict[str, Any] = {
64
+ "address": c["address"],
65
+ "workspace": c.get("workspace", {}).get("id"),
66
+ "class": c.get("class", ""),
67
+ "title": c.get("title", ""),
68
+ "at": c.get("at"),
69
+ "size": c.get("size"),
70
+ "floating": c.get("floating", False),
71
+ "pid": c.get("pid"),
72
+ }
73
+ # int enum since Hyprland 0.42 (0 none / 1 maximized / 2 fullscreen), bool before
74
+ if c.get("fullscreen"):
75
+ win["fullscreen"] = True
76
+ if c.get("hidden"):
77
+ win["hidden"] = True
78
+ return win
79
+
80
+
81
+ def snapshot_from(
82
+ monitors: list[dict[str, Any]],
83
+ workspaces: list[dict[str, Any]],
84
+ clients: list[dict[str, Any]],
85
+ active_window: dict[str, Any] | None,
86
+ cursor: tuple[int, int] | None,
87
+ ) -> dict[str, Any]:
88
+ """Pure assembly of the desktop state — separated from IPC for testability."""
89
+ visible = {m.get("activeWorkspace", {}).get("id") for m in monitors}
90
+ return {
91
+ "monitors": [
92
+ {
93
+ "name": m["name"],
94
+ "geometry": [m["x"], m["y"], m["width"], m["height"]],
95
+ "scale": m.get("scale", 1.0),
96
+ "focused": m.get("focused", False),
97
+ "active_workspace": m.get("activeWorkspace", {}).get("id"),
98
+ }
99
+ for m in monitors
100
+ ],
101
+ "workspaces": [
102
+ {
103
+ "id": w["id"],
104
+ "name": w.get("name", ""),
105
+ "monitor": w.get("monitor", ""),
106
+ "windows": w.get("windows", 0),
107
+ "visible": w["id"] in visible,
108
+ }
109
+ for w in sorted(workspaces, key=lambda w: w["id"])
110
+ ],
111
+ "windows": [_window(c) for c in clients if c.get("mapped", True)],
112
+ "active_window": (active_window or {}).get("address"),
113
+ "cursor": list(cursor) if cursor else None,
114
+ }
115
+
116
+
117
+ def snapshot() -> dict[str, Any]:
118
+ """Compact, token-lean view of the whole desktop."""
119
+ return snapshot_from(
120
+ query("monitors"),
121
+ query("workspaces"),
122
+ query("clients"),
123
+ query("activewindow") or None,
124
+ cursor_pos(),
125
+ )
hypruse/input.py ADDED
@@ -0,0 +1,199 @@
1
+ """Synthetic input.
2
+
3
+ Pointer: positioning goes through `hyprctl dispatch movecursor` (global
4
+ logical coordinates, authoritative on any monitor layout); only button and
5
+ axis events go through the virtual-pointer wire client. This split
6
+ sidesteps the known multi-monitor mapping bugs of absolute virtual-pointer
7
+ motion (hyprwm/Hyprland#6749).
8
+
9
+ Keyboard: wtype, which uploads its own XKB keymap over
10
+ zwp_virtual_keyboard_v1 — text lands layout-correct including unicode,
11
+ with no uinput scancode guessing.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import shutil
17
+ import subprocess
18
+ import time
19
+ from collections.abc import Callable
20
+ from typing import Any
21
+
22
+ from hypruse import hyprctl
23
+ from hypruse.wire import BUTTONS, PRESSED, RELEASED, VirtualPointer, WireError
24
+
25
+
26
+ class InputError(RuntimeError):
27
+ """Invalid input request or missing input backend."""
28
+
29
+
30
+ MODS = {
31
+ "ctrl": "ctrl",
32
+ "control": "ctrl",
33
+ "shift": "shift",
34
+ "alt": "alt",
35
+ "super": "logo",
36
+ "meta": "logo",
37
+ "win": "logo",
38
+ "cmd": "logo",
39
+ "altgr": "altgr",
40
+ }
41
+
42
+ KEY_ALIASES = {
43
+ "enter": "Return",
44
+ "return": "Return",
45
+ "esc": "Escape",
46
+ "escape": "Escape",
47
+ "tab": "Tab",
48
+ "space": "space",
49
+ "backspace": "BackSpace",
50
+ "delete": "Delete",
51
+ "del": "Delete",
52
+ "insert": "Insert",
53
+ "home": "Home",
54
+ "end": "End",
55
+ "pgup": "Page_Up",
56
+ "pageup": "Page_Up",
57
+ "pgdn": "Page_Down",
58
+ "pagedown": "Page_Down",
59
+ "up": "Up",
60
+ "down": "Down",
61
+ "left": "Left",
62
+ "right": "Right",
63
+ }
64
+
65
+
66
+ def parse_combo(combo: str) -> tuple[list[str], str | None]:
67
+ """'ctrl+shift+t' → (['ctrl','shift'], 't'); 'super' alone is a bare-mod tap."""
68
+ parts = [p.strip() for p in combo.split("+") if p.strip()]
69
+ if not parts:
70
+ raise InputError("empty key combo")
71
+ mods: list[str] = []
72
+ key: str | None = None
73
+ for i, part in enumerate(parts):
74
+ lower = part.lower()
75
+ last = i == len(parts) - 1
76
+ if lower in MODS:
77
+ mods.append(MODS[lower])
78
+ if last:
79
+ key = None
80
+ elif not last:
81
+ raise InputError(f"unknown modifier {part!r} in {combo!r}")
82
+ elif lower in KEY_ALIASES:
83
+ key = KEY_ALIASES[lower]
84
+ elif len(part) == 1:
85
+ key = part
86
+ elif lower.startswith("f") and lower[1:].isdigit():
87
+ key = part.upper()
88
+ else:
89
+ key = part # assume a literal XKB keysym name (case-sensitive)
90
+ return mods, key
91
+
92
+
93
+ def combo_to_wtype_args(mods: list[str], key: str | None) -> list[str]:
94
+ args: list[str] = []
95
+ for m in mods:
96
+ args += ["-M", m]
97
+ if key:
98
+ args += ["-k", key]
99
+ for m in reversed(mods):
100
+ args += ["-m", m]
101
+ if not args:
102
+ raise InputError("combo resolved to nothing")
103
+ return args
104
+
105
+
106
+ def _wtype(args: list[str], stdin: str | None = None) -> None:
107
+ if shutil.which("wtype") is None:
108
+ raise InputError("wtype not found — install wtype for keyboard input")
109
+ proc = subprocess.run(
110
+ ["wtype", *args], input=stdin, text=True, capture_output=True, timeout=15
111
+ )
112
+ if proc.returncode != 0:
113
+ raise InputError(f"wtype failed: {proc.stderr.strip()}")
114
+
115
+
116
+ def type_text(text: str) -> None:
117
+ if text:
118
+ _wtype(["-"], stdin=text) # '-' reads stdin: safe for any content
119
+
120
+
121
+ def key_combo(combo: str) -> None:
122
+ mods, key = parse_combo(combo)
123
+ _wtype(combo_to_wtype_args(mods, key))
124
+
125
+
126
+ # --- pointer ----------------------------------------------------------------
127
+
128
+ _vp: VirtualPointer | None = None
129
+
130
+
131
+ def _with_pointer(fn: Callable[[VirtualPointer], Any]) -> Any:
132
+ """Run fn with the shared virtual pointer, reconnecting once if stale."""
133
+ global _vp
134
+ for attempt in (0, 1):
135
+ if _vp is None:
136
+ _vp = VirtualPointer()
137
+ try:
138
+ return fn(_vp)
139
+ except WireError:
140
+ _vp = None
141
+ if attempt:
142
+ raise
143
+
144
+
145
+ def _check_button(button: str) -> None:
146
+ if button not in BUTTONS:
147
+ raise InputError(f"unknown button {button!r}; one of {sorted(BUTTONS)}")
148
+
149
+
150
+ def _check_xy(x: float | None, y: float | None) -> bool:
151
+ if (x is None) != (y is None):
152
+ raise InputError("give both x and y, or neither")
153
+ return x is not None
154
+
155
+
156
+ def move(x: float, y: float) -> None:
157
+ hyprctl.dispatch("movecursor", str(int(round(x))), str(int(round(y))))
158
+
159
+
160
+ def click(
161
+ x: float | None = None,
162
+ y: float | None = None,
163
+ button: str = "left",
164
+ double: bool = False,
165
+ ) -> None:
166
+ _check_button(button)
167
+ if _check_xy(x, y):
168
+ move(x, y) # type: ignore[arg-type]
169
+ time.sleep(0.02)
170
+ _with_pointer(lambda p: p.click(button, double=double))
171
+
172
+
173
+ def drag(x1: float, y1: float, x2: float, y2: float, button: str = "left") -> None:
174
+ _check_button(button)
175
+ move(x1, y1)
176
+ time.sleep(0.03)
177
+
178
+ def run(p: VirtualPointer) -> None:
179
+ p.button(button, PRESSED)
180
+ try:
181
+ steps = 12
182
+ for i in range(1, steps + 1):
183
+ move(x1 + (x2 - x1) * i / steps, y1 + (y2 - y1) * i / steps)
184
+ time.sleep(0.015)
185
+ finally:
186
+ p.button(button, RELEASED)
187
+
188
+ _with_pointer(run)
189
+
190
+
191
+ def scroll(
192
+ dy: float = 0.0, dx: float = 0.0, x: float | None = None, y: float | None = None
193
+ ) -> None:
194
+ if not dy and not dx:
195
+ raise InputError("scroll needs a non-zero dy or dx")
196
+ if _check_xy(x, y):
197
+ move(x, y) # type: ignore[arg-type]
198
+ time.sleep(0.02)
199
+ _with_pointer(lambda p: p.scroll(dy=dy, dx=dx))
hypruse/safety.py ADDED
@@ -0,0 +1,83 @@
1
+ """Activity beacon and kill-switch support.
2
+
3
+ The seat — cursor, keyboard focus — is shared between the agent and the
4
+ human at the desk. hypruse therefore keeps a runtime beacon at
5
+ $XDG_RUNTIME_DIR/hypruse/state.json:
6
+
7
+ {"pid": 1234, "started": 1789...,
8
+ "last_action": "pointer:click", "last_ts": 1789...}
9
+
10
+ written at startup, updated on every acting tool call, removed on clean
11
+ shutdown. Anything can watch it — the shipped Waybar module (waybar/)
12
+ shows a red indicator while an agent has hands on the desktop, and its
13
+ click action (or a Hyprland keybind) runs `pkill -f hypruse`: the
14
+ process dies mid-action at worst, never holding a button down, because
15
+ button press/release pairs are sent inside one tool call.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import atexit
21
+ import contextlib
22
+ import json
23
+ import os
24
+ import signal
25
+ import sys
26
+ import time
27
+ from pathlib import Path
28
+
29
+ _state_path: Path | None = None
30
+ _started: float = 0.0
31
+
32
+
33
+ def state_path() -> Path:
34
+ base = os.environ.get("XDG_RUNTIME_DIR", "/tmp")
35
+ d = Path(base) / "hypruse"
36
+ d.mkdir(parents=True, exist_ok=True)
37
+ return d / "state.json"
38
+
39
+
40
+ def _write(payload: dict) -> None:
41
+ if _state_path is None:
42
+ return
43
+ tmp = _state_path.with_suffix(".tmp")
44
+ tmp.write_text(json.dumps(payload))
45
+ tmp.replace(_state_path)
46
+
47
+
48
+ def init() -> None:
49
+ """Start the beacon; safe to call once at server startup."""
50
+ global _state_path, _started
51
+ _state_path = state_path()
52
+ _started = time.time()
53
+ _write({"pid": os.getpid(), "started": _started, "last_action": "", "last_ts": 0})
54
+ atexit.register(shutdown)
55
+ for sig in (signal.SIGTERM, signal.SIGINT):
56
+ signal.signal(sig, _die)
57
+
58
+
59
+ def touch(action: str) -> None:
60
+ """Record an acting tool call (no-op if init() was never called)."""
61
+ if _state_path is None:
62
+ return
63
+ _write(
64
+ {
65
+ "pid": os.getpid(),
66
+ "started": _started,
67
+ "last_action": action,
68
+ "last_ts": time.time(),
69
+ }
70
+ )
71
+
72
+
73
+ def shutdown() -> None:
74
+ global _state_path
75
+ if _state_path is not None:
76
+ with contextlib.suppress(OSError):
77
+ _state_path.unlink(missing_ok=True)
78
+ _state_path = None
79
+
80
+
81
+ def _die(signum: int, _frame: object) -> None:
82
+ shutdown()
83
+ sys.exit(128 + signum)
hypruse/screenshot.py ADDED
@@ -0,0 +1,203 @@
1
+ """Screenshots via grim (wlroots screencopy).
2
+
3
+ grim captures in *pixel* space while hypruse coordinates are global
4
+ *logical* pixels; on monitors with fractional scaling the two differ.
5
+ Every capture therefore returns metadata with its origin and scale so an
6
+ image pixel maps back to a clickable point:
7
+
8
+ global_x = geometry[0] + pixel_x / scale
9
+ global_y = geometry[1] + pixel_y / scale
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import re
15
+ import shutil
16
+ import subprocess
17
+ from typing import Any
18
+
19
+ from hypruse import hyprctl
20
+
21
+
22
+ class ScreenshotError(RuntimeError):
23
+ """grim failed or the capture target does not exist."""
24
+
25
+
26
+ _REGION = re.compile(r"^\s*(-?\d+)\s*,\s*(-?\d+)\s*[, ]\s*(\d+)\s*x\s*(\d+)\s*$")
27
+
28
+
29
+ def parse_region(region: str) -> tuple[int, int, int, int]:
30
+ """Accepts 'x,y,WxH' or 'x,y WxH' (grim's own format)."""
31
+ m = _REGION.match(region)
32
+ if not m:
33
+ raise ScreenshotError(f"bad region {region!r}, expected 'x,y,WxH'")
34
+ x, y, w, h = map(int, m.groups())
35
+ if w <= 0 or h <= 0:
36
+ raise ScreenshotError(f"bad region {region!r}: empty size")
37
+ return x, y, w, h
38
+
39
+
40
+ def _grim(args: list[str]) -> bytes:
41
+ if shutil.which("grim") is None:
42
+ raise ScreenshotError("grim not found — install grim for screenshots")
43
+ proc = subprocess.run(["grim", *args, "-"], capture_output=True, timeout=10)
44
+ if proc.returncode != 0 or not proc.stdout:
45
+ raise ScreenshotError(f"grim failed: {proc.stderr.decode(errors='replace').strip()}")
46
+ return proc.stdout
47
+
48
+
49
+ def _fit_ladder(start_scale: float) -> list[tuple[str, int | None, float]]:
50
+ """(format, jpeg-quality, scale) attempts from a starting scale, best
51
+ fidelity first. Full-resolution JPEG beats half-resolution PNG for
52
+ reading UI text, so format degrades before resolution does.
53
+ """
54
+ s = start_scale
55
+ return [
56
+ ("png", None, s),
57
+ ("jpeg", 85, s),
58
+ ("jpeg", 85, s * 0.75),
59
+ ("jpeg", 80, s * 0.5),
60
+ ]
61
+
62
+
63
+ def _grab_fitting(
64
+ base_args: list[str], start_scale: float, max_bytes: int | None
65
+ ) -> tuple[bytes, str, float]:
66
+ """Capture within a byte budget; returns (data, format, applied_scale)."""
67
+ for fmt, quality, s in _fit_ladder(start_scale):
68
+ args = list(base_args)
69
+ if abs(s - 1.0) > 1e-6:
70
+ args = ["-s", f"{s:g}", *args]
71
+ if fmt == "jpeg":
72
+ args = ["-t", "jpeg", "-q", str(quality), *args]
73
+ data = _grim(args)
74
+ if max_bytes is None or len(data) <= max_bytes:
75
+ return data, fmt, s
76
+ raise ScreenshotError(
77
+ f"even a downscaled JPEG exceeds the {max_bytes}-byte result budget — "
78
+ "capture a window or region instead of the whole monitor"
79
+ )
80
+
81
+
82
+ _SOF_MARKERS = {0xC0, 0xC1, 0xC2, 0xC3, 0xC5, 0xC6, 0xC7, 0xC9, 0xCA, 0xCB, 0xCD, 0xCE, 0xCF}
83
+
84
+
85
+ def image_size(data: bytes) -> tuple[int, int]:
86
+ """(width, height) of PNG or JPEG bytes, without a decode library.
87
+
88
+ The model reasons about the image it is actually shown, so hypruse
89
+ reports the true output dimensions rather than assuming the geometry
90
+ it asked grim for.
91
+ """
92
+ if data[:8] == b"\x89PNG\r\n\x1a\n":
93
+ w = int.from_bytes(data[16:20], "big")
94
+ h = int.from_bytes(data[20:24], "big")
95
+ return w, h
96
+ if data[:2] == b"\xff\xd8": # JPEG: find a Start-Of-Frame segment
97
+ i, n = 2, len(data)
98
+ while i + 9 < n:
99
+ if data[i] != 0xFF:
100
+ i += 1
101
+ continue
102
+ marker = data[i + 1]
103
+ if marker == 0xD8 or marker == 0xD9 or 0xD0 <= marker <= 0xD7:
104
+ i += 2
105
+ continue
106
+ seg = int.from_bytes(data[i + 2 : i + 4], "big")
107
+ if marker in _SOF_MARKERS:
108
+ h = int.from_bytes(data[i + 5 : i + 7], "big")
109
+ w = int.from_bytes(data[i + 7 : i + 9], "big")
110
+ return w, h
111
+ i += 2 + seg
112
+ raise ScreenshotError("could not read image dimensions")
113
+
114
+
115
+ def _cap_scale(physical_long_edge: float, max_edge: int | None) -> float:
116
+ """Downscale factor so the output's long edge fits max_edge, capped at 1.0.
117
+
118
+ Prevents the API from silently downscaling a too-large image *under* the
119
+ model, which would desync the reported scale from what the model sees.
120
+ """
121
+ if not max_edge or physical_long_edge <= max_edge:
122
+ return 1.0
123
+ return max_edge / physical_long_edge
124
+
125
+
126
+ def _scale_at(x: int, y: int, monitors: list[dict[str, Any]]) -> float:
127
+ for m in monitors:
128
+ if m["x"] <= x < m["x"] + m["width"] and m["y"] <= y < m["y"] + m["height"]:
129
+ return float(m.get("scale", 1.0))
130
+ return 1.0
131
+
132
+
133
+ def _find_window(window: str, clients: list[dict[str, Any]], active: str | None) -> dict[str, Any]:
134
+ target = active if window == "active" else window
135
+ if not target:
136
+ raise ScreenshotError("no active window")
137
+ for c in clients:
138
+ if c.get("address") == target:
139
+ return c
140
+ raise ScreenshotError(
141
+ f"window {target!r} not found — call desktop() for current addresses"
142
+ )
143
+
144
+
145
+ def capture(
146
+ window: str = "",
147
+ region: str = "",
148
+ scale: float = 0.0,
149
+ max_bytes: int | None = None,
150
+ max_edge: int | None = None,
151
+ ) -> tuple[bytes, dict[str, Any]]:
152
+ """Returns (image_bytes, metadata). Exactly one of window/region, or
153
+ neither for the focused monitor. `scale` (0 = auto) forces a capture
154
+ scale; `max_edge` caps the output's long edge (so the API never
155
+ downscales it under the model); `max_bytes` fits a transport budget by
156
+ degrading format before resolution. Any applied downscale is folded into
157
+ the metadata `scale`, so the pixel→global mapping stays exact."""
158
+ if window and region:
159
+ raise ScreenshotError("pass window OR region, not both")
160
+ if scale and not 0.1 <= scale <= 1.0:
161
+ raise ScreenshotError(f"scale {scale} out of range (0.1–1.0, or 0 for auto)")
162
+
163
+ monitors = hyprctl.query("monitors")
164
+
165
+ if region:
166
+ x, y, w, h = parse_region(region)
167
+ base = ["-g", f"{x},{y} {w}x{h}"]
168
+ meta: dict[str, Any] = {"target": "region", "geometry": [x, y, w, h]}
169
+ base_scale = _scale_at(x, y, monitors)
170
+ physical_long = max(w, h) * base_scale
171
+ elif window:
172
+ active = (hyprctl.query("activewindow") or {}).get("address")
173
+ c = _find_window(window, hyprctl.query("clients"), active)
174
+ (x, y), (w, h) = c["at"], c["size"]
175
+ base = ["-g", f"{x},{y} {w}x{h}"]
176
+ meta = {
177
+ "target": "window",
178
+ "window": c["address"],
179
+ "class": c.get("class", ""),
180
+ "geometry": [x, y, w, h],
181
+ }
182
+ base_scale = _scale_at(x, y, monitors)
183
+ physical_long = max(w, h) * base_scale
184
+ else:
185
+ m = next((m for m in monitors if m.get("focused")), monitors[0])
186
+ base = ["-o", m["name"]]
187
+ meta = {
188
+ "target": "monitor",
189
+ "monitor": m["name"],
190
+ "geometry": [m["x"], m["y"], m["width"], m["height"]],
191
+ }
192
+ base_scale = float(m.get("scale", 1.0))
193
+ physical_long = max(m["width"], m["height"])
194
+
195
+ # explicit scale wins; otherwise fit the long edge to keep the mapping honest
196
+ start_scale = scale or _cap_scale(physical_long, max_edge)
197
+ data, fmt, applied = _grab_fitting(base, start_scale, max_bytes)
198
+ iw, ih = image_size(data)
199
+ meta["image"] = [iw, ih]
200
+ meta["format"] = fmt
201
+ meta["scale"] = round(base_scale * applied, 6)
202
+ meta["coords"] = "click a target at global = geometry[:2] + image_pixel / scale"
203
+ return data, meta