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.
Files changed (130) hide show
  1. bobframes/__init__.py +3 -0
  2. bobframes/_version.py +1 -0
  3. bobframes/catalog.py +154 -0
  4. bobframes/cli.py +266 -0
  5. bobframes/derive_post_merge.py +365 -0
  6. bobframes/derives/__init__.py +0 -0
  7. bobframes/derives/pass_class_breakdown.py +102 -0
  8. bobframes/derives/texture_usage.py +121 -0
  9. bobframes/discovery.py +132 -0
  10. bobframes/global_entities.py +99 -0
  11. bobframes/html/__init__.py +0 -0
  12. bobframes/html/template.py +1056 -0
  13. bobframes/lint.py +114 -0
  14. bobframes/manifest.py +127 -0
  15. bobframes/parquetize.py +282 -0
  16. bobframes/parsers/__init__.py +0 -0
  17. bobframes/parsers/derive_program_transitions.py +73 -0
  18. bobframes/parsers/parse_init_state.py +675 -0
  19. bobframes/paths.py +111 -0
  20. bobframes/probes/__init__.py +0 -0
  21. bobframes/probes/whatif.py +165 -0
  22. bobframes/qrd_harness.py +119 -0
  23. bobframes/query_examples.py +222 -0
  24. bobframes/rdcmd.py +72 -0
  25. bobframes/replay/__init__.py +26 -0
  26. bobframes/replay/replay_main.py +2305 -0
  27. bobframes/reports/__init__.py +0 -0
  28. bobframes/reports/_dashboard.py +425 -0
  29. bobframes/reports/ab.py +88 -0
  30. bobframes/reports/base.py +114 -0
  31. bobframes/reports/cache.py +147 -0
  32. bobframes/reports/chrome.py +1306 -0
  33. bobframes/reports/cli.py +99 -0
  34. bobframes/reports/delta.py +167 -0
  35. bobframes/reports/discovery.py +118 -0
  36. bobframes/reports/draws_by_class.py +165 -0
  37. bobframes/reports/formatters.py +122 -0
  38. bobframes/reports/instancing_opportunities.py +276 -0
  39. bobframes/reports/orchestrator.py +59 -0
  40. bobframes/reports/overdraw.py +293 -0
  41. bobframes/reports/pass_gpu.py +190 -0
  42. bobframes/reports/shader_hotlist.py +240 -0
  43. bobframes/reports/trend_table.py +444 -0
  44. bobframes/resource_labels.py +162 -0
  45. bobframes/run.py +480 -0
  46. bobframes/schemas.py +426 -0
  47. bobframes/stable_keys.py +83 -0
  48. bobframes/tests/__init__.py +0 -0
  49. bobframes/tests/_render_util.py +84 -0
  50. bobframes/tests/data/golden/_reports/draws_by_class.html +323 -0
  51. bobframes/tests/data/golden/_reports/drill/District 01/2026-05-28_r110600/index.html +1560 -0
  52. bobframes/tests/data/golden/_reports/index.html +264 -0
  53. bobframes/tests/data/golden/_reports/instancing_opportunities.html +266 -0
  54. bobframes/tests/data/golden/_reports/overdraw.html +275 -0
  55. bobframes/tests/data/golden/_reports/pass_gpu.html +277 -0
  56. bobframes/tests/data/golden/_reports/shader_hotlist.html +265 -0
  57. bobframes/tests/data/golden/_reports/trend_table.html +390 -0
  58. bobframes/tests/data/golden/index.html +1175 -0
  59. bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/_manifest.json +51 -0
  60. bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/buffers.parquet +0 -0
  61. bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/clears.parquet +0 -0
  62. bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/counters_per_event.parquet +0 -0
  63. bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/descriptor_access.parquet +0 -0
  64. bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/dispatches.parquet +0 -0
  65. bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/draw_bindings.parquet +0 -0
  66. bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/draws.parquet +0 -0
  67. bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/events.parquet +0 -0
  68. bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/fbos.parquet +0 -0
  69. bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/frame_totals.parquet +0 -0
  70. bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/ibo_samples.parquet +0 -0
  71. bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/indirect_args.parquet +0 -0
  72. bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/passes.parquet +0 -0
  73. bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/pixel_history.parquet +0 -0
  74. bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/post_vs_samples.parquet +0 -0
  75. bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/program_transitions.parquet +0 -0
  76. bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/programs.parquet +0 -0
  77. bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/render_targets.parquet +0 -0
  78. bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/resource_creation.parquet +0 -0
  79. bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/rt_event_timeline.parquet +0 -0
  80. bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/samplers.parquet +0 -0
  81. bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/shaders.parquet +0 -0
  82. bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/state_change_events.parquet +0 -0
  83. bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/texture_samples.parquet +0 -0
  84. bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/textures.parquet +0 -0
  85. bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/vbo_samples.parquet +0 -0
  86. bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/vertex_inputs.parquet +0 -0
  87. bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/_manifest.json +51 -0
  88. bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/buffers.parquet +0 -0
  89. bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/clears.parquet +0 -0
  90. bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/counters_per_event.parquet +0 -0
  91. bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/descriptor_access.parquet +0 -0
  92. bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/dispatches.parquet +0 -0
  93. bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/draw_bindings.parquet +0 -0
  94. bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/draws.parquet +0 -0
  95. bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/events.parquet +0 -0
  96. bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/fbos.parquet +0 -0
  97. bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/frame_totals.parquet +0 -0
  98. bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/ibo_samples.parquet +0 -0
  99. bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/indirect_args.parquet +0 -0
  100. bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/passes.parquet +0 -0
  101. bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/pixel_history.parquet +0 -0
  102. bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/post_vs_samples.parquet +0 -0
  103. bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/program_transitions.parquet +0 -0
  104. bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/programs.parquet +0 -0
  105. bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/render_targets.parquet +0 -0
  106. bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/resource_creation.parquet +0 -0
  107. bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/rt_event_timeline.parquet +0 -0
  108. bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/samplers.parquet +0 -0
  109. bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/shaders.parquet +0 -0
  110. bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/state_change_events.parquet +0 -0
  111. bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/texture_samples.parquet +0 -0
  112. bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/textures.parquet +0 -0
  113. bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/vbo_samples.parquet +0 -0
  114. bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/vertex_inputs.parquet +0 -0
  115. bobframes/tests/make_synthetic.py +171 -0
  116. bobframes/tests/smoke.py +199 -0
  117. bobframes/tests/test_determinism.py +19 -0
  118. bobframes/tests/test_discovery.py +97 -0
  119. bobframes/tests/test_hardening.py +142 -0
  120. bobframes/tests/test_parity.py +22 -0
  121. bobframes/tests/test_perf.py +18 -0
  122. bobframes/tests/test_replay_drift.py +115 -0
  123. bobframes/tests/test_schemas.py +26 -0
  124. bobframes/tests/test_schemas_unit.py +55 -0
  125. bobframes/tests/test_stable_keys.py +61 -0
  126. bobframes-0.1.0.dist-info/METADATA +144 -0
  127. bobframes-0.1.0.dist-info/RECORD +130 -0
  128. bobframes-0.1.0.dist-info/WHEEL +4 -0
  129. bobframes-0.1.0.dist-info/entry_points.txt +2 -0
  130. 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:]))