@mengruo/dsh-vision-toolkit 0.0.1

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 (216) hide show
  1. package/LICENSE +21 -0
  2. package/README.i18n.yaml +6 -0
  3. package/README.md +356 -0
  4. package/README.zh.md +354 -0
  5. package/assets/community-group-qr.png +0 -0
  6. package/assets/dsh-conversation-artifact.png +0 -0
  7. package/assets/dsh-conversation-image-qa-top.png +0 -0
  8. package/assets/dsh-conversation-image-qa.png +0 -0
  9. package/assets/dsh-conversation-pixel-diff.png +0 -0
  10. package/assets/dsh-conversation-screenshot-debugging-top.png +0 -0
  11. package/assets/dsh-conversation-screenshot-debugging.png +0 -0
  12. package/assets/dsh-conversation-tool-call.png +0 -0
  13. package/assets/dsh-conversation-vision-trace.png +0 -0
  14. package/assets/dsh-view-example.png +0 -0
  15. package/assets/hero-v2.png +0 -0
  16. package/assets/logo_aihubmix.png +0 -0
  17. package/assets/python-bootstrap.json +48 -0
  18. package/assets/skill/SKILL.md +329 -0
  19. package/assets/skill/UPSTREAM.json +71 -0
  20. package/assets/skill/references/gui.md +88 -0
  21. package/assets/skill/references/long-screenshot-ocr.md +77 -0
  22. package/assets/skill/references/restore-graphic.md +84 -0
  23. package/assets/skill/references/restore-structure.md +45 -0
  24. package/assets/skill/references/restore-ui.md +202 -0
  25. package/assets/social-preview.png +0 -0
  26. package/assets/upstream/README.md +18 -0
  27. package/assets/upstream/focus-hint-comparison-1.webp +0 -0
  28. package/assets/upstream/focus-hint-comparison-2.webp +0 -0
  29. package/assets/upstream/infographic-reference.webp +0 -0
  30. package/assets/upstream/infographic-result.webp +0 -0
  31. package/assets/upstream/ui-fast-restore-reference.webp +0 -0
  32. package/assets/upstream/ui-fast-restore-result.webp +0 -0
  33. package/assets/upstream/ui-result.webp +0 -0
  34. package/assets/upstream/ui-sketch.webp +0 -0
  35. package/assets/vision-model-test.png +0 -0
  36. package/assets/vision-settings.png +0 -0
  37. package/assets/wechat-reward.png +0 -0
  38. package/cordis.patch.yml +6 -0
  39. package/docs/aihubmix-gemini-vision.i18n.yaml +6 -0
  40. package/docs/aihubmix-gemini-vision.md +138 -0
  41. package/docs/aihubmix-gemini-vision.zh.md +138 -0
  42. package/docs/assets/aihubmix-api-keys.png +0 -0
  43. package/docs/assets/aihubmix-create-key.png +0 -0
  44. package/docs/assets/aihubmix-free-vision-model.png +0 -0
  45. package/docs/assets/aihubmix-home.png +0 -0
  46. package/docs/assets/aihubmix-sign-up.png +0 -0
  47. package/docs/assets/groq-console-home.png +0 -0
  48. package/docs/assets/groq-console-keys-login.png +0 -0
  49. package/docs/assets/groq-docs-quickstart.png +0 -0
  50. package/docs/assets/groq-docs-qwen3.6.png +0 -0
  51. package/docs/assets/groq-docs-vision.png +0 -0
  52. package/docs/assets/vision-settings.png +0 -0
  53. package/docs/dsh-desktop-install.md +67 -0
  54. package/docs/dsh-desktop-install.zh.md +67 -0
  55. package/docs/groq-qwen3.6-vision.i18n.yaml +6 -0
  56. package/docs/groq-qwen3.6-vision.md +236 -0
  57. package/docs/groq-qwen3.6-vision.zh.md +236 -0
  58. package/docs/python-runtime.i18n.yaml +6 -0
  59. package/docs/python-runtime.md +89 -0
  60. package/docs/python-runtime.zh.md +89 -0
  61. package/docs/requirements-traceability/README.i18n.yaml +6 -0
  62. package/docs/requirements-traceability/README.md +75 -0
  63. package/docs/requirements-traceability/README.zh.md +75 -0
  64. package/examples/ui-restoration/README.i18n.yaml +6 -0
  65. package/examples/ui-restoration/README.md +70 -0
  66. package/examples/ui-restoration/README.zh.md +70 -0
  67. package/examples/ui-restoration/assets/final-heatmap.png +0 -0
  68. package/examples/ui-restoration/assets/final-report.json +83 -0
  69. package/examples/ui-restoration/assets/implementation.png +0 -0
  70. package/examples/ui-restoration/assets/initial-heatmap.png +0 -0
  71. package/examples/ui-restoration/assets/initial-report.json +83 -0
  72. package/examples/ui-restoration/assets/initial.png +0 -0
  73. package/examples/ui-restoration/assets/metrics.json +12 -0
  74. package/examples/ui-restoration/assets/reference.png +0 -0
  75. package/examples/ui-restoration/implementation.html +94 -0
  76. package/examples/ui-restoration/initial.html +57 -0
  77. package/lib/artifact-access.js +369 -0
  78. package/lib/artifact-access.js.map +1 -0
  79. package/lib/artifacts.js +56 -0
  80. package/lib/artifacts.js.map +1 -0
  81. package/lib/client.js +2099 -0
  82. package/lib/client.js.map +1 -0
  83. package/lib/config.js +172 -0
  84. package/lib/config.js.map +1 -0
  85. package/lib/defaults.js +6 -0
  86. package/lib/defaults.js.map +1 -0
  87. package/lib/errors.js +56 -0
  88. package/lib/errors.js.map +1 -0
  89. package/lib/evidence-cache.js +350 -0
  90. package/lib/evidence-cache.js.map +1 -0
  91. package/lib/exposure.js +247 -0
  92. package/lib/exposure.js.map +1 -0
  93. package/lib/image-input-variants.js +882 -0
  94. package/lib/image-input-variants.js.map +1 -0
  95. package/lib/index.js +105 -0
  96. package/lib/index.js.map +1 -0
  97. package/lib/paste-images.js +217 -0
  98. package/lib/paste-images.js.map +1 -0
  99. package/lib/paths.js +354 -0
  100. package/lib/paths.js.map +1 -0
  101. package/lib/plugin-update.js +1003 -0
  102. package/lib/plugin-update.js.map +1 -0
  103. package/lib/runtime-install.js +964 -0
  104. package/lib/runtime-install.js.map +1 -0
  105. package/lib/runtime-manager.js +133 -0
  106. package/lib/runtime-manager.js.map +1 -0
  107. package/lib/runtime.js +1678 -0
  108. package/lib/runtime.js.map +1 -0
  109. package/lib/skill.js +25 -0
  110. package/lib/skill.js.map +1 -0
  111. package/lib/tools.js +549 -0
  112. package/lib/tools.js.map +1 -0
  113. package/lib/types/artifact-access.d.ts +61 -0
  114. package/lib/types/artifact-access.d.ts.map +1 -0
  115. package/lib/types/artifacts.d.ts +42 -0
  116. package/lib/types/artifacts.d.ts.map +1 -0
  117. package/lib/types/client/display-config.d.ts +24 -0
  118. package/lib/types/client/display-config.d.ts.map +1 -0
  119. package/lib/types/client/index.d.ts +350 -0
  120. package/lib/types/client/index.d.ts.map +1 -0
  121. package/lib/types/client/model-variants-hider.d.ts +40 -0
  122. package/lib/types/client/model-variants-hider.d.ts.map +1 -0
  123. package/lib/types/client/paste-images.d.ts +126 -0
  124. package/lib/types/client/paste-images.d.ts.map +1 -0
  125. package/lib/types/config.d.ts +123 -0
  126. package/lib/types/config.d.ts.map +1 -0
  127. package/lib/types/defaults.d.ts +6 -0
  128. package/lib/types/defaults.d.ts.map +1 -0
  129. package/lib/types/errors.d.ts +35 -0
  130. package/lib/types/errors.d.ts.map +1 -0
  131. package/lib/types/evidence-cache.d.ts +91 -0
  132. package/lib/types/evidence-cache.d.ts.map +1 -0
  133. package/lib/types/exposure.d.ts +50 -0
  134. package/lib/types/exposure.d.ts.map +1 -0
  135. package/lib/types/image-input-variants.d.ts +144 -0
  136. package/lib/types/image-input-variants.d.ts.map +1 -0
  137. package/lib/types/index.d.ts +19 -0
  138. package/lib/types/index.d.ts.map +1 -0
  139. package/lib/types/paste-images.d.ts +73 -0
  140. package/lib/types/paste-images.d.ts.map +1 -0
  141. package/lib/types/paths.d.ts +117 -0
  142. package/lib/types/paths.d.ts.map +1 -0
  143. package/lib/types/plugin-update.d.ts +119 -0
  144. package/lib/types/plugin-update.d.ts.map +1 -0
  145. package/lib/types/runtime-install.d.ts +92 -0
  146. package/lib/types/runtime-install.d.ts.map +1 -0
  147. package/lib/types/runtime-manager.d.ts +60 -0
  148. package/lib/types/runtime-manager.d.ts.map +1 -0
  149. package/lib/types/runtime.d.ts +412 -0
  150. package/lib/types/runtime.d.ts.map +1 -0
  151. package/lib/types/skill.d.ts +14 -0
  152. package/lib/types/skill.d.ts.map +1 -0
  153. package/lib/types/tools.d.ts +35 -0
  154. package/lib/types/tools.d.ts.map +1 -0
  155. package/lib/types/upstream.d.ts +233 -0
  156. package/lib/types/upstream.d.ts.map +1 -0
  157. package/lib/types/version.d.ts +15 -0
  158. package/lib/types/version.d.ts.map +1 -0
  159. package/lib/types/web-request.d.ts +11 -0
  160. package/lib/types/web-request.d.ts.map +1 -0
  161. package/lib/types/web.d.ts +118 -0
  162. package/lib/types/web.d.ts.map +1 -0
  163. package/lib/upstream.js +860 -0
  164. package/lib/upstream.js.map +1 -0
  165. package/lib/version.js +18 -0
  166. package/lib/version.js.map +1 -0
  167. package/lib/web-request.js +29 -0
  168. package/lib/web-request.js.map +1 -0
  169. package/lib/web.js +462 -0
  170. package/lib/web.js.map +1 -0
  171. package/package.json +149 -0
  172. package/patches/vision-tools-dsh.patch +970 -0
  173. package/runtime/requirements.lock +3 -0
  174. package/src/artifact-access.ts +386 -0
  175. package/src/artifacts.ts +85 -0
  176. package/src/client/display-config.ts +62 -0
  177. package/src/client/index.tsx +1580 -0
  178. package/src/client/model-variants-hider.ts +159 -0
  179. package/src/client/paste-images.tsx +747 -0
  180. package/src/config.ts +290 -0
  181. package/src/defaults.ts +5 -0
  182. package/src/errors.ts +62 -0
  183. package/src/evidence-cache.ts +418 -0
  184. package/src/exposure.ts +265 -0
  185. package/src/image-input-variants.ts +996 -0
  186. package/src/index.ts +141 -0
  187. package/src/paste-images.ts +286 -0
  188. package/src/paths.ts +383 -0
  189. package/src/plugin-update.ts +1143 -0
  190. package/src/runtime-install.ts +1161 -0
  191. package/src/runtime-manager.ts +174 -0
  192. package/src/runtime.ts +2189 -0
  193. package/src/skill.ts +35 -0
  194. package/src/tools.ts +691 -0
  195. package/src/upstream.ts +1078 -0
  196. package/src/version.ts +37 -0
  197. package/src/web-request.ts +27 -0
  198. package/src/web.ts +594 -0
  199. package/vendor/agent-vision-toolkit/CHANGELOG.md +30 -0
  200. package/vendor/agent-vision-toolkit/LICENSE +21 -0
  201. package/vendor/agent-vision-toolkit/README.md +405 -0
  202. package/vendor/agent-vision-toolkit/UPSTREAM_MANIFEST.json +94 -0
  203. package/vendor/agent-vision-toolkit/bin/crop +90 -0
  204. package/vendor/agent-vision-toolkit/bin/detect +13 -0
  205. package/vendor/agent-vision-toolkit/bin/glance +93 -0
  206. package/vendor/agent-vision-toolkit/bin/ground +13 -0
  207. package/vendor/agent-vision-toolkit/bin/trace +129 -0
  208. package/vendor/agent-vision-toolkit/detect.py +60 -0
  209. package/vendor/agent-vision-toolkit/ground.py +267 -0
  210. package/vendor/agent-vision-toolkit/skills/vision-tools/scripts/dominant_colors.py +224 -0
  211. package/vendor/agent-vision-toolkit/skills/vision-tools/scripts/extract_fg.py +278 -0
  212. package/vendor/agent-vision-toolkit/skills/vision-tools/scripts/html_shot.py +420 -0
  213. package/vendor/agent-vision-toolkit/skills/vision-tools/scripts/long_screenshot_ocr.py +1245 -0
  214. package/vendor/agent-vision-toolkit/skills/vision-tools/scripts/pixel_diff.py +88 -0
  215. package/vendor/agent-vision-toolkit/tests/test_vision_client.py +450 -0
  216. package/vendor/agent-vision-toolkit/vision_client.py +310 -0
