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,216 @@
1
+ """Stages 6-7: merge the timelines and write the output bundle."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import shutil
7
+ from dataclasses import asdict
8
+ from datetime import datetime, timezone
9
+ from pathlib import Path
10
+
11
+ from ..config import Config
12
+ from .frames import FFmpegError, Frame, dedupe, extract_frames, probe_duration
13
+ from .audio import transcribe
14
+ from .logs import load_events
15
+ from .ocr import available_backend, read_text, relevant_lines
16
+
17
+
18
+ def format_timestamp(seconds: float) -> str:
19
+ """Seconds to MM:SS — the form used in the report timeline."""
20
+ minutes, secs = divmod(int(seconds), 60)
21
+ return f"{minutes:02d}:{secs:02d}"
22
+
23
+
24
+ HELD_THRESHOLD = 5.0
25
+ """Below this, a screen simply had nothing to do; above it, it is worth saying.
26
+
27
+ How long a state lasted is part of what happened, and the timeline cannot say
28
+ it any other way: on a real capture whose last frame stood for 100 seconds, a
29
+ list that stopped at 00:37 read as if the recording had ended there. Reporting
30
+ the duration lets the reader judge whether that stillness means anything.
31
+ """
32
+
33
+
34
+ def _describe_hold(seconds: float) -> str:
35
+ minutes, secs = divmod(int(seconds), 60)
36
+ return f"{minutes}m{secs:02d}s" if minutes else f"{secs}s"
37
+
38
+
39
+ def _publish_frames(
40
+ kept: list[Frame], frames_dir: Path, cfg: Config, duration: float | None = None
41
+ ) -> tuple[list[dict], str | None]:
42
+ """Copy kept frames to their final names, OCR them, return their metadata."""
43
+ if frames_dir.exists():
44
+ shutil.rmtree(frames_dir)
45
+ frames_dir.mkdir(parents=True)
46
+
47
+ backend = available_backend() if cfg.ocr else None
48
+ published: list[dict] = []
49
+ previous_text: list[str] = []
50
+
51
+ for i, frame in enumerate(kept, start=1):
52
+ ends_at = kept[i].timestamp if i < len(kept) else duration
53
+ held = (ends_at - frame.timestamp) if ends_at is not None else 0.0
54
+ stamp = format_timestamp(frame.timestamp).replace(":", "-")
55
+ name = f"frame_{i:03d}_{stamp}.png"
56
+ shutil.copy2(frame.path, frames_dir / name)
57
+
58
+ text: list[str] = []
59
+ if backend:
60
+ text = read_text(frame.path, cfg.ocr_upscale, backend)
61
+
62
+ published.append({
63
+ "index": i,
64
+ "timestamp": round(frame.timestamp, 3),
65
+ "time": format_timestamp(frame.timestamp),
66
+ "change": int(frame.change),
67
+ "file": f"frames/{name}",
68
+ "held": round(held, 1),
69
+ "text": text,
70
+ "highlights": relevant_lines(text, previous_text, cfg.ocr_max_lines),
71
+ })
72
+ if text:
73
+ previous_text = text
74
+
75
+ return published, backend
76
+
77
+
78
+ _KIND_ORDER = {"frame": 0, "log": 1, "speech": 2}
79
+
80
+
81
+ def merge_timeline(
82
+ frames: list[dict], speech: list[dict], logs: list[dict] | None = None
83
+ ) -> list[dict]:
84
+ """Interleave the streams into one clock-ordered list.
85
+
86
+ On a tie the frame comes first, then logs, then narration: the picture is
87
+ what the other two are describing, and a log explains the frame more
88
+ directly than someone saying "look, it broke".
89
+ """
90
+ events = [{**f, "kind": "frame"} for f in frames]
91
+ events += [{**s, "kind": "speech"} for s in speech]
92
+ events += [{**entry, "kind": "log"} for entry in logs or []]
93
+ return sorted(events, key=lambda e: (e["timestamp"], _KIND_ORDER[e["kind"]]))
94
+
95
+
96
+ def render_report(
97
+ video: Path,
98
+ duration: float | None,
99
+ frames: list[dict],
100
+ speech: list[dict] | None = None,
101
+ logs: list[dict] | None = None,
102
+ ) -> str:
103
+ """Chronological markdown an agent can read top to bottom."""
104
+ speech, logs = speech or [], logs or []
105
+ header = [
106
+ f"# Bug context — {video.name}",
107
+ "",
108
+ f"- Source: `{video}`",
109
+ f"- Duration: {format_timestamp(duration)}" if duration else "- Duration: unknown",
110
+ f"- Key frames: {len(frames)}",
111
+ ]
112
+ if speech:
113
+ header.append(f"- Narration segments: {len(speech)}")
114
+ if logs:
115
+ header.append(f"- Log entries: {len(logs)}")
116
+ header += ["", "## Timeline", ""]
117
+
118
+ if not (frames or speech or logs):
119
+ return "\n".join([*header, "_Nothing extracted._", ""])
120
+
121
+ lines = []
122
+ for event in merge_timeline(frames, speech, logs):
123
+ if event["kind"] == "frame":
124
+ reason = "start of recording" if event["index"] == 1 else "screen changed"
125
+ held = event.get("held", 0)
126
+ if held >= HELD_THRESHOLD:
127
+ reason += f", then unchanged for {_describe_hold(held)}"
128
+ lines.append(f"- `[{event['time']}]` 🖼 `{event['file']}` — {reason}")
129
+ for text in event.get("highlights", []):
130
+ lines.append(f" OCR: {text}")
131
+ elif event["kind"] == "log":
132
+ repeats = event.get("repeats", 1)
133
+ suffix = f" (×{repeats})" if repeats > 1 else ""
134
+ lines.append(
135
+ f"- `[{event['time']}]` 📋 {event['level']}/{event['tag']}: "
136
+ f"{event['message']}{suffix}"
137
+ )
138
+ else:
139
+ lines.append(f"- `[{event['time']}]` 🎙 \"{event['text']}\"")
140
+ return "\n".join([*header, *lines, ""])
141
+
142
+
143
+ def build_bundle(video: Path, out_dir: Path, cfg: Config) -> Path:
144
+ """Run the video pipeline end to end. Returns the path to report.md."""
145
+ out_dir.mkdir(parents=True, exist_ok=True)
146
+ raw_dir = out_dir / ".raw"
147
+
148
+ duration = probe_duration(video)
149
+ if cfg.max_duration and duration and duration > cfg.max_duration:
150
+ raise FFmpegError(
151
+ f"{video.name} runs {duration / 60:.0f} min, over the "
152
+ f"{cfg.max_duration / 60:.0f} min guard — it would spend longer "
153
+ "decoding than you want to wait.\n"
154
+ "Trim to the part that matters:\n"
155
+ f' ffmpeg -i "{video}" -ss 00:00:00 -t 120 -c copy trimmed.mp4\n'
156
+ "Or pass --max-duration 0 to process it anyway."
157
+ )
158
+
159
+ frames = extract_frames(video, raw_dir, cfg)
160
+ kept = dedupe(frames, cfg)
161
+ entries, backend = _publish_frames(kept, out_dir / "frames", cfg, duration)
162
+ shutil.rmtree(raw_dir, ignore_errors=True)
163
+
164
+ speech: list[dict] = []
165
+ if cfg.transcribe:
166
+ speech = [
167
+ {
168
+ "timestamp": s.start,
169
+ "time": format_timestamp(s.start),
170
+ "end": s.end,
171
+ "text": s.text,
172
+ }
173
+ for s in transcribe(video, cfg.whisper_model, cfg.language)
174
+ ]
175
+
176
+ logs: list[dict] = []
177
+ if cfg.logcat_path:
178
+ started_at = (
179
+ datetime.fromisoformat(cfg.video_started_at)
180
+ if cfg.video_started_at
181
+ else None
182
+ )
183
+ logs = [
184
+ {
185
+ "timestamp": round(offset, 3),
186
+ "time": format_timestamp(offset),
187
+ "level": line.level,
188
+ "tag": line.tag,
189
+ "message": line.message,
190
+ "pid": line.pid,
191
+ "repeats": line.repeats,
192
+ }
193
+ for offset, line in load_events(
194
+ Path(cfg.logcat_path).expanduser(), started_at, duration,
195
+ cfg.log_levels, cfg.log_tag, cfg.log_offset,
196
+ pid=cfg.log_pid, limit=cfg.log_max_lines,
197
+ )
198
+ ]
199
+
200
+ report = render_report(video, duration, entries, speech, logs)
201
+ report_path = out_dir / "report.md"
202
+ report_path.write_text(report)
203
+
204
+ (out_dir / "meta.json").write_text(json.dumps({
205
+ "video": str(video),
206
+ "duration": duration,
207
+ "generated_at": datetime.now(timezone.utc).isoformat(),
208
+ "config": asdict(cfg),
209
+ "ocr_backend": backend,
210
+ "frames_examined": len(frames),
211
+ "frames": entries,
212
+ "speech": speech,
213
+ "logs": logs,
214
+ }, indent=2))
215
+
216
+ return report_path
@@ -0,0 +1,94 @@
1
+ """Stage 4: turn spoken narration into timestamped text.
2
+
3
+ Optional at every step: no audio track, a silent track, or no faster-whisper
4
+ installed all degrade to "no narration" rather than failing the run.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import re
10
+ import shutil
11
+ import subprocess
12
+ from dataclasses import dataclass
13
+ from pathlib import Path
14
+
15
+ _MEAN_VOLUME = re.compile(r"mean_volume:\s*(-?\d+(?:\.\d+)?) dB")
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class Speech:
20
+ """One spoken segment, positioned on the video clock."""
21
+
22
+ start: float
23
+ end: float
24
+ text: str
25
+
26
+
27
+ def whisper_available() -> bool:
28
+ try:
29
+ import faster_whisper # noqa: F401
30
+ except ImportError:
31
+ return False
32
+ return True
33
+
34
+
35
+ def has_audio_track(video: Path) -> bool:
36
+ if shutil.which("ffprobe") is None:
37
+ return False
38
+ proc = subprocess.run(
39
+ ["ffprobe", "-v", "error", "-select_streams", "a",
40
+ "-show_entries", "stream=index", "-of", "csv=p=0", str(video)],
41
+ capture_output=True, text=True,
42
+ )
43
+ return bool(proc.stdout.strip())
44
+
45
+
46
+ def mean_volume_db(video: Path) -> float | None:
47
+ """Average loudness of the audio track, or None if it cannot be measured."""
48
+ proc = subprocess.run(
49
+ ["ffmpeg", "-hide_banner", "-nostdin", "-i", str(video),
50
+ "-af", "volumedetect", "-f", "null", "-"],
51
+ capture_output=True, text=True,
52
+ )
53
+ match = _MEAN_VOLUME.search(proc.stderr)
54
+ return float(match.group(1)) if match else None
55
+
56
+
57
+ def is_silent(video: Path, threshold_db: float = -50.0) -> bool:
58
+ """Whether the track is quiet enough that transcribing it is wasted work.
59
+
60
+ Whisper hallucinates confident sentences out of silence, so this guard is
61
+ about output quality, not only speed.
62
+ """
63
+ volume = mean_volume_db(video)
64
+ return volume is None or volume < threshold_db
65
+
66
+
67
+ def extract_audio(video: Path, out_wav: Path) -> Path:
68
+ """Write the audio track as 16 kHz mono WAV — what whisper wants anyway."""
69
+ out_wav.parent.mkdir(parents=True, exist_ok=True)
70
+ subprocess.run(
71
+ ["ffmpeg", "-hide_banner", "-nostdin", "-y", "-i", str(video),
72
+ "-vn", "-ac", "1", "-ar", "16000", str(out_wav)],
73
+ capture_output=True, text=True, check=True,
74
+ )
75
+ return out_wav
76
+
77
+
78
+ def transcribe(
79
+ video: Path, model_size: str = "small", language: str | None = None
80
+ ) -> list[Speech]:
81
+ """Timestamped narration. Empty when there is nothing worth transcribing."""
82
+ if not whisper_available() or not has_audio_track(video) or is_silent(video):
83
+ return []
84
+
85
+ from faster_whisper import WhisperModel
86
+
87
+ model = WhisperModel(model_size, device="cpu", compute_type="int8")
88
+ segments, _ = model.transcribe(str(video), language=language, vad_filter=True)
89
+
90
+ return [
91
+ Speech(round(s.start, 3), round(s.end, 3), s.text.strip())
92
+ for s in segments
93
+ if s.text.strip()
94
+ ]
@@ -0,0 +1,185 @@
1
+ """Stages 1-2: extract candidate frames from a video, then deduplicate them."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ import shutil
7
+ import subprocess
8
+ from dataclasses import dataclass
9
+ from pathlib import Path
10
+
11
+ import imagehash
12
+ from PIL import Image
13
+
14
+ from ..config import Config
15
+
16
+ _PTS_TIME = re.compile(r"pts_time:(\d+(?:\.\d+)?)")
17
+
18
+ SECONDS_PER_FRAME = 2.0
19
+ MIN_FRAME_BUDGET = 20
20
+ MAX_FRAME_BUDGET = 40
21
+ """Bounds on the automatic frame budget.
22
+
23
+ Below the minimum a short recording loses states it barely has; above the
24
+ maximum OCR alone would run over a minute, since Vision charges a flat ~1.6 s
25
+ per frame regardless of size.
26
+ """
27
+
28
+
29
+ def frame_budget(span: float, configured: int | None) -> int:
30
+ """How many frames to publish for a recording of `span` seconds."""
31
+ if configured is not None:
32
+ return configured
33
+ scaled = round(span / SECONDS_PER_FRAME)
34
+ return int(min(MAX_FRAME_BUDGET, max(MIN_FRAME_BUDGET, scaled)))
35
+
36
+
37
+ @dataclass(frozen=True)
38
+ class Frame:
39
+ """A candidate frame: its position in the video and where it was written."""
40
+
41
+ timestamp: float
42
+ path: Path
43
+ change: int = 0
44
+ """Perceptual distance from the previously kept frame; 0 for the first."""
45
+
46
+
47
+ class FFmpegError(RuntimeError):
48
+ pass
49
+
50
+
51
+ def require_ffmpeg() -> None:
52
+ if shutil.which("ffmpeg") is None:
53
+ raise FFmpegError(
54
+ "ffmpeg not found on PATH. Install it (macOS: brew install ffmpeg)."
55
+ )
56
+
57
+
58
+ def probe_duration(video: Path) -> float | None:
59
+ """Video duration in seconds, or None if ffprobe is unavailable/fails."""
60
+ if shutil.which("ffprobe") is None:
61
+ return None
62
+ proc = subprocess.run(
63
+ ["ffprobe", "-v", "error", "-show_entries", "format=duration",
64
+ "-of", "default=nw=1:nk=1", str(video)],
65
+ capture_output=True, text=True,
66
+ )
67
+ try:
68
+ return float(proc.stdout.strip())
69
+ except ValueError:
70
+ return None
71
+
72
+
73
+ def _select_expr(cfg: Config) -> str:
74
+ # `+` is OR here: first frame, OR a scene cut, OR interval elapsed since the
75
+ # last kept frame. The interval fills gaps in screens that animate slowly.
76
+ return (
77
+ f"isnan(prev_selected_t)"
78
+ f"+gt(scene,{cfg.scene_threshold})"
79
+ f"+gte(t-prev_selected_t,{cfg.interval_seconds})"
80
+ )
81
+
82
+
83
+ def extract_frames(video: Path, out_dir: Path, cfg: Config) -> list[Frame]:
84
+ """Write candidate frames as PNGs into out_dir, ordered by timestamp."""
85
+ require_ffmpeg()
86
+ out_dir.mkdir(parents=True, exist_ok=True)
87
+ for stale in out_dir.glob("raw_*.png"):
88
+ stale.unlink()
89
+
90
+ proc = subprocess.run(
91
+ [
92
+ "ffmpeg", "-hide_banner", "-nostdin", "-y",
93
+ "-i", str(video),
94
+ "-vf", f"select='{_select_expr(cfg)}',showinfo",
95
+ # passthrough, not vfr: vfr drops frames whose timestamps collide
96
+ # after rounding, and screen recorders emit variable frame rate. On
97
+ # a real 720x1612 adb capture that silently lost 29 of 118 frames.
98
+ "-fps_mode", "passthrough",
99
+ str(out_dir / "raw_%04d.png"),
100
+ ],
101
+ capture_output=True, text=True,
102
+ )
103
+ if proc.returncode != 0:
104
+ raise FFmpegError(f"ffmpeg failed on {video}:\n{proc.stderr[-2000:]}")
105
+
106
+ timestamps = [float(m) for m in _PTS_TIME.findall(proc.stderr)]
107
+ paths = sorted(out_dir.glob("raw_*.png"))
108
+ if len(timestamps) != len(paths):
109
+ # showinfo logs one line per emitted frame; a mismatch means we cannot
110
+ # trust the pairing, and wrong timestamps are worse than none.
111
+ raise FFmpegError(
112
+ f"timestamp/frame mismatch: {len(timestamps)} pts_time vs {len(paths)} files"
113
+ )
114
+ return [Frame(ts, p) for ts, p in zip(timestamps, paths)]
115
+
116
+
117
+ def dedupe(frames: list[Frame], cfg: Config) -> list[Frame]:
118
+ """Drop near-identical frames, then cap to the most visually distinct ones.
119
+
120
+ Scene cuts and interval sampling both over-produce: a static screen sampled
121
+ every 3 s yields the same picture repeatedly. Perceptual hashing collapses
122
+ those, and the leftover distance doubles as a "how much changed" score for
123
+ the cap, so no separate scene-score bookkeeping is needed.
124
+ """
125
+ if not frames:
126
+ return []
127
+
128
+ hashes = [imagehash.phash(Image.open(f.path)) for f in frames]
129
+ kept = [frames[0]]
130
+ last = 0
131
+ for i in range(1, len(frames)):
132
+ distance = hashes[i] - hashes[last]
133
+ if distance > cfg.phash_distance:
134
+ kept.append(Frame(frames[i].timestamp, frames[i].path, distance))
135
+ last = i
136
+
137
+ # The last candidate's timestamp is the recording's span, near enough to
138
+ # its duration and already at hand — no need to probe the file again.
139
+ limit = frame_budget(frames[-1].timestamp, cfg.max_frames)
140
+ if len(kept) <= limit:
141
+ return kept
142
+ return _spread_over_time(kept, limit)
143
+
144
+
145
+ def _spread_over_time(kept: list[Frame], limit: int) -> list[Frame]:
146
+ """Trim to `limit` frames while still covering the whole recording.
147
+
148
+ Ranking purely by how much changed sounds right and fails badly: an app
149
+ with a live map animates constantly, so on a real 143 s capture the top 20
150
+ changes all landed in the first 27 s and the remaining 100 s went
151
+ unrepresented. The reader could not tell whether nothing happened or
152
+ nothing was captured.
153
+
154
+ Deciding which moment matters is the reader's job, not this function's.
155
+ The budget is therefore split into equal time windows, taking the
156
+ most-changed frame of each, so the report describes the whole recording.
157
+ Windows that hold nothing give their slot back to the most-changed frames
158
+ still unused.
159
+ """
160
+ first, rest = kept[0], kept[1:]
161
+ slots = limit - 1
162
+ if slots <= 0:
163
+ return [first]
164
+
165
+ span = rest[-1].timestamp - first.timestamp
166
+ if span <= 0:
167
+ return [first, *rest[:slots]]
168
+
169
+ windows: dict[int, Frame] = {}
170
+ for frame in rest:
171
+ index = min(int((frame.timestamp - first.timestamp) / span * slots), slots - 1)
172
+ if index not in windows or frame.change > windows[index].change:
173
+ windows[index] = frame
174
+
175
+ chosen = list(windows.values())
176
+ if len(chosen) < slots:
177
+ picked = {id(frame) for frame in chosen}
178
+ spare = sorted(
179
+ (f for f in rest if id(f) not in picked),
180
+ key=lambda f: f.change,
181
+ reverse=True,
182
+ )
183
+ chosen += spare[: slots - len(chosen)]
184
+
185
+ return sorted([first, *chosen], key=lambda f: f.timestamp)
@@ -0,0 +1,187 @@
1
+ """Stage 5: put device logs on the video's clock.
2
+
3
+ logcat timestamps are wall clock and carry no year; the video clock starts at
4
+ zero. Aligning them is the whole job — a stack trace is only useful if it lands
5
+ next to the frame that shows the crash.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+ from dataclasses import dataclass, replace
12
+ from datetime import datetime
13
+ from pathlib import Path
14
+
15
+ # `-v time`: 08-04 07:45:12.345 E/RideService( 1234): boom
16
+ # `-v threadtime`: 08-04 07:45:12.345 1234 1250 E RideService: boom
17
+ _TIME = re.compile(
18
+ r"^(?P<date>\d{2}-\d{2})\s+(?P<clock>\d{2}:\d{2}:\d{2}\.\d{3})\s+"
19
+ r"(?P<level>[VDIWEFS])/(?P<tag>[^(]+?)\s*\(\s*(?P<pid>\d+)\s*\):\s?(?P<message>.*)$"
20
+ )
21
+ _THREADTIME = re.compile(
22
+ r"^(?P<date>\d{2}-\d{2})\s+(?P<clock>\d{2}:\d{2}:\d{2}\.\d{3})\s+"
23
+ r"(?P<pid>\d+)\s+\d+\s+(?P<level>[VDIWEFS])\s+(?P<tag>\S+?)\s*:\s?(?P<message>.*)$"
24
+ )
25
+
26
+ DEFAULT_LEVELS = "WEF"
27
+ _SEVERITY = "VDIWEFS"
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class LogLine:
32
+ """One device log entry, still on wall-clock time."""
33
+
34
+ when: datetime
35
+ level: str
36
+ tag: str
37
+ message: str
38
+ pid: int = 0
39
+ repeats: int = 1
40
+ """How many identical entries in a row this one stands for."""
41
+
42
+
43
+ def parse_logcat(text: str, year: int | None = None) -> list[LogLine]:
44
+ """Parse `-v time` or `-v threadtime` output, skipping anything else.
45
+
46
+ logcat omits the year, so one is assumed — pass it explicitly when
47
+ processing a recording from a previous year.
48
+ """
49
+ year = year or datetime.now().year
50
+ lines: list[LogLine] = []
51
+
52
+ for raw in text.splitlines():
53
+ match = _THREADTIME.match(raw) or _TIME.match(raw)
54
+ if not match:
55
+ continue # banners like "--------- beginning of main"
56
+ try:
57
+ when = datetime.strptime(
58
+ f"{year}-{match['date']} {match['clock']}", "%Y-%m-%d %H:%M:%S.%f"
59
+ )
60
+ except ValueError:
61
+ continue
62
+ lines.append(
63
+ LogLine(
64
+ when,
65
+ match["level"],
66
+ match["tag"].strip(),
67
+ match["message"],
68
+ int(match["pid"]),
69
+ )
70
+ )
71
+ return lines
72
+
73
+
74
+ # A crash report from the app's own runtime, even when it is another process.
75
+ _ALWAYS_KEEP_TAGS = frozenset({"AndroidRuntime", "DEBUG"})
76
+
77
+
78
+ def filter_lines(
79
+ lines: list[LogLine],
80
+ levels: str = DEFAULT_LEVELS,
81
+ tag: str | None = None,
82
+ pid: int | None = None,
83
+ ) -> list[LogLine]:
84
+ """Keep entries at or above the given levels, optionally by tag or process.
85
+
86
+ Level alone is not enough on a real device: a 2 min capture from a phone
87
+ held 7,634 W/E/F lines, none of them from the app under test. `pid` is what
88
+ scopes the report to the app — measured 35 lines for the same capture.
89
+ """
90
+ wanted = {level.upper() for level in levels}
91
+ floor = min((_SEVERITY.index(level) for level in wanted), default=0)
92
+ needle = tag.casefold() if tag else None
93
+
94
+ return [
95
+ line
96
+ for line in lines
97
+ if _SEVERITY.index(line.level) >= floor
98
+ and (needle is None or needle in line.tag.casefold())
99
+ and (pid is None or line.pid == pid or line.tag in _ALWAYS_KEEP_TAGS)
100
+ ]
101
+
102
+
103
+ def collapse_repeats(
104
+ events: list[tuple[float, LogLine]]
105
+ ) -> list[tuple[float, LogLine]]:
106
+ """Fold consecutive identical entries into one carrying a count.
107
+
108
+ Framework chatter arrives in bursts — one real capture had the same Flogger
109
+ warning 15 times in a single second. Printing it 15 times says nothing the
110
+ first line did not, and buries the entries that matter.
111
+ """
112
+ folded: list[tuple[float, LogLine]] = []
113
+ for offset, line in events:
114
+ if folded:
115
+ _, previous = folded[-1]
116
+ if (previous.level, previous.tag, previous.message) == (
117
+ line.level, line.tag, line.message
118
+ ):
119
+ folded[-1] = (
120
+ folded[-1][0],
121
+ replace(previous, repeats=previous.repeats + 1),
122
+ )
123
+ continue
124
+ folded.append((offset, line))
125
+ return folded
126
+
127
+
128
+ def cap_lines(lines: list[LogLine], limit: int) -> list[LogLine]:
129
+ """Trim to the most severe entries, then restore chronological order.
130
+
131
+ A blind head(limit) on a busy device returns the first seconds of system
132
+ boot chatter and none of the crash.
133
+ """
134
+ if limit <= 0 or len(lines) <= limit:
135
+ return lines
136
+ severest = sorted(lines, key=lambda l: _SEVERITY.index(l.level), reverse=True)
137
+ return sorted(severest[:limit], key=lambda l: l.when)
138
+
139
+
140
+ def align_to_video(
141
+ lines: list[LogLine], video_started_at: datetime, offset: float = 0.0
142
+ ) -> list[tuple[float, LogLine]]:
143
+ """Convert wall clock to seconds-into-the-video.
144
+
145
+ `offset` is the manual escape for when the device clock and the recorder
146
+ disagree: positive shifts logs later in the video.
147
+ """
148
+ positioned = [
149
+ ((line.when - video_started_at).total_seconds() + offset, line)
150
+ for line in lines
151
+ ]
152
+ return sorted(
153
+ (item for item in positioned if item[0] >= 0), key=lambda item: item[0]
154
+ )
155
+
156
+
157
+ def load_events(
158
+ logcat_path: Path,
159
+ video_started_at: datetime | None,
160
+ duration: float | None = None,
161
+ levels: str = DEFAULT_LEVELS,
162
+ tag: str | None = None,
163
+ offset: float = 0.0,
164
+ year: int | None = None,
165
+ pid: int | None = None,
166
+ limit: int = 0,
167
+ ) -> list[tuple[float, LogLine]]:
168
+ """Read a logcat file and return entries positioned on the video clock.
169
+
170
+ Without a recording start time there is nothing to align against, so the
171
+ first log entry is assumed to be the moment recording began.
172
+ """
173
+ parsed = parse_logcat(logcat_path.read_text(errors="replace"), year)
174
+ lines = filter_lines(parsed, levels, tag, pid)
175
+ if not lines:
176
+ return []
177
+
178
+ anchor = video_started_at or min(line.when for line in lines)
179
+ events = align_to_video(lines, anchor, offset)
180
+ if duration is not None:
181
+ events = [item for item in events if item[0] <= duration + 1]
182
+
183
+ events = collapse_repeats(events)
184
+
185
+ # Cap after trimming to the video, so the budget is spent on what is in shot.
186
+ kept = set(map(id, cap_lines([line for _, line in events], limit)))
187
+ return [item for item in events if id(item[1]) in kept]