bobframes 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.
- bobframes/__init__.py +3 -0
- bobframes/_version.py +1 -0
- bobframes/catalog.py +154 -0
- bobframes/cli.py +266 -0
- bobframes/derive_post_merge.py +365 -0
- bobframes/derives/__init__.py +0 -0
- bobframes/derives/pass_class_breakdown.py +102 -0
- bobframes/derives/texture_usage.py +121 -0
- bobframes/discovery.py +132 -0
- bobframes/global_entities.py +99 -0
- bobframes/html/__init__.py +0 -0
- bobframes/html/template.py +1056 -0
- bobframes/lint.py +114 -0
- bobframes/manifest.py +127 -0
- bobframes/parquetize.py +282 -0
- bobframes/parsers/__init__.py +0 -0
- bobframes/parsers/derive_program_transitions.py +73 -0
- bobframes/parsers/parse_init_state.py +675 -0
- bobframes/paths.py +111 -0
- bobframes/probes/__init__.py +0 -0
- bobframes/probes/whatif.py +165 -0
- bobframes/qrd_harness.py +119 -0
- bobframes/query_examples.py +222 -0
- bobframes/rdcmd.py +72 -0
- bobframes/replay/__init__.py +26 -0
- bobframes/replay/replay_main.py +2305 -0
- bobframes/reports/__init__.py +0 -0
- bobframes/reports/_dashboard.py +425 -0
- bobframes/reports/ab.py +88 -0
- bobframes/reports/base.py +114 -0
- bobframes/reports/cache.py +147 -0
- bobframes/reports/chrome.py +1306 -0
- bobframes/reports/cli.py +99 -0
- bobframes/reports/delta.py +167 -0
- bobframes/reports/discovery.py +118 -0
- bobframes/reports/draws_by_class.py +165 -0
- bobframes/reports/formatters.py +122 -0
- bobframes/reports/instancing_opportunities.py +276 -0
- bobframes/reports/orchestrator.py +59 -0
- bobframes/reports/overdraw.py +293 -0
- bobframes/reports/pass_gpu.py +190 -0
- bobframes/reports/shader_hotlist.py +240 -0
- bobframes/reports/trend_table.py +444 -0
- bobframes/resource_labels.py +162 -0
- bobframes/run.py +480 -0
- bobframes/schemas.py +426 -0
- bobframes/stable_keys.py +83 -0
- bobframes/tests/__init__.py +0 -0
- bobframes/tests/_render_util.py +84 -0
- bobframes/tests/data/golden/_reports/draws_by_class.html +323 -0
- bobframes/tests/data/golden/_reports/drill/District 01/2026-05-28_r110600/index.html +1560 -0
- bobframes/tests/data/golden/_reports/index.html +264 -0
- bobframes/tests/data/golden/_reports/instancing_opportunities.html +266 -0
- bobframes/tests/data/golden/_reports/overdraw.html +275 -0
- bobframes/tests/data/golden/_reports/pass_gpu.html +277 -0
- bobframes/tests/data/golden/_reports/shader_hotlist.html +265 -0
- bobframes/tests/data/golden/_reports/trend_table.html +390 -0
- bobframes/tests/data/golden/index.html +1175 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/_manifest.json +51 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/buffers.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/clears.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/counters_per_event.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/descriptor_access.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/dispatches.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/draw_bindings.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/draws.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/events.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/fbos.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/frame_totals.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/ibo_samples.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/indirect_args.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/passes.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/pixel_history.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/post_vs_samples.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/program_transitions.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/programs.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/render_targets.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/resource_creation.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/rt_event_timeline.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/samplers.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/shaders.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/state_change_events.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/texture_samples.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/textures.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/vbo_samples.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/vertex_inputs.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/_manifest.json +51 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/buffers.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/clears.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/counters_per_event.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/descriptor_access.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/dispatches.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/draw_bindings.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/draws.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/events.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/fbos.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/frame_totals.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/ibo_samples.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/indirect_args.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/passes.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/pixel_history.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/post_vs_samples.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/program_transitions.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/programs.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/render_targets.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/resource_creation.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/rt_event_timeline.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/samplers.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/shaders.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/state_change_events.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/texture_samples.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/textures.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/vbo_samples.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/vertex_inputs.parquet +0 -0
- bobframes/tests/make_synthetic.py +171 -0
- bobframes/tests/smoke.py +199 -0
- bobframes/tests/test_determinism.py +19 -0
- bobframes/tests/test_discovery.py +97 -0
- bobframes/tests/test_hardening.py +142 -0
- bobframes/tests/test_parity.py +22 -0
- bobframes/tests/test_perf.py +18 -0
- bobframes/tests/test_replay_drift.py +115 -0
- bobframes/tests/test_schemas.py +26 -0
- bobframes/tests/test_schemas_unit.py +55 -0
- bobframes/tests/test_stable_keys.py +61 -0
- bobframes-0.1.0.dist-info/METADATA +144 -0
- bobframes-0.1.0.dist-info/RECORD +130 -0
- bobframes-0.1.0.dist-info/WHEEL +4 -0
- bobframes-0.1.0.dist-info/entry_points.txt +2 -0
- bobframes-0.1.0.dist-info/licenses/LICENSE +21 -0
bobframes/run.py
ADDED
|
@@ -0,0 +1,480 @@
|
|
|
1
|
+
"""Pipeline orchestrator. Host Python 3.14.
|
|
2
|
+
|
|
3
|
+
Per-drop stages:
|
|
4
|
+
1. Pre-flight — skip-marker / --force rotate / clean stale tmp
|
|
5
|
+
2. Export — renderdoccmd convert to .xml + .zip.xml (parallel)
|
|
6
|
+
3. Static parse — parse_init_state.py per capture (parallel)
|
|
7
|
+
4. Replay main — qrenderdoc + replay_main.py per capture (SEQUENTIAL)
|
|
8
|
+
5. Merge + parquetize — concat CSV fragments, compute stable_keys, write Parquet
|
|
9
|
+
6. Render HTML (placeholder; full template wired later)
|
|
10
|
+
7. Lint
|
|
11
|
+
8. Manifest
|
|
12
|
+
9. Atomic commit (rename tmp -> _analysis_out)
|
|
13
|
+
10. Marker
|
|
14
|
+
Run-level (after all drops):
|
|
15
|
+
11. Catalog rebuild
|
|
16
|
+
12. Root index render
|
|
17
|
+
13. Root lint
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import argparse
|
|
23
|
+
import concurrent.futures as cf
|
|
24
|
+
import datetime as _dt
|
|
25
|
+
import logging
|
|
26
|
+
import os
|
|
27
|
+
import shutil
|
|
28
|
+
import subprocess
|
|
29
|
+
import sys
|
|
30
|
+
import time
|
|
31
|
+
from dataclasses import dataclass
|
|
32
|
+
|
|
33
|
+
from . import (
|
|
34
|
+
catalog, derive_post_merge, discovery, global_entities, lint, manifest,
|
|
35
|
+
parquetize, paths, qrd_harness, query_examples, rdcmd, resource_labels, schemas,
|
|
36
|
+
)
|
|
37
|
+
from .derives import pass_class_breakdown, texture_usage
|
|
38
|
+
from .html import template
|
|
39
|
+
from .parsers import derive_program_transitions
|
|
40
|
+
from .replay import replay_script_path
|
|
41
|
+
from .reports import orchestrator as reports_orchestrator
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _ts() -> str:
|
|
45
|
+
return _dt.datetime.now().strftime('%Y%m%dT%H%M%S')
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
_logger = logging.getLogger('bobframes')
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def setup_logging(verbose: bool = False) -> None:
|
|
52
|
+
"""Configure the 'bobframes' logger once. Idempotent: if an outer caller
|
|
53
|
+
(e.g. cli.main) already attached a handler, leave it untouched so --verbose
|
|
54
|
+
set there wins (G-8). Keeps the existing ``[HH:MM:SS] message`` line format."""
|
|
55
|
+
if _logger.handlers:
|
|
56
|
+
return
|
|
57
|
+
_logger.setLevel(logging.DEBUG if verbose else logging.INFO)
|
|
58
|
+
h = logging.StreamHandler(sys.stdout)
|
|
59
|
+
h.setFormatter(logging.Formatter('[%(asctime)s] %(message)s', datefmt='%H:%M:%S'))
|
|
60
|
+
_logger.addHandler(h)
|
|
61
|
+
_logger.propagate = False
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _log(msg: str) -> None:
|
|
65
|
+
_logger.info(msg)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
# --- Stage 1: pre-flight -----------------------------------------------------
|
|
69
|
+
|
|
70
|
+
def _drop_inputs_max_mtime(drop_dir: str, captures: tuple[str, ...]) -> float:
|
|
71
|
+
mt = 0.0
|
|
72
|
+
for capture in captures:
|
|
73
|
+
for ext in ('rdc', 'xml', 'zip.xml'):
|
|
74
|
+
p = os.path.join(drop_dir, f'{capture}.{ext}')
|
|
75
|
+
if os.path.exists(p):
|
|
76
|
+
mt = max(mt, os.path.getmtime(p))
|
|
77
|
+
return mt
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _preflight(drop: discovery.Drop, force: bool, project_root: str) -> tuple[bool, str | None]:
|
|
81
|
+
"""Returns (should_skip, rotated_from). Cleans up stale tmp."""
|
|
82
|
+
drop_label_dated = os.path.basename(drop.drop_dir)
|
|
83
|
+
out = paths.drop_data_dir(project_root, drop.area, drop_label_dated)
|
|
84
|
+
tmp = paths.drop_data_dir_tmp(project_root, drop.area, drop_label_dated)
|
|
85
|
+
|
|
86
|
+
if os.path.isdir(tmp):
|
|
87
|
+
rot = f'{tmp}.{_ts()}'
|
|
88
|
+
try:
|
|
89
|
+
os.replace(tmp, rot)
|
|
90
|
+
_log(f' cleaned stale tmp -> {os.path.basename(rot)}')
|
|
91
|
+
except OSError as e:
|
|
92
|
+
_log(f' failed to rotate stale tmp: {e}')
|
|
93
|
+
shutil.rmtree(tmp, ignore_errors=True)
|
|
94
|
+
|
|
95
|
+
if os.path.isdir(out):
|
|
96
|
+
marker = os.path.join(out, 'done.marker')
|
|
97
|
+
if not force and os.path.exists(marker):
|
|
98
|
+
marker_mt = os.path.getmtime(marker)
|
|
99
|
+
input_mt = _drop_inputs_max_mtime(drop.drop_dir, drop.captures)
|
|
100
|
+
if marker_mt >= input_mt:
|
|
101
|
+
return True, None
|
|
102
|
+
|
|
103
|
+
if force:
|
|
104
|
+
rot = f'{out}.{_ts()}'
|
|
105
|
+
try:
|
|
106
|
+
os.replace(out, rot)
|
|
107
|
+
_log(f' rotated existing _data dir -> {os.path.basename(rot)}')
|
|
108
|
+
return False, os.path.basename(rot)
|
|
109
|
+
except OSError as e:
|
|
110
|
+
_log(f' rotate failed ({e}); falling back to delete')
|
|
111
|
+
shutil.rmtree(out, ignore_errors=True)
|
|
112
|
+
if os.path.isdir(out):
|
|
113
|
+
raise RuntimeError(f'cannot remove existing {out}; close any program holding it')
|
|
114
|
+
else:
|
|
115
|
+
shutil.rmtree(out, ignore_errors=True)
|
|
116
|
+
|
|
117
|
+
return False, None
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
# --- Stage 2: export ---------------------------------------------------------
|
|
121
|
+
|
|
122
|
+
def _export_one(rdc_path: str) -> tuple[str, dict[str, float]]:
|
|
123
|
+
"""Returns (capture, {fmt: elapsed_s})."""
|
|
124
|
+
drop_dir = os.path.dirname(rdc_path)
|
|
125
|
+
capture = os.path.basename(rdc_path)[:-4]
|
|
126
|
+
timings: dict[str, float] = {}
|
|
127
|
+
for fmt in ('xml', 'zip.xml'):
|
|
128
|
+
out = os.path.join(drop_dir, f'{capture}.{fmt}')
|
|
129
|
+
if not rdcmd.needs_export(rdc_path, out):
|
|
130
|
+
timings[fmt] = 0.0
|
|
131
|
+
continue
|
|
132
|
+
timings[fmt] = rdcmd.convert(rdc_path, out, fmt=fmt)
|
|
133
|
+
return capture, timings
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _do_export(drop: discovery.Drop, workers: int) -> None:
|
|
137
|
+
todo: list[str] = []
|
|
138
|
+
for capture in drop.captures:
|
|
139
|
+
rdc = os.path.join(drop.drop_dir, f'{capture}.rdc')
|
|
140
|
+
x = os.path.join(drop.drop_dir, f'{capture}.xml')
|
|
141
|
+
z = os.path.join(drop.drop_dir, f'{capture}.zip.xml')
|
|
142
|
+
if rdcmd.needs_export(rdc, x) or rdcmd.needs_export(rdc, z):
|
|
143
|
+
todo.append(rdc)
|
|
144
|
+
|
|
145
|
+
if not todo:
|
|
146
|
+
_log(f' export: nothing to do ({len(drop.captures)} captures already exported)')
|
|
147
|
+
return
|
|
148
|
+
|
|
149
|
+
_log(f' export: {len(todo)}/{len(drop.captures)} captures need export (workers={workers})')
|
|
150
|
+
t0 = time.monotonic()
|
|
151
|
+
with cf.ProcessPoolExecutor(max_workers=workers) as ex:
|
|
152
|
+
results = list(ex.map(_export_one, todo))
|
|
153
|
+
_log(f' export done in {time.monotonic()-t0:.1f}s')
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
# --- Stage 3: static parse ---------------------------------------------------
|
|
157
|
+
|
|
158
|
+
def _parse_one(args: tuple[str, str, str, str, str, str]) -> tuple[str, float, str, str]:
|
|
159
|
+
xml_path, capture_stage, area, drop_date, drop_label, capture = args
|
|
160
|
+
cmd = [
|
|
161
|
+
sys.executable, '-m', 'bobframes.parsers.parse_init_state',
|
|
162
|
+
xml_path, capture_stage, area, drop_date, drop_label, capture,
|
|
163
|
+
]
|
|
164
|
+
cwd = os.path.dirname(os.path.dirname(os.path.dirname(capture_stage)))
|
|
165
|
+
if not cwd:
|
|
166
|
+
cwd = os.getcwd()
|
|
167
|
+
cwd = os.environ.get('RDC_ROOT', cwd) or cwd
|
|
168
|
+
t0 = time.monotonic()
|
|
169
|
+
p = subprocess.run(cmd, capture_output=True, text=True, cwd=cwd)
|
|
170
|
+
elapsed = time.monotonic() - t0
|
|
171
|
+
stderr = p.stderr.strip()
|
|
172
|
+
if p.returncode != 0:
|
|
173
|
+
return capture, elapsed, f'FAIL: {stderr or p.stdout.strip()}', stderr
|
|
174
|
+
return capture, elapsed, p.stdout.strip(), stderr
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _do_parse(drop: discovery.Drop, stage_root: str, workers: int, project_root: str) -> dict[str, str]:
|
|
178
|
+
_log(f' parse: {len(drop.captures)} captures (workers={workers})')
|
|
179
|
+
args = []
|
|
180
|
+
for capture in drop.captures:
|
|
181
|
+
xml = os.path.join(drop.drop_dir, f'{capture}.zip.xml')
|
|
182
|
+
capture_stage = os.path.join(stage_root, capture)
|
|
183
|
+
os.makedirs(capture_stage, exist_ok=True)
|
|
184
|
+
args.append((xml, capture_stage, drop.area, drop.drop_date, drop.drop_label, capture))
|
|
185
|
+
prev_rdc_root = os.environ.get('RDC_ROOT')
|
|
186
|
+
os.environ['RDC_ROOT'] = project_root
|
|
187
|
+
t0 = time.monotonic()
|
|
188
|
+
statuses: dict[str, str] = {}
|
|
189
|
+
try:
|
|
190
|
+
with cf.ProcessPoolExecutor(max_workers=workers) as ex:
|
|
191
|
+
for capture, elapsed, status, stderr in ex.map(_parse_one, args):
|
|
192
|
+
statuses[capture] = 'ok' if not status.startswith('FAIL') else 'fail'
|
|
193
|
+
if stderr: # surface stderr even when rc==0 (R-7)
|
|
194
|
+
_log(f' {capture} stderr: {stderr[-400:]}')
|
|
195
|
+
_log(f' {capture}: {status} ({elapsed:.1f}s)')
|
|
196
|
+
finally:
|
|
197
|
+
# Restore RDC_ROOT so a later drop never inherits this drop's value (R-5).
|
|
198
|
+
if prev_rdc_root is None:
|
|
199
|
+
os.environ.pop('RDC_ROOT', None)
|
|
200
|
+
else:
|
|
201
|
+
os.environ['RDC_ROOT'] = prev_rdc_root
|
|
202
|
+
_log(f' parse done in {time.monotonic()-t0:.1f}s')
|
|
203
|
+
return statuses
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
# --- Stage 4: replay ---------------------------------------------------------
|
|
207
|
+
|
|
208
|
+
def _do_replay(drop: discovery.Drop, stage_root: str, pixel_grid: int = 4) -> dict[str, str]:
|
|
209
|
+
os.environ['RDC_PIXEL_GRID'] = str(pixel_grid)
|
|
210
|
+
_log(f' replay: {len(drop.captures)} captures (sequential)')
|
|
211
|
+
statuses: dict[str, str] = {}
|
|
212
|
+
# Resolve replay_main.py as a packaged resource so replay works from an installed wheel,
|
|
213
|
+
# not just an in-tree checkout (c12). The path lives for the whole loop's subprocesses.
|
|
214
|
+
with replay_script_path() as script:
|
|
215
|
+
for capture in drop.captures:
|
|
216
|
+
capture_stage = os.path.join(stage_root, capture)
|
|
217
|
+
os.makedirs(capture_stage, exist_ok=True)
|
|
218
|
+
log_path = os.path.join(capture_stage, '_harness.log')
|
|
219
|
+
t0 = time.monotonic()
|
|
220
|
+
rc, elapsed = qrd_harness.run(
|
|
221
|
+
str(script),
|
|
222
|
+
payload_args=[drop.drop_dir, capture, drop.area, drop.drop_date, drop.drop_label, stage_root],
|
|
223
|
+
log_path=log_path,
|
|
224
|
+
timeout_s=600,
|
|
225
|
+
)
|
|
226
|
+
if rc == 0:
|
|
227
|
+
statuses[capture] = 'ok'
|
|
228
|
+
_log(f' {capture}: rc={rc} {elapsed:.1f}s')
|
|
229
|
+
else:
|
|
230
|
+
# Isolate the failure: skip this capture, keep the rest of the drop alive (R-6).
|
|
231
|
+
statuses[capture] = 'replay_failed'
|
|
232
|
+
_log(f' {capture}: replay FAILED (rc={rc}, {elapsed:.1f}s); skipping, see {log_path}')
|
|
233
|
+
return statuses
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
# --- Drop driver -------------------------------------------------------------
|
|
237
|
+
|
|
238
|
+
@dataclass
|
|
239
|
+
class DropResult:
|
|
240
|
+
drop: discovery.Drop
|
|
241
|
+
row_counts: dict[str, int]
|
|
242
|
+
capture_status: dict[str, str]
|
|
243
|
+
rotated_from: str | None
|
|
244
|
+
skipped: bool
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def process_drop(drop: discovery.Drop, *, force: bool, workers: int,
|
|
248
|
+
project_root: str) -> DropResult:
|
|
249
|
+
_log(f'== drop: {drop.area} / {drop.drop_date}_{drop.drop_label} ({len(drop.captures)} captures) ==')
|
|
250
|
+
skip, rotated_from = _preflight(drop, force, project_root)
|
|
251
|
+
if skip:
|
|
252
|
+
_log(' skip (done.marker fresh)')
|
|
253
|
+
return DropResult(drop=drop, row_counts={}, capture_status={}, rotated_from=None, skipped=True)
|
|
254
|
+
|
|
255
|
+
drop_label_dated = os.path.basename(drop.drop_dir)
|
|
256
|
+
tmp = paths.drop_data_dir_tmp(project_root, drop.area, drop_label_dated)
|
|
257
|
+
stage_root = os.path.join(tmp, '_stage')
|
|
258
|
+
os.makedirs(stage_root, exist_ok=True)
|
|
259
|
+
|
|
260
|
+
_do_export(drop, workers=workers)
|
|
261
|
+
|
|
262
|
+
parse_status = _do_parse(drop, stage_root, workers=workers, project_root=project_root)
|
|
263
|
+
|
|
264
|
+
replay_status = _do_replay(drop, stage_root,
|
|
265
|
+
pixel_grid=int(os.environ.get('RDC_PIXEL_GRID', '4')))
|
|
266
|
+
|
|
267
|
+
capture_status: dict[str, str] = {}
|
|
268
|
+
for s in drop.captures:
|
|
269
|
+
p_ok = parse_status.get(s, 'ok') == 'ok'
|
|
270
|
+
r = replay_status.get(s, 'ok')
|
|
271
|
+
if r == 'replay_failed':
|
|
272
|
+
capture_status[s] = 'replay_failed'
|
|
273
|
+
elif p_ok and r == 'ok':
|
|
274
|
+
capture_status[s] = 'ok'
|
|
275
|
+
else:
|
|
276
|
+
capture_status[s] = 'fail'
|
|
277
|
+
|
|
278
|
+
_log(' merge + parquetize')
|
|
279
|
+
t0 = time.monotonic()
|
|
280
|
+
row_counts = parquetize.merge_drop(stage_root, tmp)
|
|
281
|
+
_log(f' parquetize done in {time.monotonic()-t0:.1f}s ({sum(row_counts.values())} rows)')
|
|
282
|
+
|
|
283
|
+
t0 = time.monotonic()
|
|
284
|
+
n_pt = derive_program_transitions.derive(tmp)
|
|
285
|
+
if n_pt:
|
|
286
|
+
row_counts['program_transitions'] = n_pt
|
|
287
|
+
_log(f' derive program_transitions: {n_pt} rows ({time.monotonic()-t0:.1f}s)')
|
|
288
|
+
|
|
289
|
+
t0 = time.monotonic()
|
|
290
|
+
derive_post_merge.derive(tmp)
|
|
291
|
+
_log(f' post-merge derives applied ({time.monotonic()-t0:.1f}s)')
|
|
292
|
+
|
|
293
|
+
t0 = time.monotonic()
|
|
294
|
+
n_pcb = pass_class_breakdown.build(tmp)
|
|
295
|
+
n_tu = texture_usage.build(tmp)
|
|
296
|
+
if n_pcb:
|
|
297
|
+
row_counts['pass_class_breakdown'] = n_pcb
|
|
298
|
+
if n_tu:
|
|
299
|
+
row_counts['texture_usage'] = n_tu
|
|
300
|
+
_log(f' derived tables: pass_class_breakdown={n_pcb}, texture_usage={n_tu} ({time.monotonic()-t0:.1f}s)')
|
|
301
|
+
|
|
302
|
+
t0 = time.monotonic()
|
|
303
|
+
resource_labels.write_resource_labels(tmp)
|
|
304
|
+
_log(f' resource labels written ({time.monotonic()-t0:.1f}s)')
|
|
305
|
+
|
|
306
|
+
m = manifest.build_manifest(
|
|
307
|
+
area=drop.area, drop_date=drop.drop_date, drop_label=drop.drop_label,
|
|
308
|
+
captures=list(drop.captures), capture_status=capture_status,
|
|
309
|
+
row_counts=row_counts, rotated_from=rotated_from,
|
|
310
|
+
tool_versions=manifest.gather_tool_versions(),
|
|
311
|
+
host_info=manifest.gather_host_info(),
|
|
312
|
+
)
|
|
313
|
+
manifest.write_manifest(tmp, m)
|
|
314
|
+
|
|
315
|
+
# Drop _stage/ before commit so it doesn't pollute the committed output.
|
|
316
|
+
if not os.environ.get('RDC_KEEP_STAGE'):
|
|
317
|
+
shutil.rmtree(stage_root, ignore_errors=True)
|
|
318
|
+
|
|
319
|
+
out = paths.drop_data_dir(project_root, drop.area, drop_label_dated)
|
|
320
|
+
if os.path.isdir(out):
|
|
321
|
+
raise RuntimeError(f'unexpected: {out} exists at commit time')
|
|
322
|
+
os.makedirs(os.path.dirname(out), exist_ok=True)
|
|
323
|
+
os.replace(tmp, out)
|
|
324
|
+
|
|
325
|
+
marker = os.path.join(out, 'done.marker')
|
|
326
|
+
marker_tmp = marker + '.tmp'
|
|
327
|
+
with open(marker_tmp, 'w', encoding='utf-8') as f:
|
|
328
|
+
f.write(str(_drop_inputs_max_mtime(drop.drop_dir, drop.captures)))
|
|
329
|
+
os.replace(marker_tmp, marker)
|
|
330
|
+
|
|
331
|
+
# Render per-drop browser HTML to _reports/drill/<area>/<drop>/index.html.
|
|
332
|
+
# Separate from data commit: HTML is idempotent and can be regenerated.
|
|
333
|
+
drill_dir = paths.drop_drill_dir(project_root, drop.area, drop_label_dated)
|
|
334
|
+
os.makedirs(drill_dir, exist_ok=True)
|
|
335
|
+
template.render_drop(
|
|
336
|
+
drill_dir, data_dir=out,
|
|
337
|
+
area=drop.area, drop_date=drop.drop_date, drop_label=drop.drop_label,
|
|
338
|
+
captures=list(drop.captures), schema_version=schemas.SCHEMA_VERSION,
|
|
339
|
+
build_timestamp=manifest.now_iso(),
|
|
340
|
+
row_counts=row_counts,
|
|
341
|
+
)
|
|
342
|
+
|
|
343
|
+
hits = lint.lint_file(os.path.join(drill_dir, 'index.html'))
|
|
344
|
+
if hits:
|
|
345
|
+
for lineno, label, snip in hits:
|
|
346
|
+
_log(f' LINT FAIL {drill_dir}/index.html:{lineno}: [{label}] {snip}')
|
|
347
|
+
raise RuntimeError('lint blocked the build')
|
|
348
|
+
|
|
349
|
+
_log(f' done -> {os.path.relpath(out)}')
|
|
350
|
+
return DropResult(drop=drop, row_counts=row_counts,
|
|
351
|
+
capture_status=capture_status, rotated_from=rotated_from, skipped=False)
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
# --- CLI ---------------------------------------------------------------------
|
|
355
|
+
|
|
356
|
+
def main(argv: list[str]) -> int:
|
|
357
|
+
setup_logging() # no-op if cli.main already configured logging
|
|
358
|
+
ap = argparse.ArgumentParser(prog='bobframes.run',
|
|
359
|
+
description='RDC capture analysis pipeline')
|
|
360
|
+
ap.add_argument('positional', nargs='?',
|
|
361
|
+
help='optional drop folder (e.g. "Chor bazar/2026-05-27_r110565/")')
|
|
362
|
+
ap.add_argument('--root', default='.', help='project root (containing area subdirs)')
|
|
363
|
+
ap.add_argument('--area', help='restrict to one area name')
|
|
364
|
+
ap.add_argument('--label', help='restrict to drops matching this label')
|
|
365
|
+
ap.add_argument('--capture', help='restrict to a single capture name (e.g. "1")')
|
|
366
|
+
ap.add_argument('--force', action='store_true', help='rotate existing _analysis_out and rebuild')
|
|
367
|
+
ap.add_argument('--render-only', action='store_true',
|
|
368
|
+
help='skip export/parse/replay; rebuild HTML + catalog from existing Parquet')
|
|
369
|
+
ap.add_argument('--workers', type=int, default=min(4, os.cpu_count() or 4))
|
|
370
|
+
ap.add_argument('--pixel-grid', type=int, default=4)
|
|
371
|
+
args = ap.parse_args(argv)
|
|
372
|
+
|
|
373
|
+
os.environ['RDC_PIXEL_GRID'] = str(args.pixel_grid)
|
|
374
|
+
root = os.path.abspath(args.root)
|
|
375
|
+
project_root = root # the run.py is invoked via `python -m bobframes.run` from project root
|
|
376
|
+
|
|
377
|
+
if args.positional:
|
|
378
|
+
drops = [discovery.parse_single_drop_arg(args.positional, root)]
|
|
379
|
+
if args.capture:
|
|
380
|
+
drops = [discovery.Drop(area=d.area, drop_date=d.drop_date,
|
|
381
|
+
drop_label=d.drop_label, drop_dir=d.drop_dir,
|
|
382
|
+
captures=(args.capture,)) for d in drops]
|
|
383
|
+
else:
|
|
384
|
+
drops = discovery.find_drops(
|
|
385
|
+
root=root,
|
|
386
|
+
area_filter=args.area,
|
|
387
|
+
label_filter=args.label,
|
|
388
|
+
capture_filter=args.capture,
|
|
389
|
+
)
|
|
390
|
+
|
|
391
|
+
if not drops:
|
|
392
|
+
_log('no drops to process')
|
|
393
|
+
return 0
|
|
394
|
+
|
|
395
|
+
_log(f'pipeline: {len(drops)} drop(s); root={root}')
|
|
396
|
+
|
|
397
|
+
if args.render_only:
|
|
398
|
+
_log(f'render-only: re-rendering {len(drops)} drop(s) from existing parquet')
|
|
399
|
+
for drop in drops:
|
|
400
|
+
drop_label_dated = os.path.basename(drop.drop_dir)
|
|
401
|
+
data_dir = paths.drop_data_dir(root, drop.area, drop_label_dated)
|
|
402
|
+
if not os.path.isdir(data_dir):
|
|
403
|
+
_log(f' {drop.area}: no _data dir, skipping')
|
|
404
|
+
continue
|
|
405
|
+
drill_dir = paths.drop_drill_dir(root, drop.area, drop_label_dated)
|
|
406
|
+
try:
|
|
407
|
+
derive_post_merge.derive(data_dir)
|
|
408
|
+
pass_class_breakdown.build(data_dir)
|
|
409
|
+
texture_usage.build(data_dir)
|
|
410
|
+
resource_labels.write_resource_labels(data_dir)
|
|
411
|
+
m = manifest.read_manifest(data_dir)
|
|
412
|
+
os.makedirs(drill_dir, exist_ok=True)
|
|
413
|
+
template.render_drop(
|
|
414
|
+
drill_dir, data_dir=data_dir,
|
|
415
|
+
area=drop.area, drop_date=drop.drop_date, drop_label=drop.drop_label,
|
|
416
|
+
captures=m.get('captures') or m.get('stems') or list(drop.captures),
|
|
417
|
+
schema_version=m.get('schema_version', schemas.SCHEMA_VERSION),
|
|
418
|
+
build_timestamp=m.get('build_timestamp', ''),
|
|
419
|
+
row_counts=m.get('row_counts') or {},
|
|
420
|
+
)
|
|
421
|
+
hits = lint.lint_file(os.path.join(drill_dir, 'index.html'))
|
|
422
|
+
if hits:
|
|
423
|
+
for lineno, label, snip in hits:
|
|
424
|
+
_log(f' LINT FAIL {drill_dir}/index.html:{lineno}: [{label}] {snip}')
|
|
425
|
+
return 1
|
|
426
|
+
_log(f' {drop.area}: rendered')
|
|
427
|
+
except Exception as e:
|
|
428
|
+
_log(f' {drop.area}: render FAILED: {e}')
|
|
429
|
+
return 1
|
|
430
|
+
_log('rebuilding catalog')
|
|
431
|
+
summary = catalog.build_catalog(root)
|
|
432
|
+
_log(f' catalog: {summary["drop_count"]} drops, {summary["capture_count"]} captures')
|
|
433
|
+
n_ge = global_entities.build_global_entities(root)
|
|
434
|
+
_log(f' global entities: {n_ge} rows')
|
|
435
|
+
query_examples.write_query_examples(root)
|
|
436
|
+
_log(' wrote _query_examples.md')
|
|
437
|
+
|
|
438
|
+
rc = reports_orchestrator.render_all_reports(root, _log)
|
|
439
|
+
if rc != 0:
|
|
440
|
+
return rc
|
|
441
|
+
_log('render-only done')
|
|
442
|
+
return 0
|
|
443
|
+
|
|
444
|
+
results: list[DropResult] = []
|
|
445
|
+
for drop in drops:
|
|
446
|
+
try:
|
|
447
|
+
r = process_drop(drop, force=args.force, workers=args.workers,
|
|
448
|
+
project_root=project_root)
|
|
449
|
+
results.append(r)
|
|
450
|
+
except Exception as e:
|
|
451
|
+
_log(f' drop FAILED: {e}')
|
|
452
|
+
return 1
|
|
453
|
+
|
|
454
|
+
_log('rebuilding catalog')
|
|
455
|
+
summary = catalog.build_catalog(root)
|
|
456
|
+
_log(f' catalog: {summary["drop_count"]} drops, {summary["capture_count"]} captures, areas={summary["areas"]}')
|
|
457
|
+
|
|
458
|
+
t0 = time.monotonic()
|
|
459
|
+
n_ge = global_entities.build_global_entities(root)
|
|
460
|
+
_log(f' global entities: {n_ge} rows ({time.monotonic()-t0:.1f}s)')
|
|
461
|
+
|
|
462
|
+
query_examples.write_query_examples(root)
|
|
463
|
+
_log(' wrote _query_examples.md')
|
|
464
|
+
|
|
465
|
+
rc = reports_orchestrator.render_all_reports(root, _log)
|
|
466
|
+
if rc != 0:
|
|
467
|
+
return rc
|
|
468
|
+
|
|
469
|
+
_log(f'pipeline done: {len(results)} drops processed')
|
|
470
|
+
for r in results:
|
|
471
|
+
if r.skipped:
|
|
472
|
+
_log(f' {r.drop.area}: skipped')
|
|
473
|
+
else:
|
|
474
|
+
_log(f' {r.drop.area}: {sum(r.row_counts.values())} rows')
|
|
475
|
+
|
|
476
|
+
return 0
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
if __name__ == '__main__':
|
|
480
|
+
sys.exit(main(sys.argv[1:]))
|