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/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ from ._version import __version__
2
+
3
+ __all__ = ["__version__"]
bobframes/_version.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
bobframes/catalog.py ADDED
@@ -0,0 +1,154 @@
1
+ """Build <root>/_data/_catalog.parquet and _catalog.json.
2
+
3
+ One row per (area, drop_date, drop_label, capture). Per-capture row counts
4
+ are computed by reading each drop's parquets and grouping by the `capture`
5
+ column — that way the catalog reflects what actually landed for each
6
+ capture, not just drop-level totals.
7
+
8
+ Also tracks schema version, build timestamp, replay status, and the relative
9
+ path to the drop's data dir (`_data/<area>/<drop>`). Path is RELATIVE for
10
+ portability across machines.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import datetime as _dt
16
+ import json
17
+ import os
18
+ from collections import defaultdict
19
+
20
+ import pyarrow as pa
21
+ import pyarrow.csv as pacsv
22
+ import pyarrow.parquet as papq
23
+
24
+ from . import paths as _paths
25
+
26
+
27
+ _CATALOG_TABLE_KEYS = [
28
+ 'draws', 'events', 'shaders', 'textures',
29
+ 'render_targets', 'buffers', 'programs',
30
+ 'samplers', 'fbos', 'state_change_events',
31
+ 'counters_per_event', 'descriptor_access',
32
+ 'passes', 'frame_totals',
33
+ 'clears', 'dispatches', 'rt_event_timeline',
34
+ 'vertex_inputs', 'resource_creation',
35
+ 'draw_bindings', 'program_transitions',
36
+ 'pixel_history', 'vbo_samples', 'ibo_samples',
37
+ 'post_vs_samples', 'texture_samples', 'indirect_args',
38
+ 'pass_class_breakdown', 'texture_usage',
39
+ ]
40
+
41
+
42
+ def _find_manifests(root: str) -> list[tuple[str, str, dict]]:
43
+ """Walk _data/<area>/<drop>/_manifest.json. Returns [(data_dir, rel_path, manifest)]."""
44
+ out = []
45
+ data_root = _paths.data_root(root)
46
+ if not os.path.isdir(data_root):
47
+ return out
48
+ for area_entry in sorted(os.listdir(data_root)):
49
+ if area_entry.startswith(('_', '.')):
50
+ continue
51
+ area_dir = os.path.join(data_root, area_entry)
52
+ if not os.path.isdir(area_dir):
53
+ continue
54
+ for drop_entry in sorted(os.listdir(area_dir)):
55
+ drop_dir = os.path.join(area_dir, drop_entry)
56
+ if not os.path.isdir(drop_dir):
57
+ continue
58
+ mf = os.path.join(drop_dir, '_manifest.json')
59
+ if not os.path.exists(mf):
60
+ continue
61
+ try:
62
+ with open(mf, 'r', encoding='utf-8') as f:
63
+ m = json.load(f)
64
+ except Exception:
65
+ continue
66
+ rel = _paths.drop_dir_rel(area_entry, drop_entry)
67
+ out.append((drop_dir, rel, m))
68
+ return out
69
+
70
+
71
+ def _per_capture_row_counts(data_dir: str, captures: list[str]) -> dict[str, dict[str, int]]:
72
+ """Walk all parquets in data_dir; return {capture: {table: row_count}}."""
73
+ result: dict[str, dict[str, int]] = {c: defaultdict(int) for c in captures}
74
+ for table in _CATALOG_TABLE_KEYS:
75
+ pq = os.path.join(data_dir, f'{table}.parquet')
76
+ if not os.path.exists(pq):
77
+ continue
78
+ try:
79
+ t = papq.read_table(pq, columns=['capture'])
80
+ caps = t.column('capture').to_pylist()
81
+ except Exception:
82
+ continue
83
+ for c in caps:
84
+ if c in result:
85
+ result[c][table] += 1
86
+ return {c: dict(d) for c, d in result.items()}
87
+
88
+
89
+ def _capture_rows(data_dir: str, rel_path: str, manifest: dict) -> list[dict]:
90
+ captures = manifest.get('captures') or manifest.get('stems') or []
91
+ cap_status = manifest.get('capture_status') or manifest.get('stem_status') or {}
92
+
93
+ per_cap = _per_capture_row_counts(data_dir, captures) if captures else {}
94
+
95
+ rows: list[dict] = []
96
+ for cap in captures:
97
+ counts = per_cap.get(cap, {})
98
+ rows.append({
99
+ 'area': manifest['area'],
100
+ 'drop_date': manifest['drop_date'],
101
+ 'drop_label': manifest.get('drop_label', '') or '',
102
+ 'capture': cap,
103
+ 'schema_version': int(manifest.get('schema_version', 0)),
104
+ 'build_timestamp': manifest.get('build_timestamp', ''),
105
+ 'replay_status': cap_status.get(cap, 'unknown'),
106
+ **{f'row_count_{k}': int(counts.get(k, 0)) for k in _CATALOG_TABLE_KEYS},
107
+ 'analysis_out_path': rel_path,
108
+ })
109
+ return rows
110
+
111
+
112
+ def build_catalog(root: str) -> dict:
113
+ manifests = _find_manifests(root)
114
+ all_rows: list[dict] = []
115
+ for data_dir, rel_path, m in manifests:
116
+ all_rows.extend(_capture_rows(data_dir, rel_path, m))
117
+
118
+ cols = [
119
+ 'area', 'drop_date', 'drop_label', 'capture',
120
+ 'schema_version', 'build_timestamp', 'replay_status',
121
+ ] + [f'row_count_{k}' for k in _CATALOG_TABLE_KEYS] + ['analysis_out_path']
122
+
123
+ arrays: dict[str, pa.Array] = {}
124
+ for c in cols:
125
+ vs = [r.get(c, '' if not c.startswith('row_count_') and c != 'schema_version' else 0)
126
+ for r in all_rows]
127
+ if c.startswith('row_count_') or c == 'schema_version':
128
+ arrays[c] = pa.array(vs, type=pa.int64())
129
+ else:
130
+ arrays[c] = pa.array([str(v) for v in vs], type=pa.string())
131
+ table = pa.table(arrays)
132
+
133
+ os.makedirs(_paths.data_root(root), exist_ok=True)
134
+ papq.write_table(table, _paths.catalog_parquet(root), compression='snappy')
135
+ pacsv.write_csv(table, _paths.catalog_csv(root))
136
+
137
+ summary = {
138
+ 'schema_version': max((r['schema_version'] for r in all_rows), default=0),
139
+ 'build_timestamp': _dt.datetime.now(_dt.timezone.utc).replace(microsecond=0).isoformat(),
140
+ 'drop_count': len({(r['area'], r['drop_date'], r['drop_label']) for r in all_rows}),
141
+ 'capture_count': len(all_rows),
142
+ 'areas': sorted({r['area'] for r in all_rows}),
143
+ }
144
+ with open(_paths.catalog_json(root), 'w', encoding='utf-8') as f:
145
+ json.dump(summary, f, indent=2)
146
+
147
+ return summary
148
+
149
+
150
+ if __name__ == '__main__':
151
+ import sys
152
+ root = sys.argv[1] if len(sys.argv) > 1 else '.'
153
+ s = build_catalog(root)
154
+ print(json.dumps(s, indent=2))
bobframes/cli.py ADDED
@@ -0,0 +1,266 @@
1
+ """Console entry point for the ``bobframes`` command (c11 dispatcher).
2
+
3
+ A single argparse dispatcher over the verbs in ARCHITECTURE §4. ``<root>`` is positional with
4
+ default ``.`` across every verb; flags are long-form only. Wired as ``[project.scripts]
5
+ bobframes = "bobframes.cli:main"`` and runnable as ``python -m bobframes.cli``.
6
+
7
+ Heavy modules (pipeline, reports, pyarrow) are imported lazily inside each handler so that fast
8
+ verbs like ``version`` / ``check`` stay cheap.
9
+
10
+ Exit codes (ARCHITECTURE §4): 0 success · 1 pipeline/build failure · 2 user error (argparse) ·
11
+ 3 external tool missing · 4 interrupted.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import argparse
16
+ import logging
17
+ import os
18
+ import sys
19
+
20
+ # CLI report name -> reports submodule. `build(root)` on each builds one report.
21
+ _REPORTS = {
22
+ 'draws-by-class': 'draws_by_class',
23
+ 'trend': 'trend_table',
24
+ 'instancing': 'instancing_opportunities',
25
+ 'pass-gpu': 'pass_gpu',
26
+ 'shader': 'shader_hotlist',
27
+ 'overdraw': 'overdraw',
28
+ 'dashboard': '_dashboard',
29
+ }
30
+
31
+
32
+ def _configure_logging(verbose: bool) -> None:
33
+ """Attach the shared ``[HH:MM:SS] message`` handler to the 'bobframes' logger (G-8)."""
34
+ logger = logging.getLogger('bobframes')
35
+ logger.setLevel(logging.DEBUG if verbose else logging.INFO)
36
+ if logger.handlers:
37
+ return
38
+ h = logging.StreamHandler(sys.stdout)
39
+ h.setFormatter(logging.Formatter('[%(asctime)s] %(message)s', datefmt='%H:%M:%S'))
40
+ logger.addHandler(h)
41
+ logger.propagate = False
42
+
43
+
44
+ # --- verb handlers -----------------------------------------------------------
45
+
46
+ def _cmd_version(args: argparse.Namespace) -> int:
47
+ from . import __version__, schemas
48
+ try:
49
+ import pyarrow
50
+ pa = pyarrow.__version__
51
+ except Exception:
52
+ pa = 'not installed'
53
+ print(f'bobframes {__version__} schema {schemas.SCHEMA_VERSION} pyarrow {pa}')
54
+ return 0
55
+
56
+
57
+ def _cmd_ingest(args: argparse.Namespace) -> int:
58
+ from . import run
59
+ rargv = ['--root', os.path.abspath(args.root)]
60
+ if args.area:
61
+ rargv += ['--area', args.area]
62
+ if args.label:
63
+ rargv += ['--label', args.label]
64
+ if args.capture:
65
+ rargv += ['--capture', args.capture]
66
+ if args.force:
67
+ rargv += ['--force']
68
+ if args.render_only:
69
+ rargv += ['--render-only']
70
+ rargv += ['--workers', str(args.workers), '--pixel-grid', str(args.pixel_grid)]
71
+ return run.main(rargv)
72
+
73
+
74
+ def _cmd_render(args: argparse.Namespace) -> int:
75
+ from . import run
76
+ rargv = ['--root', os.path.abspath(args.root), '--render-only']
77
+ if args.area:
78
+ rargv += ['--area', args.area]
79
+ if args.label:
80
+ rargv += ['--label', args.label]
81
+ return run.main(rargv)
82
+
83
+
84
+ def _cmd_ab(args: argparse.Namespace) -> int:
85
+ from .reports import ab
86
+ argv = [os.path.abspath(args.root),
87
+ '--baseline-label', args.baseline_label,
88
+ '--compare-label', args.compare_label]
89
+ if args.baseline_date:
90
+ argv += ['--baseline-date', args.baseline_date]
91
+ if args.compare_date:
92
+ argv += ['--compare-date', args.compare_date]
93
+ return ab.main(argv)
94
+
95
+
96
+ def _cmd_report(args: argparse.Namespace) -> int:
97
+ import importlib
98
+ modname = _REPORTS.get(args.name)
99
+ if modname is None:
100
+ print(f'unknown report {args.name!r}; choose from: {", ".join(sorted(_REPORTS))}',
101
+ file=sys.stderr)
102
+ return 2
103
+ mod = importlib.import_module(f'.reports.{modname}', package='bobframes')
104
+ out = mod.build(os.path.abspath(args.root))
105
+ print(f'wrote {out}')
106
+ return 0
107
+
108
+
109
+ def _cmd_catalog(args: argparse.Namespace) -> int:
110
+ from . import catalog
111
+ log = logging.getLogger('bobframes')
112
+ summary = catalog.build_catalog(os.path.abspath(args.root))
113
+ log.info(f"catalog: {summary['drop_count']} drops, {summary['capture_count']} captures")
114
+ return 0
115
+
116
+
117
+ def _cmd_lint(args: argparse.Namespace) -> int:
118
+ from . import lint
119
+ rc = 0
120
+ for path in args.files:
121
+ hits = lint.lint_file(path)
122
+ for lineno, label, snip in hits:
123
+ print(f'{path}:{lineno}: [{label}] {snip}')
124
+ rc = 1
125
+ return rc
126
+
127
+
128
+ def _cmd_check(args: argparse.Namespace) -> int:
129
+ if sys.platform != 'win32':
130
+ print('bobframes v1 is Windows-only (qrenderdoc replay requirement). '
131
+ 'Track GH issue for Linux/macOS support.', file=sys.stderr)
132
+ return 3
133
+ if args.write_config:
134
+ print('--write-config is a v0.2 feature (config layer); not available yet.',
135
+ file=sys.stderr)
136
+ return 2
137
+ from . import qrd_harness, rdcmd
138
+ missing = False
139
+ for name, finder in (('renderdoccmd', rdcmd.find_renderdoccmd),
140
+ ('qrenderdoc', qrd_harness.find_qrenderdoc)):
141
+ try:
142
+ print(f'{name}: {finder()}')
143
+ except FileNotFoundError:
144
+ print(f'{name}: NOT FOUND', file=sys.stderr)
145
+ missing = True
146
+ return 3 if missing else 0
147
+
148
+
149
+ def _cmd_serve(args: argparse.Namespace) -> int:
150
+ import functools
151
+ import http.server
152
+ import socketserver
153
+
154
+ root = os.path.abspath(args.root)
155
+ handler = functools.partial(http.server.SimpleHTTPRequestHandler, directory=root)
156
+ log = logging.getLogger('bobframes')
157
+ try:
158
+ with socketserver.TCPServer((args.bind, args.port), handler) as httpd:
159
+ log.info(f'serving {root} at http://{args.bind}:{args.port} (Ctrl+C to stop)')
160
+ httpd.serve_forever()
161
+ except KeyboardInterrupt:
162
+ return 4
163
+ return 0
164
+
165
+
166
+ def _cmd_smoke(args: argparse.Namespace) -> int:
167
+ # End-to-end smoke (G-12). No --data → render-only against the bundled synthetic fixture;
168
+ # --data DIR → full ingest + render against a real capture root.
169
+ from .tests import smoke
170
+ return smoke.main(data=args.data, pixel_grid=getattr(args, 'pixel_grid', 4))
171
+
172
+
173
+ # --- parser ------------------------------------------------------------------
174
+
175
+ def _build_parser() -> argparse.ArgumentParser:
176
+ common = argparse.ArgumentParser(add_help=False)
177
+ common.add_argument('--verbose', action='store_true', help='DEBUG-level logging')
178
+
179
+ p = argparse.ArgumentParser(
180
+ prog='bobframes',
181
+ description='RenderDoc capture pipeline: ingest, analyze, render.')
182
+ sub = p.add_subparsers(dest='verb', metavar='<verb>')
183
+
184
+ sp = sub.add_parser('ingest', parents=[common],
185
+ help='full pipeline: export, parse, replay, parquetize, derive, render')
186
+ sp.add_argument('root', nargs='?', default='.')
187
+ sp.add_argument('--area')
188
+ sp.add_argument('--label')
189
+ sp.add_argument('--capture')
190
+ sp.add_argument('--force', action='store_true')
191
+ sp.add_argument('--render-only', action='store_true')
192
+ sp.add_argument('--workers', type=int, default=min(4, os.cpu_count() or 4))
193
+ sp.add_argument('--pixel-grid', type=int, default=4)
194
+ sp.set_defaults(func=_cmd_ingest)
195
+
196
+ sp = sub.add_parser('render', parents=[common],
197
+ help='render-only: rebuild HTML + catalog from existing Parquet')
198
+ sp.add_argument('root', nargs='?', default='.')
199
+ sp.add_argument('--area')
200
+ sp.add_argument('--label')
201
+ sp.set_defaults(func=_cmd_render)
202
+
203
+ sp = sub.add_parser('ab', parents=[common], help='all reports for one drop pair')
204
+ sp.add_argument('root', nargs='?', default='.')
205
+ sp.add_argument('--baseline-label', required=True)
206
+ sp.add_argument('--compare-label', required=True)
207
+ sp.add_argument('--baseline-date')
208
+ sp.add_argument('--compare-date')
209
+ sp.set_defaults(func=_cmd_ab)
210
+
211
+ sp = sub.add_parser('report', parents=[common], help='build one named report')
212
+ sp.add_argument('name', choices=sorted(_REPORTS))
213
+ sp.add_argument('root', nargs='?', default='.')
214
+ sp.set_defaults(func=_cmd_report)
215
+
216
+ sp = sub.add_parser('catalog', parents=[common], help='rebuild _data/_catalog.parquet only')
217
+ sp.add_argument('root', nargs='?', default='.')
218
+ sp.set_defaults(func=_cmd_catalog)
219
+
220
+ sp = sub.add_parser('lint', parents=[common], help='lint HTML/MD files against the banlist')
221
+ sp.add_argument('files', nargs='+')
222
+ sp.set_defaults(func=_cmd_lint)
223
+
224
+ sp = sub.add_parser('check', parents=[common], help='resolve external tool paths')
225
+ sp.add_argument('--write-config', action='store_true', help='(v0.2)')
226
+ sp.set_defaults(func=_cmd_check)
227
+
228
+ sp = sub.add_parser('version', parents=[common], help='print version, schema, pyarrow')
229
+ sp.set_defaults(func=_cmd_version)
230
+
231
+ sp = sub.add_parser('serve', parents=[common], help='static preview server')
232
+ sp.add_argument('root', nargs='?', default='.')
233
+ sp.add_argument('--port', type=int, default=8000)
234
+ sp.add_argument('--bind', default='127.0.0.1')
235
+ sp.set_defaults(func=_cmd_serve)
236
+
237
+ sp = sub.add_parser('smoke', parents=[common], help='end-to-end smoke test')
238
+ sp.add_argument('--data', help='capture dir (default: bundled synthetic; c15)')
239
+ sp.set_defaults(func=_cmd_smoke)
240
+
241
+ return p
242
+
243
+
244
+ def main(argv: list[str] | None = None) -> int:
245
+ argv = list(sys.argv[1:] if argv is None else argv)
246
+ parser = _build_parser()
247
+
248
+ if not argv:
249
+ parser.print_help()
250
+ return 0
251
+
252
+ args = parser.parse_args(argv)
253
+ if not getattr(args, 'func', None):
254
+ parser.print_help()
255
+ return 0
256
+
257
+ _configure_logging(getattr(args, 'verbose', False))
258
+ try:
259
+ return args.func(args)
260
+ except KeyboardInterrupt:
261
+ print('interrupted', file=sys.stderr)
262
+ return 4
263
+
264
+
265
+ if __name__ == '__main__':
266
+ sys.exit(main())