@@ -0,0 +1,1245 @@
1
+ #!/usr/bin/env python3
2
+ """Safely split a tall screenshot, OCR each chunk with glance, and merge it."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ from concurrent.futures import ThreadPoolExecutor, as_completed
8
+ from dataclasses import dataclass, replace
9
+ from difflib import SequenceMatcher
10
+ import hashlib
11
+ import io
12
+ import json
13
+ import math
14
+ import os
15
+ from pathlib import Path
16
+ import re
17
+ import shutil
18
+ import subprocess
19
+ import sys
20
+ import unicodedata
21
+ from typing import Sequence
22
+
23
+ try:
24
+ from PIL import Image, ImageChops, ImageOps
25
+ except ImportError: # Optional dependency; handled by main().
26
+ Image = None
27
+ ImageChops = None
28
+ ImageOps = None
29
+
30
+
31
+ ANALYSIS_WIDTH = 900
32
+ SAFE_OCCUPANCY_LEVEL = 20.0
33
+ # Bump when the OCR output contract changes so --resume cannot reuse stale results.
34
+ OCR_PROMPT_VERSION = 5
35
+
36
+
37
+ @dataclass(frozen=True)
38
+ class CoreRange:
39
+ top: int
40
+ bottom: int
41
+ cut_energy: float | None
42
+ cut_quality: float | None
43
+ top_safe_margin: int | None
44
+ bottom_safe_margin: int | None
45
+
46
+
47
+ @dataclass(frozen=True)
48
+ class Chunk:
49
+ index: int
50
+ core_top: int
51
+ core_bottom: int
52
+ crop_top: int
53
+ crop_bottom: int
54
+ top_overlap: int
55
+ bottom_overlap: int
56
+ cut_energy: float | None
57
+ cut_quality: float | None
58
+ top_safe_margin: int | None
59
+ bottom_safe_margin: int | None
60
+ image_path: Path
61
+ image_sha256: str
62
+
63
+
64
+ @dataclass(frozen=True)
65
+ class ChatMessage:
66
+ speaker: str
67
+ content: str
68
+ timestamp: str = ""
69
+ message_type: str = "message"
70
+ quoted_speaker: str = ""
71
+ quoted_content: str = ""
72
+
73
+
74
+ @dataclass(frozen=True)
75
+ class Transcript:
76
+ chunk: Chunk
77
+ text: str
78
+ output_path: Path
79
+ reused: bool
80
+ messages: tuple[ChatMessage, ...] = ()
81
+
82
+
83
+ def clamp(value: float, minimum: int, maximum: int) -> int:
84
+ return round(max(minimum, min(maximum, value)))
85
+
86
+
87
+ def percentile(values: Sequence[float], percent: float) -> float:
88
+ if not values:
89
+ raise ValueError("percentile requires at least one value")
90
+ ordered = sorted(values)
91
+ position = (len(ordered) - 1) * percent / 100
92
+ lower = math.floor(position)
93
+ upper = math.ceil(position)
94
+ if lower == upper:
95
+ return float(ordered[lower])
96
+ fraction = position - lower
97
+ return float(ordered[lower] * (1 - fraction) + ordered[upper] * fraction)
98
+
99
+
100
+ def rolling_mean(values: Sequence[float], radius: int) -> list[float]:
101
+ if radius <= 0 or len(values) <= 1:
102
+ return [float(value) for value in values]
103
+ padded = [float(values[0])] * radius
104
+ padded.extend(float(value) for value in values)
105
+ padded.extend([float(values[-1])] * radius)
106
+ window = radius * 2 + 1
107
+ total = sum(padded[:window])
108
+ result = []
109
+ for index in range(len(values)):
110
+ result.append(total / window)
111
+ if index + window < len(padded):
112
+ total += padded[index + window] - padded[index]
113
+ return result
114
+
115
+
116
+ def row_energy(image: "Image.Image") -> tuple[list[float], list[float], float]:
117
+ """Return per-row edge energy and foreground occupancy with Pillow operations."""
118
+ scale = min(1.0, ANALYSIS_WIDTH / image.width)
119
+ analysis_width = max(1, round(image.width * scale))
120
+ analysis_height = max(1, round(image.height * scale))
121
+ analysis = image.convert("RGB")
122
+ if scale < 1.0:
123
+ analysis = analysis.resize(
124
+ (analysis_width, analysis_height), Image.Resampling.BILINEAR
125
+ )
126
+ gray = analysis.convert("L")
127
+
128
+ width, height = gray.size
129
+ shifted_x = Image.new("L", gray.size)
130
+ shifted_x.paste(gray, (1, 0))
131
+ shifted_x.paste(gray.crop((0, 0, 1, height)), (0, 0))
132
+ horizontal = ImageChops.difference(gray, shifted_x)
133
+
134
+ shifted_y = Image.new("L", gray.size)
135
+ shifted_y.paste(gray, (0, 1))
136
+ shifted_y.paste(gray.crop((0, 0, width, 1)), (0, 0))
137
+ vertical = ImageChops.difference(gray, shifted_y)
138
+
139
+ combined = Image.blend(horizontal, vertical, 0.32)
140
+ collapsed = combined.resize((1, height), Image.Resampling.BOX)
141
+ edges = [float(value) for value in collapsed.tobytes()]
142
+
143
+ border_width = max(1, min(24, width // 18))
144
+ left = analysis.crop((0, 0, border_width, height)).resize(
145
+ (1, height), Image.Resampling.BOX
146
+ )
147
+ right = analysis.crop((width - border_width, 0, width, height)).resize(
148
+ (1, height), Image.Resampling.BOX
149
+ )
150
+ edge_reference = Image.new("RGB", (2, height))
151
+ edge_reference.paste(left, (0, 0))
152
+ edge_reference.paste(right, (1, 0))
153
+ background = edge_reference.resize((width, height), Image.Resampling.BILINEAR)
154
+ foreground_difference = ImageChops.difference(analysis, background)
155
+ red_difference, green_difference, blue_difference = foreground_difference.split()
156
+ foreground_distance = ImageChops.lighter(
157
+ ImageChops.lighter(red_difference, green_difference), blue_difference
158
+ )
159
+ foreground_mask = foreground_distance.point(
160
+ lambda value: 255 if value >= 14 else 0,
161
+ mode="L",
162
+ )
163
+ occupancy_column = foreground_mask.resize((1, height), Image.Resampling.BOX)
164
+ occupancy = [float(value) for value in occupancy_column.tobytes()]
165
+
166
+ radius = max(1, round(3 * scale))
167
+ smoothed_edges = rolling_mean(edges, radius)
168
+ smoothed_occupancy = rolling_mean(occupancy, radius)
169
+ energy = [
170
+ edge_value + occupancy_value * 0.55
171
+ for edge_value, occupancy_value in zip(smoothed_edges, smoothed_occupancy)
172
+ ]
173
+ return energy, smoothed_occupancy, scale
174
+
175
+
176
+ def resolve_split_sizes(
177
+ width: int,
178
+ mode: str,
179
+ target_height: int | None,
180
+ min_height: int | None,
181
+ max_height: int | None,
182
+ overlap: int | None,
183
+ ) -> tuple[int, int, int, int]:
184
+ automatic_target = clamp(
185
+ width * (1.75 if mode == "chat" else 1.45),
186
+ 1400 if mode == "chat" else 1200,
187
+ 2400,
188
+ )
189
+ target = target_height or automatic_target
190
+ minimum = min_height or max(600, round(target * 0.58))
191
+ maximum = max_height or min(3400, round(target * 1.42))
192
+ resolved_overlap = overlap if overlap is not None else (64 if mode == "chat" else 40)
193
+
194
+ if min(target, minimum, maximum) <= 0:
195
+ raise ValueError("split heights must be greater than zero")
196
+ if not minimum <= target <= maximum:
197
+ raise ValueError("split heights must satisfy min-height <= target-height <= max-height")
198
+ if resolved_overlap < 0:
199
+ raise ValueError("overlap cannot be negative")
200
+ if resolved_overlap * 2 >= minimum:
201
+ raise ValueError("overlap must be less than half of min-height")
202
+ return target, minimum, maximum, resolved_overlap
203
+
204
+
205
+ def choose_cut(
206
+ energy: Sequence[float],
207
+ occupancy: Sequence[float],
208
+ start: int,
209
+ target: int,
210
+ minimum: int,
211
+ maximum: int,
212
+ mode: str,
213
+ safe_radius: int,
214
+ ) -> tuple[int, float, float, int]:
215
+ image_height = len(energy)
216
+ lower = min(image_height - 1, start + minimum)
217
+ upper = min(image_height - minimum, start + maximum)
218
+ desired = min(image_height - 1, start + target)
219
+ if lower >= upper:
220
+ return upper, float(energy[upper]), 0.0, 0
221
+
222
+ local = [float(value) for value in energy[lower : upper + 1]]
223
+ low = percentile(local, 8)
224
+ high = percentile(local, 92)
225
+ normalized = [(value - low) / max(0.001, high - low) for value in local]
226
+
227
+ threshold = percentile(local, 32 if mode == "chat" else 25)
228
+ low_rows = [
229
+ 1.0
230
+ if value <= threshold and occupancy[index] <= SAFE_OCCUPANCY_LEVEL
231
+ else 0.0
232
+ for index, value in enumerate(energy)
233
+ ]
234
+ blank_ratio = rolling_mean(low_rows, safe_radius)[lower : upper + 1]
235
+ distance_weight = 0.20 if mode == "chat" else 0.30
236
+
237
+ selected_offset = min(
238
+ range(len(local)),
239
+ key=lambda offset: (
240
+ normalized[offset]
241
+ + abs((lower + offset) - desired) / max(1, maximum - minimum) * distance_weight
242
+ + occupancy[lower + offset] / 255 * 0.75
243
+ - blank_ratio[offset] * 0.48
244
+ ),
245
+ )
246
+ selected = lower + selected_offset
247
+
248
+ band_threshold = percentile(local, 40)
249
+ band_left = selected
250
+ band_right = selected
251
+ while (
252
+ band_left > lower
253
+ and energy[band_left - 1] <= band_threshold
254
+ and occupancy[band_left - 1] <= SAFE_OCCUPANCY_LEVEL
255
+ ):
256
+ band_left -= 1
257
+ while (
258
+ band_right < upper
259
+ and energy[band_right + 1] <= band_threshold
260
+ and occupancy[band_right + 1] <= SAFE_OCCUPANCY_LEVEL
261
+ ):
262
+ band_right += 1
263
+ if band_right - band_left >= max(4, safe_radius // 2):
264
+ selected = (band_left + band_right) // 2
265
+ safe_margin = min(selected - band_left, band_right - selected)
266
+ else:
267
+ safe_margin = 0
268
+
269
+ selected_energy = float(energy[selected])
270
+ percentile_rank = sum(value <= selected_energy for value in local) / len(local)
271
+ quality = max(0.0, min(1.0, 1.0 - percentile_rank))
272
+ return selected, selected_energy, quality, safe_margin
273
+
274
+
275
+ def find_core_ranges(
276
+ image: "Image.Image",
277
+ mode: str,
278
+ target_height: int,
279
+ min_height: int,
280
+ max_height: int,
281
+ ) -> tuple[list[CoreRange], dict[str, float]]:
282
+ if image.height <= max_height:
283
+ return [CoreRange(0, image.height, None, None, None, None)], {
284
+ "analysis_scale": 1.0,
285
+ "safe_band_radius_px": 0.0,
286
+ }
287
+
288
+ energy, occupancy, scale = row_energy(image)
289
+ target = max(1, round(target_height * scale))
290
+ minimum = max(1, round(min_height * scale))
291
+ maximum = max(minimum + 1, round(max_height * scale))
292
+ safe_radius = max(2, round(image.width * 0.012 * scale))
293
+
294
+ cuts = [0]
295
+ cut_details: list[tuple[float, float, int]] = []
296
+ analysis_height = len(energy)
297
+ while analysis_height - cuts[-1] > maximum:
298
+ if analysis_height - cuts[-1] < minimum * 2:
299
+ break
300
+ cut, selected_energy, quality, safe_margin = choose_cut(
301
+ energy,
302
+ occupancy,
303
+ cuts[-1],
304
+ target,
305
+ minimum,
306
+ maximum,
307
+ mode,
308
+ safe_radius,
309
+ )
310
+ if cut <= cuts[-1]:
311
+ cut = min(analysis_height, cuts[-1] + target)
312
+ cuts.append(cut)
313
+ cut_details.append((selected_energy, quality, safe_margin))
314
+ cuts.append(analysis_height)
315
+
316
+ original_cuts = [0]
317
+ for cut in cuts[1:-1]:
318
+ mapped = max(original_cuts[-1] + 1, min(image.height - 1, round(cut / scale)))
319
+ original_cuts.append(mapped)
320
+ original_cuts.append(image.height)
321
+
322
+ ranges = []
323
+ for index, (top, bottom) in enumerate(zip(original_cuts, original_cuts[1:])):
324
+ detail = cut_details[index] if index < len(cut_details) else (None, None, None)
325
+ top_safe_margin = (
326
+ round(cut_details[index - 1][2] / scale) if index > 0 else None
327
+ )
328
+ bottom_safe_margin = (
329
+ round(detail[2] / scale) if detail[2] is not None else None
330
+ )
331
+ ranges.append(
332
+ CoreRange(
333
+ top=top,
334
+ bottom=bottom,
335
+ cut_energy=detail[0],
336
+ cut_quality=detail[1],
337
+ top_safe_margin=top_safe_margin,
338
+ bottom_safe_margin=bottom_safe_margin,
339
+ )
340
+ )
341
+ return ranges, {
342
+ "analysis_scale": scale,
343
+ "safe_band_radius_px": safe_radius / scale,
344
+ }
345
+
346
+
347
+ def atomic_write_bytes(path: Path, data: bytes) -> None:
348
+ path.parent.mkdir(parents=True, exist_ok=True)
349
+ temporary = path.with_name(path.name + ".tmp")
350
+ temporary.write_bytes(data)
351
+ temporary.replace(path)
352
+
353
+
354
+ def atomic_write_text(path: Path, text: str) -> None:
355
+ atomic_write_bytes(path, text.encode("utf-8"))
356
+
357
+
358
+ def save_chunks(
359
+ image: "Image.Image",
360
+ ranges: Sequence[CoreRange],
361
+ chunks_dir: Path,
362
+ overlap: int,
363
+ ) -> list[Chunk]:
364
+ chunks_dir.mkdir(parents=True, exist_ok=True)
365
+ chunks = []
366
+ digits = max(3, len(str(len(ranges))))
367
+ for index, core in enumerate(ranges, 1):
368
+ top_overlap = (
369
+ 0
370
+ if not core.top or (core.top_safe_margin is not None and core.top_safe_margin > 0)
371
+ else overlap
372
+ )
373
+ bottom_overlap = (
374
+ 0
375
+ if core.bottom >= image.height
376
+ or (core.bottom_safe_margin is not None and core.bottom_safe_margin > 0)
377
+ else overlap
378
+ )
379
+ crop_top = max(0, core.top - top_overlap)
380
+ crop_bottom = min(image.height, core.bottom + bottom_overlap)
381
+ image_path = chunks_dir / f"chunk_{index:0{digits}d}.png"
382
+
383
+ buffer = io.BytesIO()
384
+ image.crop((0, crop_top, image.width, crop_bottom)).save(buffer, format="PNG")
385
+ image_bytes = buffer.getvalue()
386
+ image_sha256 = hashlib.sha256(image_bytes).hexdigest()
387
+ existing_sha256 = (
388
+ hashlib.sha256(image_path.read_bytes()).hexdigest()
389
+ if image_path.is_file()
390
+ else ""
391
+ )
392
+ if existing_sha256 != image_sha256:
393
+ atomic_write_bytes(image_path, image_bytes)
394
+
395
+ chunks.append(
396
+ Chunk(
397
+ index=index,
398
+ core_top=core.top,
399
+ core_bottom=core.bottom,
400
+ crop_top=crop_top,
401
+ crop_bottom=crop_bottom,
402
+ top_overlap=top_overlap,
403
+ bottom_overlap=bottom_overlap,
404
+ cut_energy=core.cut_energy,
405
+ cut_quality=core.cut_quality,
406
+ top_safe_margin=core.top_safe_margin,
407
+ bottom_safe_margin=core.bottom_safe_margin,
408
+ image_path=image_path,
409
+ image_sha256=image_sha256,
410
+ )
411
+ )
412
+ prune_stale_chunk_files(chunks_dir, chunks)
413
+ return chunks
414
+
415
+
416
+ def prune_stale_chunk_files(chunks_dir: Path, chunks: Sequence[Chunk]) -> None:
417
+ active = set()
418
+ for chunk in chunks:
419
+ active.update(
420
+ {
421
+ chunk.image_path.name,
422
+ chunk.image_path.with_suffix(".ocr.md").name,
423
+ chunk.image_path.with_suffix(".ocr.md").name + ".sha256",
424
+ chunk.image_path.with_suffix(".ocr.json").name,
425
+ chunk.image_path.with_suffix(".ocr.json").name + ".sha256",
426
+ }
427
+ )
428
+ generated = re.compile(
429
+ r"chunk_\d+\.(?:png|ocr\.md(?:\.sha256)?|ocr\.json(?:\.sha256)?|ocr\.sha256)"
430
+ )
431
+ for path in chunks_dir.iterdir():
432
+ if path.is_file() and generated.fullmatch(path.name) and path.name not in active:
433
+ path.unlink()
434
+
435
+
436
+ def command_for_path(path: Path) -> list[str]:
437
+ suffix = path.suffix.lower()
438
+ if suffix in {".py", ".pyw"}:
439
+ return [sys.executable, str(path)]
440
+ if os.name == "nt":
441
+ # Windows cannot exec a shebang script directly; a bare script on PATH
442
+ # (or the repo's own bin/glance) must be run through the interpreter.
443
+ if suffix:
444
+ return [str(path)]
445
+ try:
446
+ with open(path, "rb") as handle:
447
+ first = handle.readline(256).decode("utf-8", errors="replace")
448
+ except OSError:
449
+ first = ""
450
+ if first.lstrip().startswith("#!") and "python" in first.lower():
451
+ return [sys.executable, str(path)]
452
+ return [str(path)]
453
+ if not os.access(path, os.X_OK):
454
+ return [sys.executable, str(path)]
455
+ return [str(path)]
456
+
457
+
458
+ def resolve_glance_command() -> list[str]:
459
+ discovered = shutil.which("glance")
460
+ if discovered:
461
+ return command_for_path(Path(discovered))
462
+ repository_glance = Path(__file__).resolve().parents[3] / "bin" / "glance"
463
+ if repository_glance.is_file():
464
+ return command_for_path(repository_glance)
465
+ raise FileNotFoundError("glance is not on PATH; install the toolkit first")
466
+
467
+
468
+ def ocr_prompt(mode: str, index: int, total: int, custom: str | None) -> str:
469
+ if mode == "chat":
470
+ instructions = (
471
+ "Transcribe this chat screenshot chunk in strict top-to-bottom message order. "
472
+ "Return only one valid JSON object with this exact shape: "
473
+ '{"messages":[{"speaker":"visible name","content":"message text",'
474
+ '"timestamp":"","message_type":"message","quoted_speaker":"",'
475
+ '"quoted_content":""}]}. '
476
+ "Give every message a speaker and copy the visible nickname exactly; never replace "
477
+ "it with roles such as customer, support, me, or other. If the screenshot shows a "
478
+ "question-mark square glyph in a nickname, preserve it as Unicode U+25A1. When a "
479
+ "chat UI clearly marks an outgoing self-message by alignment and bubble style but "
480
+ "omits its nickname, use You as the speaker. Ignore app chrome such as the status "
481
+ "bar, chat title, pinned-message banner, and composer. Inside the chat history, "
482
+ "transcribe every date separator, service notice, and unread divider as a system "
483
+ "message. Merge "
484
+ "screen-width wrapping back into the same message. Each rounded message bubble is "
485
+ "exactly one message: keep code blocks, bullet lists, attachment filenames, and file "
486
+ "metadata inside that bubble's content instead of creating a second message. Put file "
487
+ "and voice-card titles and metadata on separate lines. For polls, include the visible "
488
+ "poll label and write each option as a bullet line. For photo messages, include visible "
489
+ "overlay text before the caption. Preserve intentional code and list line breaks. "
490
+ "Put replied-to text in "
491
+ "quoted_speaker and quoted_content while keeping the new message in speaker and "
492
+ "content. Fill timestamp only when the entire timestamp is clearly visible; "
493
+ "otherwise leave it empty. message_type must be message, system, image, or file. "
494
+ "Do not summarize, rewrite, translate, or infer clipped text. Use [unreadable] for "
495
+ "visible text that cannot be read and [clipped] for a visibly cut-off message."
496
+ )
497
+ else:
498
+ instructions = (
499
+ "Keep the visible top-to-bottom reading order and preserve wording, punctuation, "
500
+ "line breaks, labels, timestamps, headings, lists, tables, code, quoted text, and "
501
+ "paragraph order. Do not infer clipped or hidden content; write [unreadable] only "
502
+ "where visible text cannot be read."
503
+ )
504
+ chunk_note = f" This is chunk {index} of {total} from one vertically scrolling screenshot."
505
+ custom_note = f" {custom.strip()}" if custom and custom.strip() else ""
506
+ return instructions + chunk_note + custom_note
507
+
508
+
509
+ def join_visual_wraps(content: str, preserve_lines: bool = False) -> str:
510
+ content = content.replace("\r\n", "\n").replace("\r", "\n").strip()
511
+ if not content:
512
+ return ""
513
+ paragraphs = re.split(r"\n\s*\n", content)
514
+ normalized = []
515
+ list_pattern = re.compile(r"^(?:[-*+\u2022] |\d+[.)] )")
516
+ code_line_pattern = re.compile(r"^[A-Za-z0-9_.-]+:\s+\S")
517
+ for paragraph in paragraphs:
518
+ lines = [re.sub(r"[ \t]+", " ", line.strip()) for line in paragraph.splitlines()]
519
+ lines = [line for line in lines if line]
520
+ if not lines:
521
+ continue
522
+ structured_card = any(line.casefold() == "anonymous poll" for line in lines)
523
+ if preserve_lines or structured_card:
524
+ normalized.append("\n".join(lines))
525
+ continue
526
+ merged = lines[0]
527
+ for line in lines[1:]:
528
+ if list_pattern.match(line) or code_line_pattern.match(line):
529
+ merged += "\n" + line
530
+ continue
531
+ separator = (
532
+ " "
533
+ if re.search(r"[A-Za-z0-9]$", merged) and re.match(r"[A-Za-z0-9]", line)
534
+ else ""
535
+ )
536
+ merged += separator + line
537
+ normalized.append(merged)
538
+ return "\n\n".join(normalized)
539
+
540
+
541
+ def normalize_timestamp(value: object) -> str:
542
+ timestamp = str(value or "").strip()
543
+ lowered = timestamp.casefold()
544
+ if any(marker in lowered for marker in ("[unreadable]", "[clipped]")):
545
+ return ""
546
+ if re.search(r"[:\uFF1A]\d$", timestamp):
547
+ return ""
548
+ return timestamp
549
+
550
+
551
+ def parse_chat_messages(raw_text: str) -> tuple[ChatMessage, ...]:
552
+ text = raw_text.strip()
553
+ if text.startswith("```"):
554
+ text = re.sub(r"^```(?:json)?\s*", "", text, flags=re.IGNORECASE)
555
+ text = re.sub(r"\s*```$", "", text)
556
+ start = text.find("{")
557
+ if start < 0:
558
+ raise ValueError("chat OCR did not return a JSON object")
559
+ try:
560
+ payload, _end = json.JSONDecoder().raw_decode(text[start:])
561
+ except json.JSONDecodeError as exc:
562
+ raise ValueError(f"chat OCR returned invalid JSON: {exc.msg}") from exc
563
+ records = payload.get("messages") if isinstance(payload, dict) else None
564
+ if not isinstance(records, list):
565
+ raise ValueError("chat OCR JSON is missing a messages array")
566
+
567
+ messages = []
568
+ for record in records:
569
+ if not isinstance(record, dict):
570
+ continue
571
+ message_type = str(record.get("message_type") or "message").strip().lower()
572
+ if message_type not in {"message", "system", "image", "file"}:
573
+ message_type = "message"
574
+ content = join_visual_wraps(
575
+ str(record.get("content") or ""),
576
+ preserve_lines=message_type in {"image", "file"},
577
+ )
578
+ if not content:
579
+ continue
580
+ speaker = str(record.get("speaker") or "").strip()
581
+ speaker = re.sub(r"\u25a1\s+\u3002", "\u25a1\u3002", speaker)
582
+ if message_type == "system":
583
+ speaker = "system"
584
+ if not speaker:
585
+ speaker = "[unreadable speaker]"
586
+ messages.append(
587
+ ChatMessage(
588
+ speaker=speaker,
589
+ content=content,
590
+ timestamp=normalize_timestamp(record.get("timestamp")),
591
+ message_type=message_type,
592
+ quoted_speaker=str(record.get("quoted_speaker") or "").strip(),
593
+ quoted_content=join_visual_wraps(str(record.get("quoted_content") or "")),
594
+ )
595
+ )
596
+ if not messages:
597
+ raise ValueError("chat OCR JSON contains no readable messages")
598
+ return tuple(messages)
599
+
600
+
601
+ def chat_message_record(message: ChatMessage) -> dict[str, str]:
602
+ return {
603
+ "speaker": message.speaker,
604
+ "content": message.content,
605
+ "timestamp": message.timestamp,
606
+ "message_type": message.message_type,
607
+ "quoted_speaker": message.quoted_speaker,
608
+ "quoted_content": message.quoted_content,
609
+ }
610
+
611
+
612
+ def render_chat_messages(messages: Sequence[ChatMessage]) -> str:
613
+ rendered = []
614
+ for message in messages:
615
+ timestamp = f" ({message.timestamp})" if message.timestamp else ""
616
+ blocks = []
617
+ if message.quoted_content:
618
+ quoted_speaker = message.quoted_speaker or "[quoted speaker]"
619
+ quoted_text = message.quoted_content.replace("\n", "\n> ")
620
+ blocks.append(f"> **{quoted_speaker}**: {quoted_text}")
621
+ blocks.append(f"**{message.speaker}**{timestamp}: {message.content}")
622
+ rendered.append("\n\n".join(blocks))
623
+ return "\n\n".join(rendered)
624
+
625
+
626
+ def recognition_fingerprint(
627
+ chunk: Chunk,
628
+ total: int,
629
+ mode: str,
630
+ custom_prompt: str | None,
631
+ ) -> str:
632
+ payload = {
633
+ "prompt_version": OCR_PROMPT_VERSION,
634
+ "image_sha256": chunk.image_sha256,
635
+ "chunk_index": chunk.index,
636
+ "chunk_total": total,
637
+ "mode": mode,
638
+ "custom_prompt": custom_prompt.strip() if custom_prompt else "",
639
+ }
640
+ encoded = json.dumps(payload, ensure_ascii=False, sort_keys=True).encode("utf-8")
641
+ return hashlib.sha256(encoded).hexdigest()
642
+
643
+
644
+ def run_glance(command: Sequence[str], timeout: float, chunk_index: int) -> str:
645
+ completed = subprocess.run(
646
+ command,
647
+ text=True,
648
+ capture_output=True,
649
+ timeout=timeout,
650
+ )
651
+ if completed.returncode != 0:
652
+ detail = completed.stderr.strip() or completed.stdout.strip() or "unknown error"
653
+ raise RuntimeError(f"chunk {chunk_index}: glance failed: {detail}")
654
+ text = completed.stdout.strip()
655
+ if not text:
656
+ raise RuntimeError(f"chunk {chunk_index}: glance returned an empty transcription")
657
+ return text
658
+
659
+
660
+ def recognize_chunk(
661
+ chunk: Chunk,
662
+ total: int,
663
+ glance_command: Sequence[str],
664
+ mode: str,
665
+ custom_prompt: str | None,
666
+ timeout: float,
667
+ resume: bool,
668
+ ) -> Transcript:
669
+ output_path = chunk.image_path.with_suffix(".ocr.json" if mode == "chat" else ".ocr.md")
670
+ hash_path = output_path.with_name(output_path.name + ".sha256")
671
+ fingerprint = recognition_fingerprint(chunk, total, mode, custom_prompt)
672
+ if (
673
+ resume
674
+ and output_path.is_file()
675
+ and hash_path.is_file()
676
+ and hash_path.read_text(encoding="utf-8").strip() == fingerprint
677
+ ):
678
+ stored = output_path.read_text(encoding="utf-8").strip()
679
+ if mode == "chat":
680
+ messages = parse_chat_messages(stored)
681
+ return Transcript(chunk, render_chat_messages(messages), output_path, True, messages)
682
+ return Transcript(chunk, stored, output_path, True)
683
+
684
+ prompt = ocr_prompt(mode, chunk.index, total, custom_prompt)
685
+ messages: tuple[ChatMessage, ...] = ()
686
+ if mode == "chat":
687
+ retry_note = (
688
+ " Return compact valid JSON only. Escape every newline inside a JSON string as "
689
+ "\\n, close every quote and brace, and do not use a Markdown code fence."
690
+ )
691
+ parse_error: ValueError | None = None
692
+ for attempt in range(2):
693
+ attempt_prompt = prompt + (retry_note if attempt else "")
694
+ command = [*glance_command, str(chunk.image_path), "--query", attempt_prompt]
695
+ text = run_glance(command, timeout, chunk.index)
696
+ try:
697
+ messages = parse_chat_messages(text)
698
+ break
699
+ except ValueError as exc:
700
+ parse_error = exc
701
+ if attempt == 0:
702
+ print(
703
+ f"retrying chunk {chunk.index}/{total} after invalid chat JSON",
704
+ file=sys.stderr,
705
+ )
706
+ else:
707
+ raise RuntimeError(f"chunk {chunk.index}: {parse_error}") from parse_error
708
+ else:
709
+ command = [*glance_command, str(chunk.image_path), "--ocr", prompt]
710
+ text = run_glance(command, timeout, chunk.index)
711
+ if mode == "chat":
712
+ stored = json.dumps(
713
+ {"messages": [chat_message_record(message) for message in messages]},
714
+ ensure_ascii=False,
715
+ indent=2,
716
+ )
717
+ atomic_write_text(output_path, stored + "\n")
718
+ text = render_chat_messages(messages)
719
+ else:
720
+ atomic_write_text(output_path, text + "\n")
721
+ atomic_write_text(hash_path, fingerprint + "\n")
722
+ return Transcript(chunk, text, output_path, False, messages)
723
+
724
+
725
+ def recognize_chunks(
726
+ chunks: Sequence[Chunk],
727
+ glance_command: Sequence[str],
728
+ mode: str,
729
+ custom_prompt: str | None,
730
+ timeout: float,
731
+ jobs: int,
732
+ resume: bool,
733
+ ) -> list[Transcript]:
734
+ results: dict[int, Transcript] = {}
735
+ worker_count = min(max(1, jobs), len(chunks))
736
+ with ThreadPoolExecutor(max_workers=worker_count) as executor:
737
+ futures = {
738
+ executor.submit(
739
+ recognize_chunk,
740
+ chunk,
741
+ len(chunks),
742
+ glance_command,
743
+ mode,
744
+ custom_prompt,
745
+ timeout,
746
+ resume,
747
+ ): chunk
748
+ for chunk in chunks
749
+ }
750
+ try:
751
+ for future in as_completed(futures):
752
+ transcript = future.result()
753
+ results[transcript.chunk.index] = transcript
754
+ state = "reused" if transcript.reused else "recognized"
755
+ print(
756
+ f"{state} chunk {transcript.chunk.index}/{len(chunks)}",
757
+ file=sys.stderr,
758
+ )
759
+ except Exception:
760
+ for future in futures:
761
+ future.cancel()
762
+ raise
763
+ return [results[index] for index in sorted(results)]
764
+
765
+
766
+ def trim_outer_blank_lines(text: str) -> list[str]:
767
+ lines = [line.rstrip() for line in text.replace("\r\n", "\n").replace("\r", "\n").split("\n")]
768
+ while lines and not lines[0].strip():
769
+ lines.pop(0)
770
+ while lines and not lines[-1].strip():
771
+ lines.pop()
772
+ return lines
773
+
774
+
775
+ def normalized_line(line: str) -> str:
776
+ normalized = unicodedata.normalize("NFKC", line)
777
+ return " ".join(normalized.casefold().split())
778
+
779
+
780
+ def find_text_overlap(previous: Sequence[str], current: Sequence[str]) -> tuple[int, str]:
781
+ maximum = min(24, len(previous), len(current))
782
+ for count in range(maximum, 0, -1):
783
+ left = [normalized_line(line) for line in previous[-count:]]
784
+ right = [normalized_line(line) for line in current[:count]]
785
+ if left == right and any(left):
786
+ return count, "exact"
787
+
788
+ for count in range(min(3, maximum), 0, -1):
789
+ left = [normalized_line(line) for line in previous[-count:]]
790
+ right = [normalized_line(line) for line in current[:count]]
791
+ joined_length = sum(len(line) for line in left + right)
792
+ if joined_length < 24 or not all(left) or not all(right):
793
+ continue
794
+ ratios = [SequenceMatcher(None, a, b).ratio() for a, b in zip(left, right)]
795
+ if min(ratios) >= 0.92 and sum(ratios) / len(ratios) >= 0.96:
796
+ return count, "fuzzy"
797
+ return 0, "none"
798
+
799
+
800
+ def message_fingerprint(message: ChatMessage) -> tuple[str, str, str]:
801
+ def simplify(value: str) -> str:
802
+ return re.sub(r"[\W_]+", "", value, flags=re.UNICODE).casefold()
803
+
804
+ return (
805
+ simplify(message.speaker),
806
+ simplify(message.content),
807
+ simplify(message.quoted_content),
808
+ )
809
+
810
+
811
+ def unreadable_speaker(value: str) -> bool:
812
+ lowered = value.casefold()
813
+ return not value.strip() or "unreadable" in lowered or "clipped" in lowered
814
+
815
+
816
+ def high_confidence_message_match(left: ChatMessage, right: ChatMessage) -> bool:
817
+ if (left.message_type == "system") != (right.message_type == "system"):
818
+ return False
819
+
820
+ left_speaker, left_content, left_quote = message_fingerprint(left)
821
+ right_speaker, right_content, right_quote = message_fingerprint(right)
822
+ speakers_match = (
823
+ left_speaker == right_speaker
824
+ or unreadable_speaker(left.speaker)
825
+ or unreadable_speaker(right.speaker)
826
+ )
827
+ timestamps_match = (
828
+ not left.timestamp
829
+ or not right.timestamp
830
+ or left.timestamp == right.timestamp
831
+ )
832
+ quotes_match = (
833
+ not left_quote
834
+ or not right_quote
835
+ or left_quote == right_quote
836
+ )
837
+ if not speakers_match or not timestamps_match or not quotes_match:
838
+ return False
839
+ if left_content == right_content and left_content:
840
+ return True
841
+ if min(len(left_content), len(right_content)) < 32:
842
+ return False
843
+ return SequenceMatcher(None, left_content, right_content).ratio() >= 0.97
844
+
845
+
846
+ def richer_text(left: str, right: str) -> str:
847
+ def score(value: str) -> tuple[int, int, int]:
848
+ lowered = value.casefold()
849
+ marker_penalty = lowered.count("[clipped]") + lowered.count("[unreadable]")
850
+ return (-marker_penalty, value.count("\n"), len(value))
851
+
852
+ return max((left, right), key=score)
853
+
854
+
855
+ def merge_duplicate_message(left: ChatMessage, right: ChatMessage) -> ChatMessage:
856
+ speaker = left.speaker
857
+ if unreadable_speaker(speaker) and not unreadable_speaker(right.speaker):
858
+ speaker = right.speaker
859
+ quoted_speaker = left.quoted_speaker
860
+ if unreadable_speaker(quoted_speaker) and not unreadable_speaker(right.quoted_speaker):
861
+ quoted_speaker = right.quoted_speaker
862
+ return replace(
863
+ left,
864
+ speaker=speaker,
865
+ content=richer_text(left.content, right.content),
866
+ timestamp=left.timestamp or right.timestamp,
867
+ message_type=(
868
+ right.message_type
869
+ if left.message_type == "message" and right.message_type != "message"
870
+ else left.message_type
871
+ ),
872
+ quoted_speaker=quoted_speaker,
873
+ quoted_content=richer_text(left.quoted_content, right.quoted_content),
874
+ )
875
+
876
+
877
+ def canonicalize_speakers(messages: Sequence[ChatMessage]) -> list[ChatMessage]:
878
+ def key(value: str) -> str:
879
+ return re.sub(r"[\W_]+", "", value, flags=re.UNICODE).casefold()
880
+
881
+ variants: dict[str, set[str]] = {}
882
+ for message in messages:
883
+ for value in (message.speaker, message.quoted_speaker):
884
+ if value:
885
+ variants.setdefault(key(value), set()).add(value)
886
+ canonical = {
887
+ fingerprint: min(
888
+ choices,
889
+ key=lambda value: (sum(char.isspace() for char in value), len(value), value),
890
+ )
891
+ for fingerprint, choices in variants.items()
892
+ }
893
+ return [
894
+ replace(
895
+ message,
896
+ speaker=canonical.get(key(message.speaker), message.speaker),
897
+ quoted_speaker=(
898
+ canonical.get(key(message.quoted_speaker), message.quoted_speaker)
899
+ if message.quoted_speaker
900
+ else ""
901
+ ),
902
+ )
903
+ for message in messages
904
+ ]
905
+
906
+
907
+ def find_message_overlap(
908
+ previous: Sequence[ChatMessage], current: Sequence[ChatMessage]
909
+ ) -> tuple[int, str]:
910
+ maximum = min(8, len(previous), len(current))
911
+ for count in range(maximum, 0, -1):
912
+ left = [message_fingerprint(message) for message in previous[-count:]]
913
+ right = [message_fingerprint(message) for message in current[:count]]
914
+ if left == right and any(any(part for part in item) for item in left):
915
+ return count, "message-exact"
916
+ for count in range(maximum, 0, -1):
917
+ pairs = zip(previous[-count:], current[:count])
918
+ if all(high_confidence_message_match(left, right) for left, right in pairs):
919
+ return count, "message-fuzzy"
920
+ return 0, "none"
921
+
922
+
923
+ def merge_general_transcripts(
924
+ transcripts: Sequence[Transcript],
925
+ ) -> tuple[str, list[dict[str, int | str]]]:
926
+ merged: list[str] = []
927
+ boundaries = []
928
+ for position, transcript in enumerate(transcripts):
929
+ current = trim_outer_blank_lines(transcript.text)
930
+ if position == 0:
931
+ merged.extend(current)
932
+ continue
933
+ previous_chunk = transcripts[position - 1].chunk
934
+ pixel_overlap = previous_chunk.bottom_overlap + transcript.chunk.top_overlap
935
+ if pixel_overlap:
936
+ overlap_lines, method = find_text_overlap(merged, current)
937
+ else:
938
+ overlap_lines, method = 0, "not-needed"
939
+ boundaries.append(
940
+ {
941
+ "after_chunk": transcripts[position - 1].chunk.index,
942
+ "before_chunk": transcript.chunk.index,
943
+ "removed_items": overlap_lines,
944
+ "unit": "lines",
945
+ "method": method,
946
+ }
947
+ )
948
+ merged.extend(current[overlap_lines:])
949
+ return "\n".join(merged).strip() + "\n", boundaries
950
+
951
+
952
+ def merge_chat_transcripts(
953
+ transcripts: Sequence[Transcript],
954
+ ) -> tuple[str, list[dict[str, int | str]]]:
955
+ merged: list[ChatMessage] = []
956
+ boundaries = []
957
+ for position, transcript in enumerate(transcripts):
958
+ current = list(transcript.messages)
959
+ if position == 0:
960
+ merged.extend(current)
961
+ continue
962
+ previous_chunk = transcripts[position - 1].chunk
963
+ pixel_overlap = previous_chunk.bottom_overlap + transcript.chunk.top_overlap
964
+ removed, method = (
965
+ find_message_overlap(merged, current) if pixel_overlap else (0, "not-needed")
966
+ )
967
+ if removed:
968
+ merged[-removed:] = [
969
+ merge_duplicate_message(left, right)
970
+ for left, right in zip(merged[-removed:], current[:removed])
971
+ ]
972
+ boundaries.append(
973
+ {
974
+ "after_chunk": previous_chunk.index,
975
+ "before_chunk": transcript.chunk.index,
976
+ "removed_items": removed,
977
+ "unit": "messages",
978
+ "method": method,
979
+ }
980
+ )
981
+ merged.extend(current[removed:])
982
+ return render_chat_messages(canonicalize_speakers(merged)).strip() + "\n", boundaries
983
+
984
+
985
+ def merge_transcripts(
986
+ transcripts: Sequence[Transcript],
987
+ ) -> tuple[str, list[dict[str, int | str]]]:
988
+ if transcripts and all(transcript.messages for transcript in transcripts):
989
+ return merge_chat_transcripts(transcripts)
990
+ return merge_general_transcripts(transcripts)
991
+
992
+
993
+ def chunk_record(chunk: Chunk, transcript: Transcript | None = None) -> dict[str, object]:
994
+ record: dict[str, object] = {
995
+ "index": chunk.index,
996
+ "image": chunk.image_path.name,
997
+ "image_sha256": chunk.image_sha256,
998
+ "core_top": chunk.core_top,
999
+ "core_bottom": chunk.core_bottom,
1000
+ "crop_top": chunk.crop_top,
1001
+ "crop_bottom": chunk.crop_bottom,
1002
+ "top_overlap": chunk.top_overlap,
1003
+ "bottom_overlap": chunk.bottom_overlap,
1004
+ "cut_energy": chunk.cut_energy,
1005
+ "cut_quality": chunk.cut_quality,
1006
+ "top_safe_margin": chunk.top_safe_margin,
1007
+ "bottom_safe_margin": chunk.bottom_safe_margin,
1008
+ }
1009
+ if transcript is not None:
1010
+ record.update(
1011
+ {
1012
+ "ocr": transcript.output_path.name,
1013
+ "ocr_reused": transcript.reused,
1014
+ }
1015
+ )
1016
+ return record
1017
+
1018
+
1019
+ def write_manifest(
1020
+ path: Path,
1021
+ input_path: Path,
1022
+ image_size: tuple[int, int],
1023
+ mode: str,
1024
+ split_sizes: tuple[int, int, int, int],
1025
+ analysis: dict[str, float],
1026
+ chunks: Sequence[Chunk],
1027
+ transcripts: Sequence[Transcript] | None = None,
1028
+ boundaries: Sequence[dict[str, int | str]] | None = None,
1029
+ output_path: Path | None = None,
1030
+ ) -> None:
1031
+ transcript_by_index = {
1032
+ transcript.chunk.index: transcript for transcript in transcripts or []
1033
+ }
1034
+ target, minimum, maximum, overlap = split_sizes
1035
+ payload = {
1036
+ "schema_version": 1,
1037
+ "input": str(input_path),
1038
+ "image_width": image_size[0],
1039
+ "image_height": image_size[1],
1040
+ "mode": mode,
1041
+ "target_height": target,
1042
+ "min_height": minimum,
1043
+ "max_height": maximum,
1044
+ "fallback_overlap": overlap,
1045
+ "analysis": analysis,
1046
+ "chunks": [
1047
+ chunk_record(chunk, transcript_by_index.get(chunk.index)) for chunk in chunks
1048
+ ],
1049
+ "merge_boundaries": list(boundaries or []),
1050
+ "output": str(output_path) if output_path else None,
1051
+ "complete": transcripts is not None,
1052
+ }
1053
+ atomic_write_text(path, json.dumps(payload, ensure_ascii=False, indent=2) + "\n")
1054
+
1055
+
1056
+ def write_audit(
1057
+ path: Path,
1058
+ input_path: Path,
1059
+ chunks: Sequence[Chunk],
1060
+ boundaries: Sequence[dict[str, int | str]],
1061
+ ) -> None:
1062
+ boundary_by_after = {int(item["after_chunk"]): item for item in boundaries}
1063
+ lines = [
1064
+ "# Long-screenshot OCR audit",
1065
+ "",
1066
+ f"- Source: `{input_path}`",
1067
+ f"- Chunks: {len(chunks)}",
1068
+ "",
1069
+ "| Boundary | Pixel overlap | Removed overlap | Match | Review |",
1070
+ "|---|---:|---:|---|---|",
1071
+ ]
1072
+ if len(chunks) == 1:
1073
+ lines.append("| none | 0 px | 0 | none | no |")
1074
+ for position, chunk in enumerate(chunks[:-1]):
1075
+ boundary = boundary_by_after.get(chunk.index, {})
1076
+ pixel_overlap = chunk.bottom_overlap + chunks[position + 1].top_overlap
1077
+ removed = int(boundary.get("removed_items", 0))
1078
+ unit = str(boundary.get("unit", "lines"))
1079
+ method = str(boundary.get("method", "none"))
1080
+ review = "yes" if pixel_overlap > 0 or method == "fuzzy" else "no"
1081
+ lines.append(
1082
+ f"| {chunk.index} -> {chunk.index + 1} | {pixel_overlap}px | "
1083
+ f"{removed} {unit} | {method} | {review} |"
1084
+ )
1085
+ lines.extend(
1086
+ [
1087
+ "",
1088
+ "Review every boundary marked `yes` against the two adjacent chunk images. "
1089
+ "The merger removes only exact or very high-confidence repeated lines or messages.",
1090
+ "",
1091
+ ]
1092
+ )
1093
+ atomic_write_text(path, "\n".join(lines))
1094
+
1095
+
1096
+ def parse_args() -> argparse.Namespace:
1097
+ parser = argparse.ArgumentParser(
1098
+ prog="long_screenshot_ocr",
1099
+ description="Safely split a tall screenshot, OCR each chunk with glance, and merge it",
1100
+ )
1101
+ parser.add_argument("input", type=Path, help="long screenshot path")
1102
+ parser.add_argument(
1103
+ "--mode",
1104
+ choices=("general", "chat"),
1105
+ default="general",
1106
+ help="content mode (default: general)",
1107
+ )
1108
+ parser.add_argument("-o", "--output", type=Path, help="merged Markdown output path")
1109
+ parser.add_argument("--chunks-dir", type=Path, help="chunk images and audit directory")
1110
+ parser.add_argument("--target-height", type=int, help="preferred core chunk height")
1111
+ parser.add_argument("--min-height", type=int, help="minimum core chunk height")
1112
+ parser.add_argument("--max-height", type=int, help="maximum core chunk height")
1113
+ parser.add_argument(
1114
+ "--overlap",
1115
+ type=int,
1116
+ help="fallback pixel overlap when no safe low-content cut band is found",
1117
+ )
1118
+ parser.add_argument("--prompt", help="additional OCR requirements passed to glance")
1119
+ parser.add_argument(
1120
+ "--jobs",
1121
+ type=int,
1122
+ default=2,
1123
+ help="parallel glance processes (default: 2)",
1124
+ )
1125
+ parser.add_argument(
1126
+ "--timeout",
1127
+ type=float,
1128
+ default=180,
1129
+ help="timeout in seconds for each glance call (default: 180)",
1130
+ )
1131
+ parser.add_argument(
1132
+ "--split-only",
1133
+ action="store_true",
1134
+ help="write chunks and manifest without calling the vision API",
1135
+ )
1136
+ parser.add_argument(
1137
+ "--resume",
1138
+ action="store_true",
1139
+ help="reuse OCR sidecars whose chunk, mode, and prompt fingerprint still matches",
1140
+ )
1141
+ return parser.parse_args()
1142
+
1143
+
1144
+ def main() -> None:
1145
+ parser = argparse.ArgumentParser(add_help=False)
1146
+ args = parse_args()
1147
+ if Image is None:
1148
+ parser.exit(1, "long_screenshot_ocr: requires Pillow; install pillow first\n")
1149
+ if args.jobs <= 0:
1150
+ parser.exit(1, "long_screenshot_ocr: --jobs must be greater than zero\n")
1151
+ if args.timeout <= 0:
1152
+ parser.exit(1, "long_screenshot_ocr: --timeout must be greater than zero\n")
1153
+
1154
+ input_path = args.input.expanduser().resolve()
1155
+ if not input_path.is_file():
1156
+ parser.exit(1, f"long_screenshot_ocr: image not found: {input_path}\n")
1157
+ output_path = (
1158
+ args.output.expanduser().resolve()
1159
+ if args.output
1160
+ else input_path.with_name(input_path.stem + ".ocr.md")
1161
+ )
1162
+ chunks_dir = (
1163
+ args.chunks_dir.expanduser().resolve()
1164
+ if args.chunks_dir
1165
+ else input_path.with_name(input_path.stem + "_chunks")
1166
+ )
1167
+ manifest_path = chunks_dir / "manifest.json"
1168
+ if output_path == input_path:
1169
+ parser.exit(1, "long_screenshot_ocr: output must not overwrite the source image\n")
1170
+ if chunks_dir.exists() and not chunks_dir.is_dir():
1171
+ parser.exit(1, f"long_screenshot_ocr: chunks path is not a directory: {chunks_dir}\n")
1172
+ reserved_output = output_path.parent == chunks_dir and (
1173
+ output_path.name in {"manifest.json", "ocr_audit.md"}
1174
+ or re.fullmatch(r"chunk_\d+\..+", output_path.name)
1175
+ )
1176
+ if reserved_output:
1177
+ parser.exit(1, "long_screenshot_ocr: output conflicts with generated chunk artifacts\n")
1178
+
1179
+ try:
1180
+ with Image.open(input_path) as source:
1181
+ image = ImageOps.exif_transpose(source)
1182
+ image.load()
1183
+ split_sizes = resolve_split_sizes(
1184
+ image.width,
1185
+ args.mode,
1186
+ args.target_height,
1187
+ args.min_height,
1188
+ args.max_height,
1189
+ args.overlap,
1190
+ )
1191
+ target, minimum, maximum, overlap = split_sizes
1192
+ ranges, analysis = find_core_ranges(
1193
+ image,
1194
+ args.mode,
1195
+ target,
1196
+ minimum,
1197
+ maximum,
1198
+ )
1199
+ chunks = save_chunks(image, ranges, chunks_dir, overlap)
1200
+ write_manifest(
1201
+ manifest_path,
1202
+ input_path,
1203
+ image.size,
1204
+ args.mode,
1205
+ split_sizes,
1206
+ analysis,
1207
+ chunks,
1208
+ )
1209
+ if args.split_only:
1210
+ print(manifest_path)
1211
+ return
1212
+
1213
+ glance_command = resolve_glance_command()
1214
+ transcripts = recognize_chunks(
1215
+ chunks,
1216
+ glance_command,
1217
+ args.mode,
1218
+ args.prompt,
1219
+ args.timeout,
1220
+ args.jobs,
1221
+ args.resume,
1222
+ )
1223
+ output_text, boundaries = merge_transcripts(transcripts)
1224
+ atomic_write_text(output_path, output_text)
1225
+ audit_path = chunks_dir / "ocr_audit.md"
1226
+ write_audit(audit_path, input_path, chunks, boundaries)
1227
+ write_manifest(
1228
+ manifest_path,
1229
+ input_path,
1230
+ image.size,
1231
+ args.mode,
1232
+ split_sizes,
1233
+ analysis,
1234
+ chunks,
1235
+ transcripts,
1236
+ boundaries,
1237
+ output_path,
1238
+ )
1239
+ except (OSError, ValueError, FileNotFoundError, RuntimeError, subprocess.TimeoutExpired) as exc:
1240
+ parser.exit(1, f"long_screenshot_ocr: {exc}\n")
1241
+ print(output_path)
1242
+
1243
+
1244
+ if __name__ == "__main__":
1245
+ main()