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.
- bug2context/__init__.py +1 -0
- bug2context/android.py +179 -0
- bug2context/cli.py +90 -0
- bug2context/config.py +93 -0
- bug2context/mcp_server.py +119 -0
- bug2context/menu.py +160 -0
- bug2context/pipeline/__init__.py +0 -0
- bug2context/pipeline/assemble.py +216 -0
- bug2context/pipeline/audio.py +94 -0
- bug2context/pipeline/frames.py +185 -0
- bug2context/pipeline/logs.py +187 -0
- bug2context/pipeline/ocr.py +207 -0
- bug2context-0.1.0.dist-info/METADATA +232 -0
- bug2context-0.1.0.dist-info/RECORD +17 -0
- bug2context-0.1.0.dist-info/WHEEL +4 -0
- bug2context-0.1.0.dist-info/entry_points.txt +3 -0
- bug2context-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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]
|