bug2context 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.
@@ -0,0 +1,207 @@
1
+ """Stage 3: read on-screen text from frames.
2
+
3
+ Two backends, picked at runtime: Apple's Vision framework on macOS (better on
4
+ UI text, offline, no install) and tesseract everywhere else. Both are optional
5
+ — with neither available the pipeline still produces a frames-only bundle.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import hashlib
11
+ import json
12
+ import re
13
+ import shutil
14
+ import tempfile
15
+ from pathlib import Path
16
+
17
+ from PIL import Image
18
+
19
+ _ERROR_HINTS = re.compile(
20
+ r"\b(error|exception|fatal|crash|crashed|failed|failure|warning|traceback|"
21
+ r"undefined|null|nan|denied|unauthorized|forbidden|timeout|timed out|"
22
+ r"offline|no internet|not found|invalid|unable to|cannot|can't)\b",
23
+ re.IGNORECASE,
24
+ )
25
+ _STACK_FRAME = re.compile(r"\w+\.(kt|java|dart|swift|py|js|ts|rb|go|php):\d+")
26
+ _HTTP_ERROR = re.compile(r"\b[45]\d{2}\b")
27
+
28
+
29
+ def _vision_available() -> bool:
30
+ try:
31
+ import Vision # noqa: F401
32
+ from Foundation import NSURL # noqa: F401
33
+ except ImportError:
34
+ return False
35
+ return True
36
+
37
+
38
+ def available_backend() -> str | None:
39
+ """Name of the OCR backend that will be used, or None if there is none."""
40
+ if _vision_available():
41
+ return "vision"
42
+ if shutil.which("tesseract") is not None:
43
+ try:
44
+ import pytesseract # noqa: F401
45
+ except ImportError:
46
+ return None
47
+ return "tesseract"
48
+ return None
49
+
50
+
51
+ def _read_vision(path: Path) -> list[str]:
52
+ import Vision
53
+ from Foundation import NSURL
54
+
55
+ handler = Vision.VNImageRequestHandler.alloc().initWithURL_options_(
56
+ NSURL.fileURLWithPath_(str(path)), None
57
+ )
58
+ request = Vision.VNRecognizeTextRequest.alloc().init()
59
+ request.setRecognitionLevel_(Vision.VNRequestTextRecognitionLevelAccurate)
60
+ request.setUsesLanguageCorrection_(False) # keep identifiers like RideRequest.kt
61
+ handler.performRequests_error_([request], None)
62
+
63
+ lines = []
64
+ for observation in request.results() or []:
65
+ candidates = observation.topCandidates_(1)
66
+ if candidates:
67
+ lines.append(str(candidates[0].string()))
68
+ return lines
69
+
70
+
71
+ def _read_tesseract(path: Path) -> list[str]:
72
+ import pytesseract
73
+
74
+ text = pytesseract.image_to_string(Image.open(path))
75
+ return [line.strip() for line in text.splitlines() if line.strip()]
76
+
77
+
78
+ CACHE_DIR = Path("~/.cache/bug2context/ocr").expanduser()
79
+ """Where recognised text is remembered between runs.
80
+
81
+ Reading a frame costs a flat ~1.6 s and dominates everything else, while the
82
+ frames themselves are reproducible: extracting the same video twice was
83
+ measured to yield byte-identical PNGs. So the same picture never has to be
84
+ read twice, which is what makes re-running a video with different settings
85
+ cheap. Entries are a few hundred bytes of text; delete the directory to reset.
86
+ """
87
+
88
+
89
+ def _cache_key(data: bytes, backend: str, upscale: int) -> str:
90
+ return f"{hashlib.sha256(data).hexdigest()}-{backend}-{upscale}"
91
+
92
+
93
+ def _cache_read(key: str) -> list[str] | None:
94
+ try:
95
+ return json.loads((CACHE_DIR / f"{key}.json").read_text())
96
+ except (OSError, json.JSONDecodeError):
97
+ return None # a cold or damaged cache is not an error
98
+
99
+
100
+ def _cache_write(key: str, lines: list[str]) -> None:
101
+ try:
102
+ CACHE_DIR.mkdir(parents=True, exist_ok=True)
103
+ (CACHE_DIR / f"{key}.json").write_text(json.dumps(lines))
104
+ except OSError:
105
+ pass # an unwritable cache must never fail the run
106
+
107
+
108
+ UPSCALE_BELOW_WIDTH = 1600
109
+ """Frames at least this wide are read as they are.
110
+
111
+ Upscaling exists to rescue text that recompression smeared on a small frame.
112
+ On a 2880x1864 desktop capture it changed nothing measurable (198 lines either
113
+ way) while building a 5760x3728 image per frame — pure waste.
114
+ """
115
+
116
+
117
+ def read_text(
118
+ path: Path, upscale: int = 1, backend: str | None = None, cache: bool = True
119
+ ) -> list[str]:
120
+ """Text lines visible in the frame, in reading order. Empty if no backend."""
121
+ backend = backend or available_backend()
122
+ if backend is None:
123
+ return []
124
+
125
+ key = None
126
+ if cache:
127
+ key = _cache_key(path.read_bytes(), backend, upscale)
128
+ remembered = _cache_read(key)
129
+ if remembered is not None:
130
+ return remembered
131
+
132
+ lines = _recognise(path, upscale, backend)
133
+ if key is not None:
134
+ _cache_write(key, lines)
135
+ return lines
136
+
137
+
138
+ def _recognise(path: Path, upscale: int, backend: str) -> list[str]:
139
+ reader = _read_vision if backend == "vision" else _read_tesseract
140
+ if upscale <= 1:
141
+ return reader(path)
142
+
143
+ image = Image.open(path)
144
+ if image.width >= UPSCALE_BELOW_WIDTH:
145
+ return reader(path)
146
+
147
+ with tempfile.TemporaryDirectory() as tmp:
148
+ enlarged = Path(tmp) / "upscaled.png"
149
+ image.resize(
150
+ (image.width * upscale, image.height * upscale), Image.LANCZOS
151
+ ).save(enlarged)
152
+ return reader(enlarged)
153
+
154
+
155
+ def _normalize(line: str) -> str:
156
+ return re.sub(r"\s+", " ", line).strip().casefold()
157
+
158
+
159
+ def is_fragment(line: str, min_letters: int = 3, letter_ratio: float = 0.4) -> bool:
160
+ """Whether a line is OCR debris rather than something someone wrote.
161
+
162
+ Screen recordings are full of icons, signal bars and clocks, and OCR turns
163
+ them into things like `.lll (100 4`, `9011(1004` or `»`. Two cheap signals
164
+ separate them from real UI text: how many letters there are at all, and what
165
+ share of the line they make up.
166
+
167
+ Calibrated on 36 lines from a real Android capture: 34 correct, and both
168
+ misses were debris that leaked through rather than text that was lost —
169
+ losing a real line is the expensive direction.
170
+ """
171
+ if not line:
172
+ return True
173
+ letters = sum(character.isalpha() for character in line)
174
+ return letters < min_letters or letters / len(line) < letter_ratio
175
+
176
+
177
+ def relevant_lines(
178
+ lines: list[str], previous: list[str], limit: int = 12
179
+ ) -> list[str]:
180
+ """Pick the lines worth putting in the report.
181
+
182
+ Two things earn a line a place: it looks like a failure, or it is new since
183
+ the last frame. Screens are mostly chrome that repeats across every frame —
184
+ reprinting it buries the signal an agent is looking for.
185
+ """
186
+ seen = {_normalize(line) for line in previous}
187
+ picked: list[str] = []
188
+ already: set[str] = set()
189
+
190
+ for line in lines:
191
+ key = _normalize(line)
192
+ if not key or key in already:
193
+ continue
194
+ looks_like_failure = bool(
195
+ _ERROR_HINTS.search(line)
196
+ or _STACK_FRAME.search(line)
197
+ or _HTTP_ERROR.search(line)
198
+ )
199
+ # A failure overrides the fragment test: an error rendered oddly is
200
+ # still the most important thing on screen.
201
+ if not looks_like_failure and is_fragment(line):
202
+ continue
203
+ if looks_like_failure or key not in seen:
204
+ picked.append(line.strip())
205
+ already.add(key)
206
+
207
+ return picked[:limit]
@@ -0,0 +1,232 @@
1
+ Metadata-Version: 2.5
2
+ Name: bug2context
3
+ Version: 0.1.0
4
+ Summary: Turns bug screen recordings into structured, chronological context for AI agents.
5
+ Project-URL: Homepage, https://github.com/emanueld92/bug2context
6
+ Project-URL: Issues, https://github.com/emanueld92/bug2context/issues
7
+ Author: Emanuel Duran
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Keywords: bug-report,claude,debugging,llm,mcp,ocr,screen-recording
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Programming Language :: Python :: 3.13
16
+ Classifier: Topic :: Multimedia :: Video
17
+ Classifier: Topic :: Software Development :: Bug Tracking
18
+ Classifier: Typing :: Typed
19
+ Requires-Python: >=3.12
20
+ Requires-Dist: imagehash>=4.3
21
+ Requires-Dist: mcp>=1.2
22
+ Requires-Dist: pillow>=11.0
23
+ Requires-Dist: typer>=0.15
24
+ Provides-Extra: audio
25
+ Requires-Dist: faster-whisper>=1.1; extra == 'audio'
26
+ Provides-Extra: ocr
27
+ Requires-Dist: pyobjc-framework-vision>=10.3; (sys_platform == 'darwin') and extra == 'ocr'
28
+ Requires-Dist: pytesseract>=0.3.13; extra == 'ocr'
29
+ Description-Content-Type: text/markdown
30
+
31
+ # bug2context
32
+
33
+ Turns a screen recording of a bug into structured, chronological context an AI
34
+ agent can actually read.
35
+
36
+ Screenshots lose the story: bugs happen fast, a still frame has no timeline, and
37
+ the logs and the narration live somewhere else entirely. LLMs don't take video.
38
+ This distills a recording into one markdown chronology — key frames, the text on
39
+ screen, device logs and spoken narration, all on the same clock.
40
+
41
+ - **No SDK.** Works on any video, including one someone sent you over WhatsApp.
42
+ - **Local-first.** Nothing leaves your machine. No account, no API key, no cloud.
43
+ - **MCP-native.** Claude Code calls it as a tool; you just point at the file.
44
+
45
+ ```bash
46
+ uv run bug2context # guided menu
47
+ uv run bug2context process bug.mp4 --out bundle/
48
+ ```
49
+
50
+ Running it with no arguments opens a menu — record an Android device, process a
51
+ video you already have, or list previous bundles — and writes to
52
+ `~/.bug2context/`, which is where the MCP server looks. Everything it does is
53
+ reachable through the flags below; it just stops you having to remember them.
54
+
55
+ ## What comes out
56
+
57
+ ```
58
+ - `[00:00]` 🖼 `frames/frame_001_00-00.png` — start of recording
59
+ OCR: Cart 2 items
60
+ - `[00:04]` 🖼 `frames/frame_002_00-04.png` — screen changed
61
+ OCR: Applying discount..
62
+ - `[00:04]` 📋 W/DiscountEngine: rate field missing
63
+ - `[00:06]` 🖼 `frames/frame_003_00-06.png` — screen changed
64
+ OCR: TypeError: cannot read 'rate'
65
+ - `[00:06]` 📋 E/RideService: NullPointerException at RideRequest.kt:142
66
+ - `[00:06]` 📋 F/AndroidRuntime: FATAL EXCEPTION: main
67
+ - `[00:06]` 🎙 "ahí está, explota al aplicar el descuento"
68
+ ```
69
+
70
+ A full run is in [examples/report.md](examples/report.md).
71
+
72
+ ```
73
+ bundle/
74
+ ├── report.md the chronology above
75
+ ├── frames/ the key frames, named by timestamp
76
+ └── meta.json the same data, structured, for programmatic use
77
+ ```
78
+
79
+ ## Install
80
+
81
+ Requires [`ffmpeg`](https://ffmpeg.org) on PATH (`brew install ffmpeg`).
82
+
83
+ ```bash
84
+ git clone https://github.com/emanueld92/bug2context
85
+ cd bug2context
86
+ uv sync --extra ocr # OCR: Apple Vision on macOS, tesseract elsewhere
87
+ uv sync --extra audio # optional: spoken narration
88
+ ```
89
+
90
+ Every stage is optional and degrades quietly: no OCR backend still gives you
91
+ frames, no audio track still gives you the visual timeline.
92
+
93
+ ## Use it from Claude Code
94
+
95
+ ```bash
96
+ claude mcp add bug2context -- uvx --from /path/to/bug2context bug2context-mcp
97
+ ```
98
+
99
+ Then just ask: *"analiza este video del bug: ~/Desktop/crash.mp4"*.
100
+
101
+ | Tool | Purpose |
102
+ |---|---|
103
+ | `analyze_bug_video` | Video → chronological report (returns the text itself) |
104
+ | `get_frame` | Fetch one frame as an image so the agent can look at it |
105
+ | `list_bundles` | Previously processed bundles, newest first |
106
+
107
+ Bundles default to `~/.bug2context/<video>-<timestamp>/`.
108
+
109
+ ## Android: screen + logcat in one command
110
+
111
+ ```bash
112
+ uv run bug2context record --seconds 60 --package com.example.app --out bundle/
113
+ ```
114
+
115
+ Reproduce the bug while it records. Both streams start from a single host
116
+ timestamp — that anchor is what makes a stack trace land next to the frame
117
+ showing the crash.
118
+
119
+ **Pass `--package`.** Level filtering alone is not enough on a real phone: a
120
+ 2 min capture held 7,634 W/E/F lines and *none* came from the app under test —
121
+ they were all `AppOpsControllerImpl`, `GNSSMGT` and friends. Scoping to the
122
+ app's process left 35. Crash tags (`AndroidRuntime`, `DEBUG`) are kept
123
+ regardless of process, since a crash report is the whole point.
124
+
125
+ Logs are captured unfiltered and scoped afterwards, so an app that crashes and
126
+ restarts still has its death recorded. Consecutive identical entries fold into
127
+ one with a `(×N)` count — framework chatter arrives in bursts of fifteen.
128
+
129
+ Already have a log file? Merge it into any video:
130
+
131
+ ```bash
132
+ uv run bug2context process bug.mp4 --logcat logcat.txt --log-tag RideService
133
+ ```
134
+
135
+ Reads both `-v time` and `-v threadtime`. `--log-levels` defaults to `WEF` and
136
+ `--log-max-lines` to 80, ranked by severity so a cap never trades the crash for
137
+ boot chatter. `--log-pid` scopes to a process when you already know it. If the device clock and the
138
+ recorder disagree, `--log-offset` shifts everything by N seconds; with no
139
+ `--video-started-at`, the first log entry becomes the anchor.
140
+
141
+ `screenrecord` caps at 3 minutes, so `record` refuses longer rather than handing
142
+ back a silently truncated video. Ctrl-C cuts a recording short safely — the file
143
+ is finalised on the device first, because killing the local adb leaves one
144
+ `ffprobe` cannot open.
145
+
146
+ Videos over 10 minutes are refused with the `ffmpeg` command to trim them
147
+ (`--max-duration 0` overrides). Nothing scales badly with length except time,
148
+ but it scales linearly: decoding alone runs at about a third of real time.
149
+
150
+ ## Narration
151
+
152
+ ```bash
153
+ uv run bug2context process bug.mp4 --transcribe --language es
154
+ ```
155
+
156
+ Off by default: it downloads a model on first use and is the slowest stage,
157
+ while most recordings have no voice-over. Skipped automatically when there is no
158
+ audio track, or the track is quieter than −50 dB — whisper invents confident
159
+ sentences out of silence, so that guard is about output quality, not just speed.
160
+
161
+ ## How frames get picked
162
+
163
+ ffmpeg over-produces candidates (scene cuts **and** a fixed interval, so slowly
164
+ changing screens are not skipped), then perceptual hashing collapses the
165
+ near-duplicates. `--max-frames` caps the result, always keeping the opening frame
166
+ and then whichever changed most.
167
+
168
+ | Flag | Default | Notes |
169
+ |---|---|---|
170
+ | `--scene-threshold` | `0.08` | ffmpeg scene score for a cut |
171
+ | `--interval-seconds` | `1.0` | forced sample when no cut fires |
172
+ | `--max-frames` | auto | one frame per 2 s, between 20 and 40 |
173
+ | `--phash-distance` | `4` | below this, frames count as duplicates |
174
+ | `--ocr-upscale` | `2` | enlarge before OCR; skipped above 1600 px wide |
175
+
176
+ Measured end to end, OCR on:
177
+
178
+ | Source | Result | Time |
179
+ |---|---|---|
180
+ | synthetic 3 min, 1080×1920 | 19 frames | 8.5 s |
181
+ | Android capture, 2 min, 720×1612 @ ~12 fps | 118 candidates → 20 frames | 16 s |
182
+ | macOS capture, 90 s, 2880×1864 @ 60 fps | 97 candidates → 18 frames | 86 s |
183
+
184
+ Recognised text is cached by frame content in `~/.cache/bug2context/`, so
185
+ re-running a video with different settings only pays for what actually changed —
186
+ measured 9.0 s cold against 3.3 s warm on a 44 s clip, identical output.
187
+ Extracting the same video twice yields byte-identical frames, which is what
188
+ makes the cache safe. Delete the directory to reset it.
189
+
190
+ Retina desktop recordings are the slow case and there is no trick to remove:
191
+ the cost is decoding 60 fps at 5.4 megapixels (26 s) plus OCR, which Vision
192
+ charges at ~1.6 s per frame regardless of size. Budget roughly real time for
193
+ those; phone captures stay far under it.
194
+
195
+ On `--ocr-upscale`: a stack trace on a recompressed 1080×1920 frame read as
196
+ `Null PointerSxception Ride Requestkt14` at 1× and correctly as
197
+ `NullPointerException Ride Request kt 142` at 3×. Raise it for badly
198
+ recompressed sources — ask for the video as a file, not as a WhatsApp video.
199
+ It is skipped on frames already 1600 px or wider, where it changed nothing
200
+ measurable while building a 5760×3728 image per frame.
201
+
202
+ OCR also drops lines that are debris rather than text — `»`, `.lll (100 4`,
203
+ a misread status-bar clock. On a real capture that removed 41 of 210 report
204
+ lines and no real text. A line that looks like a failure is never dropped.
205
+
206
+ ## Known limitation
207
+
208
+ Perceptual hashing compares visual *structure*. It sees navigation, dialogs and
209
+ error banners, but it cannot detect a small **text-only** change on an otherwise
210
+ identical layout — measured on 1080×1920 those differences fall inside the noise
211
+ floor at every hash size, so no `--phash-distance` value separates them.
212
+
213
+ OCR does not rescue this: it runs on frames that survive deduplication, so a
214
+ screen already collapsed is never read. Reading every candidate instead measured
215
+ 48–59 s for a 3 min video, over the entire time budget. If your bug *is* a small
216
+ text change on an identical screen, record cropped or at lower resolution so the
217
+ text occupies more of the frame.
218
+
219
+ ## Development
220
+
221
+ ```bash
222
+ uv sync --extra ocr
223
+ uv run pytest -q
224
+ ```
225
+
226
+ 183 tests. Device recording is mocked on purpose — a test suite should not record
227
+ anyone's phone. Whisper is opt-in via `BUG2CONTEXT_TEST_WHISPER=1` so the suite
228
+ stays offline.
229
+
230
+ ## License
231
+
232
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,17 @@
1
+ bug2context/__init__.py,sha256=kUR5RAFc7HCeiqdlX36dZOHkUI5wI6V_43RpEcD8b-0,22
2
+ bug2context/android.py,sha256=XX19DZxQg-9IrehJD42FQGKSd9oQXo2HnyzBH1zbZtU,6162
3
+ bug2context/cli.py,sha256=ID6UovrcEHXu4yN60WV__BC8nmkuSU3gE6BBMa0vd1g,3330
4
+ bug2context/config.py,sha256=Bj9uhXk1N0kQV3HuvMXzu0YZRkN9uML83aADeNY57jQ,3742
5
+ bug2context/mcp_server.py,sha256=VSLaN355Ve2jPdzlThMppC9J3Fgp_74lgufuO9FRpck,3985
6
+ bug2context/menu.py,sha256=0iI0aOUYD8bi-_3212wWgMSRbgp-Eli9xjj5Ge97fUI,5189
7
+ bug2context/pipeline/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
8
+ bug2context/pipeline/assemble.py,sha256=jXRqQumUCgw4IUn7ftihfG9rHRKVHmKiki1EePZf3Ug,7716
9
+ bug2context/pipeline/audio.py,sha256=qICN0nlkCwx8z7uRp_OR_2FKRc-_6SbTivd2CKugWB4,2886
10
+ bug2context/pipeline/frames.py,sha256=73CxnecQKEh6ElTfXn8ndl4OKYrfPzl-0Pcc6bLpbe8,6469
11
+ bug2context/pipeline/logs.py,sha256=HrdUyRgYKTbrMZ6cI6l78oMFe7ilPBXar0CB1hiaK0g,6318
12
+ bug2context/pipeline/ocr.py,sha256=gZMkn7OJlAZ16McJd5QakpL0bd6gSQwD0DjfRp3IZqw,6909
13
+ bug2context-0.1.0.dist-info/METADATA,sha256=zB5TcOoYZyCbcH7mnrmn1UyIC9p4ppUjUNby2Br3Je4,9614
14
+ bug2context-0.1.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
15
+ bug2context-0.1.0.dist-info/entry_points.txt,sha256=tkAPuQLRV7d95vt6nfLlTDE64CHnSKOTuBu1TnQe2k8,98
16
+ bug2context-0.1.0.dist-info/licenses/LICENSE,sha256=S4R_xraCVRSr_RmXOEOlNHfHfsj0rru0OKndzGfIofw,1070
17
+ bug2context-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ bug2context = bug2context.cli:app
3
+ bug2context-mcp = bug2context.mcp_server:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Emanuel Duran
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.