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
|
@@ -0,0 +1,1056 @@
|
|
|
1
|
+
"""Per-drop data browser HTML + root cross-drop directory HTML.
|
|
2
|
+
|
|
3
|
+
Tables render via virtual scrolling: data ships as JSON in <script> blocks,
|
|
4
|
+
JS renders only the rows visible inside each scroll container, so the DOM
|
|
5
|
+
stays cheap regardless of dataset size. Sort/filter rebuild the visible
|
|
6
|
+
window in O(window-size).
|
|
7
|
+
|
|
8
|
+
Resource ID cells are enriched with labels from `_resource_labels.json`
|
|
9
|
+
(e.g. `tex_id=2184` displays as `2184 SceneDepthZ`).
|
|
10
|
+
|
|
11
|
+
CSS and JS are inlined for self-contained file:// browsing.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import csv
|
|
17
|
+
import html as _html
|
|
18
|
+
import json
|
|
19
|
+
import os
|
|
20
|
+
from typing import Iterable
|
|
21
|
+
|
|
22
|
+
import pyarrow.parquet as papq
|
|
23
|
+
|
|
24
|
+
from ..reports import base as reports_base
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
_CATEGORY_MAP = {
|
|
28
|
+
'aggregates': ['passes', 'pass_class_breakdown', 'frame_totals',
|
|
29
|
+
'texture_usage'],
|
|
30
|
+
'entities': ['shaders', 'textures', 'render_targets', 'programs',
|
|
31
|
+
'samplers', 'buffers', 'fbos'],
|
|
32
|
+
'actions': ['draws', 'draw_bindings', 'events', 'clears', 'dispatches',
|
|
33
|
+
'state_change_events', 'vertex_inputs', 'indirect_args',
|
|
34
|
+
'descriptor_access', 'rt_event_timeline',
|
|
35
|
+
'program_transitions', 'resource_creation',
|
|
36
|
+
'counters_per_event'],
|
|
37
|
+
'samples': ['vbo_samples', 'ibo_samples', 'post_vs_samples',
|
|
38
|
+
'texture_samples', 'pixel_history'],
|
|
39
|
+
}
|
|
40
|
+
_CATEGORY_ORDER = ['aggregates', 'entities', 'actions', 'samples', 'sidecars']
|
|
41
|
+
_DEFAULT_OPEN = {'aggregates'}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# Map of (table_name, column_name) -> resource kind key in _resource_labels.json
|
|
45
|
+
_LABEL_COLS: dict[str, dict[str, str]] = {
|
|
46
|
+
'draws': {
|
|
47
|
+
'program_id': 'program',
|
|
48
|
+
'vs_shader_id': 'shader',
|
|
49
|
+
'fs_shader_id': 'shader',
|
|
50
|
+
'depth_rt_id': 'texture',
|
|
51
|
+
'color_rt_ids': 'texture_list', # semicolon-joined
|
|
52
|
+
'ibo_id': 'buffer',
|
|
53
|
+
},
|
|
54
|
+
'draw_bindings': {
|
|
55
|
+
# resource_id meaning depends on slot_kind; resolved in JS
|
|
56
|
+
'resource_id': 'auto_by_slot_kind',
|
|
57
|
+
'sampler_id': 'sampler',
|
|
58
|
+
},
|
|
59
|
+
'passes': {
|
|
60
|
+
'color_rt_id_first': 'texture',
|
|
61
|
+
'depth_rt_id_first': 'texture',
|
|
62
|
+
},
|
|
63
|
+
'shaders': {
|
|
64
|
+
# shader_id self-references; no need.
|
|
65
|
+
},
|
|
66
|
+
'render_targets': {
|
|
67
|
+
# has its own label column already
|
|
68
|
+
},
|
|
69
|
+
'textures': {},
|
|
70
|
+
'programs': {
|
|
71
|
+
'vs_shader_id': 'shader',
|
|
72
|
+
'fs_shader_id': 'shader',
|
|
73
|
+
'cs_shader_id': 'shader',
|
|
74
|
+
},
|
|
75
|
+
'samplers': {},
|
|
76
|
+
'fbos': {
|
|
77
|
+
'resource_id': 'texture',
|
|
78
|
+
},
|
|
79
|
+
'events': {
|
|
80
|
+
'output_color_rt_id': 'texture',
|
|
81
|
+
'output_depth_rt_id': 'texture',
|
|
82
|
+
'copy_source_id': 'texture',
|
|
83
|
+
'copy_destination_id': 'texture',
|
|
84
|
+
},
|
|
85
|
+
'clears': {
|
|
86
|
+
'fbo_id': 'fbo',
|
|
87
|
+
},
|
|
88
|
+
'dispatches': {
|
|
89
|
+
'program_id': 'program',
|
|
90
|
+
'cs_shader_id': 'shader',
|
|
91
|
+
},
|
|
92
|
+
'rt_event_timeline': {
|
|
93
|
+
'rt_id': 'texture',
|
|
94
|
+
},
|
|
95
|
+
'descriptor_access': {
|
|
96
|
+
'resource_id': 'auto_by_kind',
|
|
97
|
+
},
|
|
98
|
+
'pixel_history': {
|
|
99
|
+
'rt_id': 'texture',
|
|
100
|
+
},
|
|
101
|
+
'texture_usage': {
|
|
102
|
+
'tex_id': 'texture',
|
|
103
|
+
},
|
|
104
|
+
'pass_class_breakdown': {},
|
|
105
|
+
'program_transitions': {
|
|
106
|
+
'from_program_id': 'program',
|
|
107
|
+
'to_program_id': 'program',
|
|
108
|
+
},
|
|
109
|
+
'state_change_events': {},
|
|
110
|
+
'indirect_args': {
|
|
111
|
+
'indirect_buffer_id': 'buffer',
|
|
112
|
+
},
|
|
113
|
+
'vertex_inputs': {
|
|
114
|
+
'buffer_id': 'buffer',
|
|
115
|
+
},
|
|
116
|
+
'resource_creation': {},
|
|
117
|
+
'counters_per_event': {},
|
|
118
|
+
'frame_totals': {},
|
|
119
|
+
'vbo_samples': {'buffer_id': 'buffer'},
|
|
120
|
+
'ibo_samples': {'buffer_id': 'buffer'},
|
|
121
|
+
'post_vs_samples': {},
|
|
122
|
+
'texture_samples': {'tex_id': 'texture'},
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
_PER_DROP_CSS = """
|
|
127
|
+
:root { --label: #4a6a3a; --th-bg: var(--surface-2); --th-bg-active: var(--row-hover); }
|
|
128
|
+
@media (prefers-color-scheme: dark) {
|
|
129
|
+
:root { --label: #a3d39c; }
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
body { max-width: 1800px; }
|
|
133
|
+
|
|
134
|
+
nav.toc a { display: flex; justify-content: space-between; padding: 2px 0; gap: 1rem; }
|
|
135
|
+
nav.toc a .ct { color: var(--text-3); font-variant-numeric: tabular-nums; }
|
|
136
|
+
|
|
137
|
+
.controls {
|
|
138
|
+
display: flex; gap: var(--sp-3); align-items: center;
|
|
139
|
+
font-size: var(--fs-small); flex-wrap: wrap;
|
|
140
|
+
margin: var(--sp-2) 0 var(--sp-2);
|
|
141
|
+
}
|
|
142
|
+
.controls input[type=search] {
|
|
143
|
+
font: inherit; padding: 4px 8px;
|
|
144
|
+
border: 1px solid var(--border-2); background: var(--surface-0); color: var(--text-1);
|
|
145
|
+
border-radius: 2px; min-width: 22rem;
|
|
146
|
+
}
|
|
147
|
+
.controls input[type=search]:focus { outline: 1px solid var(--accent); }
|
|
148
|
+
.controls .ct { color: var(--text-2); font-variant-numeric: tabular-nums; }
|
|
149
|
+
.controls .dl { font-size: var(--fs-small); color: var(--accent); }
|
|
150
|
+
|
|
151
|
+
.table-scroll {
|
|
152
|
+
height: 60vh; overflow: auto;
|
|
153
|
+
border: 1px solid var(--border-1);
|
|
154
|
+
background: var(--surface-0);
|
|
155
|
+
position: relative;
|
|
156
|
+
}
|
|
157
|
+
.table-scroll.short { height: auto; max-height: 60vh; }
|
|
158
|
+
table.data {
|
|
159
|
+
border-collapse: separate; border-spacing: 0;
|
|
160
|
+
font: var(--fs-mono) ui-monospace, 'Cascadia Code', Consolas, monospace;
|
|
161
|
+
width: max-content; min-width: 100%; table-layout: auto;
|
|
162
|
+
}
|
|
163
|
+
table.data thead th {
|
|
164
|
+
position: sticky; top: 0; z-index: 2;
|
|
165
|
+
background: var(--th-bg);
|
|
166
|
+
text-align: left; cursor: pointer; user-select: none;
|
|
167
|
+
color: var(--accent); font-weight: 600;
|
|
168
|
+
padding: 4px 8px;
|
|
169
|
+
border-bottom: 1px solid var(--border-2);
|
|
170
|
+
white-space: nowrap;
|
|
171
|
+
}
|
|
172
|
+
table.data thead th:hover { background: var(--th-bg-active); }
|
|
173
|
+
table.data thead th .sort-arrow { display: inline-block; width: 10px; color: var(--text-3); }
|
|
174
|
+
table.data thead th.numeric, table.data tbody td.numeric {
|
|
175
|
+
text-align: right; font-variant-numeric: tabular-nums;
|
|
176
|
+
}
|
|
177
|
+
table.data tbody td {
|
|
178
|
+
padding: 2px 8px;
|
|
179
|
+
border-bottom: 1px solid var(--border-1);
|
|
180
|
+
vertical-align: top; white-space: nowrap;
|
|
181
|
+
max-width: 380px; overflow: hidden; text-overflow: ellipsis;
|
|
182
|
+
background: var(--surface-0);
|
|
183
|
+
}
|
|
184
|
+
table.data tbody tr.alt td { background: var(--surface-1); }
|
|
185
|
+
table.data tbody tr:hover td { background: var(--row-hover); }
|
|
186
|
+
table.data tbody td .lbl {
|
|
187
|
+
color: var(--label); margin-left: 6px;
|
|
188
|
+
font-style: italic; opacity: .85;
|
|
189
|
+
}
|
|
190
|
+
table.data tbody td a {
|
|
191
|
+
color: inherit; text-decoration: none;
|
|
192
|
+
border-bottom: 1px dotted var(--accent);
|
|
193
|
+
}
|
|
194
|
+
table.data tbody td a:hover { color: var(--accent); border-bottom-style: solid; }
|
|
195
|
+
.spacer td { padding: 0; border: 0; background: var(--surface-0); }
|
|
196
|
+
|
|
197
|
+
.sidecar-list a { font-family: ui-monospace, monospace; font-size: var(--fs-small); }
|
|
198
|
+
.sidecar-list span { color: var(--text-2); margin-left: .4rem; font-size: var(--fs-small); }
|
|
199
|
+
ul.sidecar-list { list-style: none; padding: 0; margin: var(--sp-2) 0;
|
|
200
|
+
columns: 5; column-gap: var(--sp-6); column-rule: 1px solid var(--border-1); }
|
|
201
|
+
ul.sidecar-list li { padding: 1px 0; break-inside: avoid; }
|
|
202
|
+
|
|
203
|
+
/* Per-drop table sections: inline (no full card chrome) */
|
|
204
|
+
section.table-section {
|
|
205
|
+
margin: 0 0 var(--sp-4);
|
|
206
|
+
}
|
|
207
|
+
section.table-section > header.table-header {
|
|
208
|
+
display: flex; align-items: baseline; justify-content: space-between;
|
|
209
|
+
gap: var(--sp-3); margin: 0 0 var(--sp-2);
|
|
210
|
+
padding: 0 0 0 var(--sp-3);
|
|
211
|
+
border-left: 3px solid var(--border-2);
|
|
212
|
+
}
|
|
213
|
+
section.table-section > header.table-header h2 {
|
|
214
|
+
margin: 0; padding: 0; border: 0;
|
|
215
|
+
font-size: var(--fs-h3); color: var(--text-1);
|
|
216
|
+
}
|
|
217
|
+
section.table-section .table-meta {
|
|
218
|
+
font: var(--fs-small) ui-monospace, monospace;
|
|
219
|
+
color: var(--text-3);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
details.category > .cat-body {
|
|
223
|
+
background: var(--surface-0);
|
|
224
|
+
}
|
|
225
|
+
"""
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _compose_css() -> str:
|
|
229
|
+
return (reports_base.design_tokens_css()
|
|
230
|
+
+ reports_base.chrome_css()
|
|
231
|
+
+ _PER_DROP_CSS)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
_CSS = _compose_css()
|
|
235
|
+
|
|
236
|
+
# Virtual-scroll JS. One VTable per table.
|
|
237
|
+
_JS = r"""
|
|
238
|
+
(function(){
|
|
239
|
+
const ROW_H = 22;
|
|
240
|
+
const BUFFER = 8;
|
|
241
|
+
|
|
242
|
+
// For ID kinds: where to jump when an ID cell is clicked
|
|
243
|
+
const LINK_TARGET = {
|
|
244
|
+
shader: { table: 'shaders', col: 'shader_id' },
|
|
245
|
+
program: { table: 'programs', col: 'program_id' },
|
|
246
|
+
texture: { table: 'textures', col: 'tex_id' },
|
|
247
|
+
sampler: { table: 'samplers', col: 'sampler_id' },
|
|
248
|
+
buffer: { table: 'buffers', col: 'buffer_id' },
|
|
249
|
+
fbo: { table: 'fbos', col: 'fbo_id' },
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
function isNumeric(v){
|
|
253
|
+
return v != null && (typeof v === 'number' || (typeof v === 'string' && /^-?\d+(\.\d+)?([eE][+-]?\d+)?$/.test(v)));
|
|
254
|
+
}
|
|
255
|
+
function fmt(v){
|
|
256
|
+
if (v == null) return '';
|
|
257
|
+
if (typeof v === 'number'){
|
|
258
|
+
if (v === 0) return '0';
|
|
259
|
+
if (Math.abs(v) < 1e-4 || Math.abs(v) >= 1e7) return v.toExponential(4);
|
|
260
|
+
return (Math.round(v * 1e6) / 1e6).toString();
|
|
261
|
+
}
|
|
262
|
+
return String(v);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function lookupLabel(labels, kind, id){
|
|
266
|
+
if (!labels || !kind || id == null || id === '' || id === 0 || id === '0') return '';
|
|
267
|
+
const k = String(id);
|
|
268
|
+
const cap = labels.capture;
|
|
269
|
+
if (!cap || !labels.by_capture || !labels.by_capture[cap]) return '';
|
|
270
|
+
const buckets = labels.by_capture[cap];
|
|
271
|
+
if (kind === 'auto_by_slot_kind' || kind === 'auto_by_kind') return '';
|
|
272
|
+
if (kind === 'texture_list') return '';
|
|
273
|
+
return (buckets[kind] && buckets[kind][k]) || '';
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function autoKindForSlot(slotKind){
|
|
277
|
+
if (slotKind === 'texture') return 'texture';
|
|
278
|
+
if (slotKind === 'sampler') return 'sampler';
|
|
279
|
+
if (slotKind === 'ubo' || slotKind === 'ssbo') return 'buffer';
|
|
280
|
+
return '';
|
|
281
|
+
}
|
|
282
|
+
function autoKindForDescriptor(descriptorKind){
|
|
283
|
+
if (descriptorKind === 'ReadOnlyResource' || descriptorKind === 'ImageSampler' || descriptorKind === 'TypedBuffer') return 'texture';
|
|
284
|
+
if (descriptorKind === 'Sampler') return 'sampler';
|
|
285
|
+
if (descriptorKind === 'ConstantBuffer' || descriptorKind === 'ReadWriteResource' || descriptorKind === 'ReadWriteBuffer') return 'buffer';
|
|
286
|
+
return '';
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
class VTable {
|
|
290
|
+
constructor(host, payload, labels){
|
|
291
|
+
this.host = host;
|
|
292
|
+
this.cols = payload.cols;
|
|
293
|
+
this.rows = payload.rows;
|
|
294
|
+
this.labelCols = payload.labelCols || {};
|
|
295
|
+
this.labels = labels;
|
|
296
|
+
this.view = this.rows.slice();
|
|
297
|
+
this.sortCol = -1;
|
|
298
|
+
this.sortDir = 1;
|
|
299
|
+
|
|
300
|
+
// detect numeric columns from first 50 non-null cells
|
|
301
|
+
this.numericCols = new Set();
|
|
302
|
+
for (let ci = 0; ci < this.cols.length; ci++){
|
|
303
|
+
let count = 0, num = 0;
|
|
304
|
+
for (let ri = 0; ri < this.rows.length && count < 50; ri++){
|
|
305
|
+
const v = this.rows[ri][ci];
|
|
306
|
+
if (v == null || v === '') continue;
|
|
307
|
+
count++;
|
|
308
|
+
if (typeof v === 'number' || (typeof v === 'string' && /^-?\d+(\.\d+)?([eE][+-]?\d+)?$/.test(v))) num++;
|
|
309
|
+
}
|
|
310
|
+
if (count > 0 && num / count > 0.7) this.numericCols.add(ci);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// detect slot_kind column for draw_bindings (resource_id auto-label)
|
|
314
|
+
this.slotKindCol = this.cols.indexOf('slot_kind');
|
|
315
|
+
this.descriptorKindCol = this.cols.indexOf('descriptor_kind');
|
|
316
|
+
|
|
317
|
+
this.build();
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
build(){
|
|
321
|
+
const table = document.createElement('table');
|
|
322
|
+
table.className = 'data';
|
|
323
|
+
const thead = table.createTHead();
|
|
324
|
+
const tr = thead.insertRow();
|
|
325
|
+
for (let i = 0; i < this.cols.length; i++){
|
|
326
|
+
const th = document.createElement('th');
|
|
327
|
+
const txt = document.createTextNode(this.cols[i]);
|
|
328
|
+
th.appendChild(txt);
|
|
329
|
+
if (this.numericCols.has(i)) th.classList.add('numeric');
|
|
330
|
+
const arrow = document.createElement('span');
|
|
331
|
+
arrow.className = 'sort-arrow';
|
|
332
|
+
th.appendChild(arrow);
|
|
333
|
+
th.addEventListener('click', () => this.sort(i));
|
|
334
|
+
tr.appendChild(th);
|
|
335
|
+
}
|
|
336
|
+
const tbody = document.createElement('tbody');
|
|
337
|
+
const sTop = document.createElement('tr');
|
|
338
|
+
const sBot = document.createElement('tr');
|
|
339
|
+
sTop.className = 'spacer'; sBot.className = 'spacer';
|
|
340
|
+
const tdTop = document.createElement('td');
|
|
341
|
+
const tdBot = document.createElement('td');
|
|
342
|
+
tdTop.colSpan = this.cols.length;
|
|
343
|
+
tdBot.colSpan = this.cols.length;
|
|
344
|
+
sTop.appendChild(tdTop); sBot.appendChild(tdBot);
|
|
345
|
+
tbody.appendChild(sTop); tbody.appendChild(sBot);
|
|
346
|
+
table.appendChild(tbody);
|
|
347
|
+
this.host.appendChild(table);
|
|
348
|
+
|
|
349
|
+
this.tbody = tbody;
|
|
350
|
+
this.sTop = sTop;
|
|
351
|
+
this.sBot = sBot;
|
|
352
|
+
this.tdTop = tdTop;
|
|
353
|
+
this.tdBot = tdBot;
|
|
354
|
+
|
|
355
|
+
this.host.addEventListener('scroll', () => this.render());
|
|
356
|
+
window.addEventListener('resize', () => this.render());
|
|
357
|
+
// Re-render when a containing <details class="category"> opens.
|
|
358
|
+
const detailsEl = this.host.closest('details');
|
|
359
|
+
if (detailsEl){
|
|
360
|
+
detailsEl.addEventListener('toggle', () => {
|
|
361
|
+
if (detailsEl.open){
|
|
362
|
+
requestAnimationFrame(() => this.render());
|
|
363
|
+
setTimeout(() => this.render(), 50);
|
|
364
|
+
}
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
this.render();
|
|
368
|
+
// Re-render after layout settles (initial clientHeight can be 0)
|
|
369
|
+
requestAnimationFrame(() => this.render());
|
|
370
|
+
// And once more after fonts/sizes stabilize
|
|
371
|
+
setTimeout(() => this.render(), 50);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
sort(ci){
|
|
375
|
+
if (this.sortCol === ci) this.sortDir = -this.sortDir;
|
|
376
|
+
else { this.sortCol = ci; this.sortDir = 1; }
|
|
377
|
+
const dir = this.sortDir;
|
|
378
|
+
const isNum = this.numericCols.has(ci);
|
|
379
|
+
this.view.sort((a, b) => {
|
|
380
|
+
const aa = a[ci], bb = b[ci];
|
|
381
|
+
if (aa == null && bb == null) return 0;
|
|
382
|
+
if (aa == null) return 1;
|
|
383
|
+
if (bb == null) return -1;
|
|
384
|
+
if (isNum){
|
|
385
|
+
const na = +aa, nb = +bb;
|
|
386
|
+
return (na - nb) * dir;
|
|
387
|
+
}
|
|
388
|
+
return String(aa).localeCompare(String(bb)) * dir;
|
|
389
|
+
});
|
|
390
|
+
const headers = this.host.querySelectorAll('thead th');
|
|
391
|
+
for (let i = 0; i < headers.length; i++){
|
|
392
|
+
const a = headers[i].querySelector('.sort-arrow');
|
|
393
|
+
if (a) a.textContent = (i === ci) ? (dir > 0 ? ' ▲' : ' ▼') : '';
|
|
394
|
+
}
|
|
395
|
+
this.host.scrollTop = 0;
|
|
396
|
+
this.render();
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
filter(query){
|
|
400
|
+
const q = (query || '').trim().toLowerCase();
|
|
401
|
+
if (!q){
|
|
402
|
+
this.view = this.rows.slice();
|
|
403
|
+
} else {
|
|
404
|
+
const labels = this.labels;
|
|
405
|
+
const labelCols = this.labelCols;
|
|
406
|
+
const cols = this.cols;
|
|
407
|
+
const slotKindCol = this.slotKindCol;
|
|
408
|
+
const descriptorKindCol = this.descriptorKindCol;
|
|
409
|
+
this.view = this.rows.filter(r => {
|
|
410
|
+
for (let i = 0; i < r.length; i++){
|
|
411
|
+
const v = r[i];
|
|
412
|
+
if (v == null) continue;
|
|
413
|
+
if (String(v).toLowerCase().indexOf(q) >= 0) return true;
|
|
414
|
+
// also match against the resolved label, if any
|
|
415
|
+
const lc = labelCols[cols[i]];
|
|
416
|
+
if (!lc || v === 0 || v === '0' || v === '') continue;
|
|
417
|
+
let kind = lc;
|
|
418
|
+
if (kind === 'auto_by_slot_kind' && slotKindCol >= 0) kind = autoKindForSlot(r[slotKindCol]);
|
|
419
|
+
else if (kind === 'auto_by_kind' && descriptorKindCol >= 0) kind = autoKindForDescriptor(r[descriptorKindCol]);
|
|
420
|
+
if (kind === 'texture_list'){
|
|
421
|
+
const ids = String(v).split(';').filter(x => x);
|
|
422
|
+
for (const id of ids){
|
|
423
|
+
const lbl = lookupLabel(labels, 'texture', id);
|
|
424
|
+
if (lbl && lbl.toLowerCase().indexOf(q) >= 0) return true;
|
|
425
|
+
}
|
|
426
|
+
} else if (kind){
|
|
427
|
+
const lbl = lookupLabel(labels, kind, v);
|
|
428
|
+
if (lbl && lbl.toLowerCase().indexOf(q) >= 0) return true;
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
return false;
|
|
432
|
+
});
|
|
433
|
+
}
|
|
434
|
+
// resort if a sort was active
|
|
435
|
+
if (this.sortCol >= 0){
|
|
436
|
+
const ci = this.sortCol, dir = this.sortDir;
|
|
437
|
+
const isNum = this.numericCols.has(ci);
|
|
438
|
+
this.view.sort((a, b) => {
|
|
439
|
+
const aa = a[ci], bb = b[ci];
|
|
440
|
+
if (aa == null && bb == null) return 0;
|
|
441
|
+
if (aa == null) return 1;
|
|
442
|
+
if (bb == null) return -1;
|
|
443
|
+
if (isNum) return (+aa - +bb) * dir;
|
|
444
|
+
return String(aa).localeCompare(String(bb)) * dir;
|
|
445
|
+
});
|
|
446
|
+
}
|
|
447
|
+
this.host.scrollTop = 0;
|
|
448
|
+
this.render();
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
cellNode(value, ri, ci){
|
|
452
|
+
const td = document.createElement('td');
|
|
453
|
+
if (this.numericCols.has(ci)) td.classList.add('numeric');
|
|
454
|
+
|
|
455
|
+
const colName = this.cols[ci];
|
|
456
|
+
const lc = this.labelCols[colName];
|
|
457
|
+
let kind = lc;
|
|
458
|
+
if (kind === 'auto_by_slot_kind' && this.slotKindCol >= 0){
|
|
459
|
+
kind = autoKindForSlot(this.view[ri][this.slotKindCol]);
|
|
460
|
+
} else if (kind === 'auto_by_kind' && this.descriptorKindCol >= 0){
|
|
461
|
+
kind = autoKindForDescriptor(this.view[ri][this.descriptorKindCol]);
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// primary cell value: cross-link if we have a target for this kind
|
|
465
|
+
const formatted = fmt(value);
|
|
466
|
+
const link = LINK_TARGET[kind];
|
|
467
|
+
if (link && value != null && value !== '' && value !== 0 && value !== '0' && kind !== 'texture_list'){
|
|
468
|
+
const a = document.createElement('a');
|
|
469
|
+
a.href = '#' + link.table;
|
|
470
|
+
a.textContent = formatted;
|
|
471
|
+
a.title = 'jump to ' + link.table + ' filtered to ' + link.col + '=' + value;
|
|
472
|
+
a.addEventListener('click', (ev) => {
|
|
473
|
+
ev.preventDefault();
|
|
474
|
+
jumpToTable(link.table, String(value));
|
|
475
|
+
});
|
|
476
|
+
td.appendChild(a);
|
|
477
|
+
} else {
|
|
478
|
+
td.textContent = formatted;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
// label enrichment (appears after the value/link)
|
|
482
|
+
if (lc && value != null && value !== '' && value !== 0 && value !== '0'){
|
|
483
|
+
if (kind === 'texture_list'){
|
|
484
|
+
const ids = String(value).split(';').filter(x => x);
|
|
485
|
+
const labels = ids.map(id => lookupLabel(this.labels, 'texture', id))
|
|
486
|
+
.filter(x => x);
|
|
487
|
+
if (labels.length){
|
|
488
|
+
const span = document.createElement('span');
|
|
489
|
+
span.className = 'lbl';
|
|
490
|
+
span.textContent = labels.join(', ');
|
|
491
|
+
td.appendChild(span);
|
|
492
|
+
}
|
|
493
|
+
} else if (kind){
|
|
494
|
+
const label = lookupLabel(this.labels, kind, value);
|
|
495
|
+
if (label){
|
|
496
|
+
const span = document.createElement('span');
|
|
497
|
+
span.className = 'lbl';
|
|
498
|
+
span.textContent = label;
|
|
499
|
+
td.appendChild(span);
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
return td;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
render(){
|
|
507
|
+
const scrollTop = this.host.scrollTop;
|
|
508
|
+
const height = this.host.clientHeight || 600;
|
|
509
|
+
const len = this.view.length;
|
|
510
|
+
const start = Math.max(0, Math.floor(scrollTop / ROW_H) - BUFFER);
|
|
511
|
+
const end = Math.min(len, Math.ceil((scrollTop + height) / ROW_H) + BUFFER);
|
|
512
|
+
|
|
513
|
+
// clear data rows between spacers
|
|
514
|
+
while (this.sTop.nextSibling !== this.sBot){
|
|
515
|
+
this.tbody.removeChild(this.sTop.nextSibling);
|
|
516
|
+
}
|
|
517
|
+
// height on <tr> directly; <td> alone doesn't make a row tall in all browsers
|
|
518
|
+
this.sTop.style.height = (start * ROW_H) + 'px';
|
|
519
|
+
this.sBot.style.height = ((len - end) * ROW_H) + 'px';
|
|
520
|
+
this.tdTop.style.height = (start * ROW_H) + 'px';
|
|
521
|
+
this.tdBot.style.height = ((len - end) * ROW_H) + 'px';
|
|
522
|
+
|
|
523
|
+
const frag = document.createDocumentFragment();
|
|
524
|
+
for (let i = start; i < end; i++){
|
|
525
|
+
const tr = document.createElement('tr');
|
|
526
|
+
tr.style.height = ROW_H + 'px';
|
|
527
|
+
if (i % 2 === 1) tr.className = 'alt';
|
|
528
|
+
const row = this.view[i];
|
|
529
|
+
for (let ci = 0; ci < this.cols.length; ci++){
|
|
530
|
+
tr.appendChild(this.cellNode(row[ci], i, ci));
|
|
531
|
+
}
|
|
532
|
+
frag.appendChild(tr);
|
|
533
|
+
}
|
|
534
|
+
this.tbody.insertBefore(frag, this.sBot);
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
function jumpToTable(tableName, idValue){
|
|
539
|
+
const host = document.querySelector('div.table-scroll[data-table="' + tableName + '"]');
|
|
540
|
+
if (!host || !host._vt) return;
|
|
541
|
+
const section = host.closest('section');
|
|
542
|
+
const input = section ? section.querySelector('input[type=search]') : null;
|
|
543
|
+
if (input){
|
|
544
|
+
input.value = idValue;
|
|
545
|
+
// trigger filter immediately (no debounce on programmatic set)
|
|
546
|
+
host._vt.filter(idValue);
|
|
547
|
+
const counter = section.querySelector('.ct.visible-count');
|
|
548
|
+
if (counter){
|
|
549
|
+
const v = host._vt.view.length, t = host._vt.rows.length;
|
|
550
|
+
counter.textContent = v.toLocaleString() + ' / ' + t.toLocaleString() + ' visible';
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
section.scrollIntoView({behavior: 'smooth', block: 'start'});
|
|
554
|
+
}
|
|
555
|
+
window.__jumpToTable = jumpToTable;
|
|
556
|
+
|
|
557
|
+
window.addEventListener('DOMContentLoaded', () => {
|
|
558
|
+
const labels = window.__labels || {};
|
|
559
|
+
document.querySelectorAll('div.table-scroll[data-table]').forEach(host => {
|
|
560
|
+
const name = host.dataset.table;
|
|
561
|
+
const payload = window['__data_' + name];
|
|
562
|
+
if (!payload){ return; }
|
|
563
|
+
const labelsForTable = Object.assign({}, labels);
|
|
564
|
+
// Per-section: capture from data is row-level; use the section's data-capture if set
|
|
565
|
+
labelsForTable.capture = host.dataset.capture || (payload.rows[0] ? payload.rows[0][3] : '');
|
|
566
|
+
const vt = new VTable(host, payload, labelsForTable);
|
|
567
|
+
host._vt = vt;
|
|
568
|
+
|
|
569
|
+
const section = host.closest('section');
|
|
570
|
+
const input = section.querySelector('input[type=search]');
|
|
571
|
+
const counter = section.querySelector('.ct.visible-count');
|
|
572
|
+
function updateCounter(){
|
|
573
|
+
const v = vt.view.length, t = vt.rows.length;
|
|
574
|
+
counter.textContent = v.toLocaleString() + ' / ' + t.toLocaleString() + ' visible';
|
|
575
|
+
}
|
|
576
|
+
updateCounter();
|
|
577
|
+
let timer = null;
|
|
578
|
+
input.addEventListener('input', () => {
|
|
579
|
+
clearTimeout(timer);
|
|
580
|
+
timer = setTimeout(() => { vt.filter(input.value); updateCounter(); }, 80);
|
|
581
|
+
});
|
|
582
|
+
});
|
|
583
|
+
});
|
|
584
|
+
})();
|
|
585
|
+
"""
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
def _h(s) -> str:
|
|
589
|
+
return _html.escape(str(s if s is not None else ''))
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
def _row_count(pq_path: str) -> int:
|
|
593
|
+
try:
|
|
594
|
+
return papq.read_metadata(pq_path).num_rows
|
|
595
|
+
except Exception:
|
|
596
|
+
return 0
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
def _file_size_label(path: str) -> str:
|
|
600
|
+
if not os.path.exists(path):
|
|
601
|
+
return ''
|
|
602
|
+
n = os.path.getsize(path)
|
|
603
|
+
if n < 1024:
|
|
604
|
+
return f'{n} B'
|
|
605
|
+
if n < 1024 * 1024:
|
|
606
|
+
return f'{n / 1024:.1f} KB'
|
|
607
|
+
return f'{n / 1024 / 1024:.1f} MB'
|
|
608
|
+
|
|
609
|
+
|
|
610
|
+
def _table_payload(table_name: str, out_dir: str) -> dict | None:
|
|
611
|
+
pq = os.path.join(out_dir, f'{table_name}.parquet')
|
|
612
|
+
if not os.path.exists(pq):
|
|
613
|
+
return None
|
|
614
|
+
t = papq.read_table(pq)
|
|
615
|
+
if t.num_rows == 0:
|
|
616
|
+
return None
|
|
617
|
+
cols = t.column_names
|
|
618
|
+
# Build rows as a list-of-lists. Pyarrow's to_pylist on a Table doesn't
|
|
619
|
+
# exist; use per-column extraction.
|
|
620
|
+
arrays = [t.column(c).to_pylist() for c in cols]
|
|
621
|
+
n = t.num_rows
|
|
622
|
+
rows = [[arrays[ci][ri] for ci in range(len(cols))] for ri in range(n)]
|
|
623
|
+
label_cols = _LABEL_COLS.get(table_name, {})
|
|
624
|
+
return {'cols': cols, 'rows': rows, 'labelCols': label_cols}
|
|
625
|
+
|
|
626
|
+
|
|
627
|
+
def _inline_table_with_data(table_name: str, out_dir: str,
|
|
628
|
+
sidecar_rel: str = '.') -> tuple[str, str] | None:
|
|
629
|
+
"""Returns (section_html, script_html) or None if table empty.
|
|
630
|
+
|
|
631
|
+
sidecar_rel: relative path from rendered HTML to the data dir, used to
|
|
632
|
+
construct CSV/parquet download links. Default '.' for legacy callers
|
|
633
|
+
where HTML lives next to data.
|
|
634
|
+
"""
|
|
635
|
+
payload = _table_payload(table_name, out_dir)
|
|
636
|
+
if payload is None:
|
|
637
|
+
return None
|
|
638
|
+
n_total = len(payload['rows'])
|
|
639
|
+
n_cols = len(payload['cols'])
|
|
640
|
+
pq_sz = _file_size_label(os.path.join(out_dir, f'{table_name}.parquet'))
|
|
641
|
+
csv_sz = _file_size_label(os.path.join(out_dir, f'{table_name}.csv'))
|
|
642
|
+
|
|
643
|
+
prefix = '' if sidecar_rel in ('.', '') else sidecar_rel.rstrip('/') + '/'
|
|
644
|
+
|
|
645
|
+
section = []
|
|
646
|
+
section.append(f'<section class="table-section" id="{table_name}">')
|
|
647
|
+
section.append('<header class="table-header">')
|
|
648
|
+
section.append(f'<h2>{_h(table_name)}</h2>')
|
|
649
|
+
section.append(f'<span class="table-meta">{n_total:,} rows, {n_cols} cols</span>')
|
|
650
|
+
section.append('</header>')
|
|
651
|
+
section.append('<div class="controls">')
|
|
652
|
+
section.append(f'<input type="search" placeholder="filter {table_name}...">')
|
|
653
|
+
section.append('<span class="ct visible-count"></span>')
|
|
654
|
+
section.append(f'<a class="dl" href="{prefix}{table_name}.csv">CSV ({csv_sz})</a>')
|
|
655
|
+
section.append(f'<a class="dl" href="{prefix}{table_name}.parquet">parquet ({pq_sz})</a>')
|
|
656
|
+
section.append('</div>')
|
|
657
|
+
section.append(f'<div class="table-scroll" data-table="{table_name}"></div>')
|
|
658
|
+
section.append('</section>')
|
|
659
|
+
|
|
660
|
+
script = (f'<script>window.__data_{table_name}='
|
|
661
|
+
f'{json.dumps(payload, separators=(",", ":"))};</script>')
|
|
662
|
+
return '\n'.join(section), script
|
|
663
|
+
|
|
664
|
+
|
|
665
|
+
def _categorize(table_specs: list[tuple[str, int, int]]) -> dict:
|
|
666
|
+
"""Return {category: [(name, n_rows, n_cols), ...]} preserving order."""
|
|
667
|
+
by_cat: dict[str, list] = {cat: [] for cat in _CATEGORY_ORDER if cat != 'sidecars'}
|
|
668
|
+
spec_by_name = {name: (name, n_rows, n_cols)
|
|
669
|
+
for (name, n_rows, n_cols) in table_specs}
|
|
670
|
+
used: set = set()
|
|
671
|
+
for cat in _CATEGORY_ORDER:
|
|
672
|
+
if cat == 'sidecars':
|
|
673
|
+
continue
|
|
674
|
+
for name in _CATEGORY_MAP.get(cat, []):
|
|
675
|
+
if name in spec_by_name:
|
|
676
|
+
by_cat[cat].append(spec_by_name[name])
|
|
677
|
+
used.add(name)
|
|
678
|
+
# Anything uncategorized goes into 'actions' as a tail bucket
|
|
679
|
+
for name, n_rows, n_cols in table_specs:
|
|
680
|
+
if name not in used:
|
|
681
|
+
by_cat.setdefault('actions', []).append((name, n_rows, n_cols))
|
|
682
|
+
return by_cat
|
|
683
|
+
|
|
684
|
+
|
|
685
|
+
def _toc(by_cat: dict) -> str:
|
|
686
|
+
"""Category-aware TOC. Each category links to its <details> anchor."""
|
|
687
|
+
parts = ['<nav class="toc">']
|
|
688
|
+
for cat in _CATEGORY_ORDER:
|
|
689
|
+
if cat == 'sidecars':
|
|
690
|
+
parts.append(f'<a href="#cat-sidecars"><span>sidecars</span></a>')
|
|
691
|
+
continue
|
|
692
|
+
items = by_cat.get(cat, [])
|
|
693
|
+
if not items:
|
|
694
|
+
continue
|
|
695
|
+
total_rows = sum(r for _, r, _ in items)
|
|
696
|
+
parts.append(f'<a href="#cat-{_h(cat)}"><span>{_h(cat)}</span>'
|
|
697
|
+
f'<span class="ct">{len(items)}t, {total_rows:,}r</span></a>')
|
|
698
|
+
parts.append('</nav>')
|
|
699
|
+
return '\n'.join(parts)
|
|
700
|
+
|
|
701
|
+
|
|
702
|
+
def _category_block(cat: str, items: list, table_sections: dict,
|
|
703
|
+
is_open: bool) -> str:
|
|
704
|
+
"""Render <details class='category'> wrapping its table sections."""
|
|
705
|
+
if not items:
|
|
706
|
+
return ''
|
|
707
|
+
total_rows = sum(r for _, r, _ in items)
|
|
708
|
+
open_attr = ' open' if is_open else ''
|
|
709
|
+
parts = [
|
|
710
|
+
f'<details class="category" id="cat-{_h(cat)}"{open_attr}>',
|
|
711
|
+
'<summary>',
|
|
712
|
+
f'<span class="cat-name">{_h(cat)}</span>',
|
|
713
|
+
f'<span class="cat-meta">{len(items)} tables, {total_rows:,} rows</span>',
|
|
714
|
+
'</summary>',
|
|
715
|
+
'<div class="cat-body">',
|
|
716
|
+
]
|
|
717
|
+
for name, _, _ in items:
|
|
718
|
+
sec_html = table_sections.get(name)
|
|
719
|
+
if sec_html:
|
|
720
|
+
parts.append(sec_html)
|
|
721
|
+
parts.append('</div></details>')
|
|
722
|
+
return '\n'.join(parts)
|
|
723
|
+
|
|
724
|
+
|
|
725
|
+
def _sidecar_category(out_dir: str, sidecar_rel: str = '.') -> str:
|
|
726
|
+
"""Render the sidecars category as <details class='category'>.
|
|
727
|
+
|
|
728
|
+
sidecar_rel: relative path from rendered HTML to data dir, used to
|
|
729
|
+
construct download links. Default '.' for legacy callers.
|
|
730
|
+
"""
|
|
731
|
+
body_parts = []
|
|
732
|
+
counts = []
|
|
733
|
+
prefix = '' if sidecar_rel in ('.', '') else sidecar_rel.rstrip('/') + '/'
|
|
734
|
+
|
|
735
|
+
src_dir = os.path.join(out_dir, 'shader_src')
|
|
736
|
+
if os.path.isdir(src_dir):
|
|
737
|
+
files = os.listdir(src_dir)
|
|
738
|
+
files.sort(key=lambda f: int(f.split('.', 1)[0]) if f.split('.', 1)[0].isdigit() else 0)
|
|
739
|
+
counts.append(f'shader_src {len(files)}')
|
|
740
|
+
body_parts.append(f'<h3>shader_src ({len(files)} files)</h3>')
|
|
741
|
+
body_parts.append('<ul class="sidecar-list">')
|
|
742
|
+
for f in files:
|
|
743
|
+
sz = os.path.getsize(os.path.join(src_dir, f))
|
|
744
|
+
sz_str = f'{sz // 1024}K' if sz >= 1024 else f'{sz}B'
|
|
745
|
+
body_parts.append(f'<li><a href="{prefix}shader_src/{_h(f)}">{_h(f)}</a><span>{sz_str}</span></li>')
|
|
746
|
+
body_parts.append('</ul>')
|
|
747
|
+
|
|
748
|
+
hist_dir = os.path.join(out_dir, 'histogram')
|
|
749
|
+
if os.path.isdir(hist_dir):
|
|
750
|
+
files = sorted(os.listdir(hist_dir))
|
|
751
|
+
if files:
|
|
752
|
+
counts.append(f'histogram {len(files)}')
|
|
753
|
+
body_parts.append(f'<h3>histogram ({len(files)} files)</h3>')
|
|
754
|
+
body_parts.append('<ul class="sidecar-list">')
|
|
755
|
+
for f in files:
|
|
756
|
+
body_parts.append(f'<li><a href="{prefix}histogram/{_h(f)}">{_h(f)}</a></li>')
|
|
757
|
+
body_parts.append('</ul>')
|
|
758
|
+
|
|
759
|
+
for jsonl in ('frame_metadata.jsonl', 'uniforms_per_pass.jsonl'):
|
|
760
|
+
p = os.path.join(out_dir, jsonl)
|
|
761
|
+
if os.path.exists(p):
|
|
762
|
+
counts.append(jsonl)
|
|
763
|
+
body_parts.append(f'<h3>{_h(jsonl)}</h3>')
|
|
764
|
+
body_parts.append(f'<p><a href="{prefix}{_h(jsonl)}">download</a> '
|
|
765
|
+
f'<span class="ct">{_file_size_label(p)}</span></p>')
|
|
766
|
+
|
|
767
|
+
if not body_parts:
|
|
768
|
+
return ''
|
|
769
|
+
|
|
770
|
+
meta = ' / '.join(counts)
|
|
771
|
+
return ('<details class="category" id="cat-sidecars">'
|
|
772
|
+
'<summary>'
|
|
773
|
+
'<span class="cat-name">sidecars</span>'
|
|
774
|
+
f'<span class="cat-meta">{_h(meta)}</span>'
|
|
775
|
+
'</summary>'
|
|
776
|
+
'<div class="cat-body">'
|
|
777
|
+
+ '\n'.join(body_parts)
|
|
778
|
+
+ '</div></details>')
|
|
779
|
+
|
|
780
|
+
|
|
781
|
+
def _read_gl_renderer(out_dir: str) -> str:
|
|
782
|
+
p = os.path.join(out_dir, 'frame_metadata.jsonl')
|
|
783
|
+
if not os.path.exists(p):
|
|
784
|
+
return ''
|
|
785
|
+
try:
|
|
786
|
+
with open(p, 'r', encoding='utf-8') as f:
|
|
787
|
+
for line in f:
|
|
788
|
+
try:
|
|
789
|
+
o = json.loads(line)
|
|
790
|
+
except json.JSONDecodeError:
|
|
791
|
+
continue
|
|
792
|
+
s = o.get('gl_renderer_string') or o.get('gl_renderer') or ''
|
|
793
|
+
if s:
|
|
794
|
+
return s
|
|
795
|
+
except OSError:
|
|
796
|
+
pass
|
|
797
|
+
return ''
|
|
798
|
+
|
|
799
|
+
|
|
800
|
+
def render_drop(drill_dir: str, *, data_dir: str,
|
|
801
|
+
area: str, drop_date: str, drop_label: str,
|
|
802
|
+
captures: list[str], schema_version: int, build_timestamp: str,
|
|
803
|
+
row_counts: dict[str, int]) -> str:
|
|
804
|
+
"""Render the per-drop browser HTML into drill_dir, reading data from data_dir.
|
|
805
|
+
|
|
806
|
+
drill_dir: <root>/_reports/drill/<area>/<drop>/ (HTML output target)
|
|
807
|
+
data_dir: <root>/_data/<area>/<drop>/ (parquet + sidecars source)
|
|
808
|
+
|
|
809
|
+
Returns path to drill_dir/index.html. Sidecar links (shader_src, histogram)
|
|
810
|
+
point at data_dir via relative path.
|
|
811
|
+
"""
|
|
812
|
+
total_rows = sum(row_counts.values())
|
|
813
|
+
|
|
814
|
+
table_specs: list[tuple[str, int, int]] = []
|
|
815
|
+
for table_name, n in sorted(row_counts.items()):
|
|
816
|
+
if n <= 0:
|
|
817
|
+
continue
|
|
818
|
+
pq = os.path.join(data_dir, f'{table_name}.parquet')
|
|
819
|
+
if not os.path.exists(pq):
|
|
820
|
+
continue
|
|
821
|
+
n_cols = len(papq.read_schema(pq).names)
|
|
822
|
+
table_specs.append((table_name, n, n_cols))
|
|
823
|
+
|
|
824
|
+
# Load labels sidecar (small JSON) from data_dir
|
|
825
|
+
labels_path = os.path.join(data_dir, '_resource_labels.json')
|
|
826
|
+
labels_json = '{}'
|
|
827
|
+
if os.path.exists(labels_path):
|
|
828
|
+
with open(labels_path, 'r', encoding='utf-8') as f:
|
|
829
|
+
labels_json = f.read()
|
|
830
|
+
|
|
831
|
+
drop_key = f'{drop_date}_{drop_label}' if drop_label else drop_date
|
|
832
|
+
gl_renderer = _read_gl_renderer(data_dir)
|
|
833
|
+
|
|
834
|
+
kpis = [
|
|
835
|
+
{'label': 'rows total', 'value': reports_base.fmt_int(total_rows)},
|
|
836
|
+
{'label': 'captures', 'value': reports_base.fmt_int(len(captures))},
|
|
837
|
+
{'label': 'tables', 'value': reports_base.fmt_int(len(table_specs))},
|
|
838
|
+
]
|
|
839
|
+
|
|
840
|
+
parts = ['<!doctype html><html lang="en"><head><meta charset="utf-8">']
|
|
841
|
+
parts.append(f'<title>{_h(area)} {_h(drop_date)}</title>')
|
|
842
|
+
parts.append(f'<link rel="icon" href="{reports_base._FAVICON_HREF}">')
|
|
843
|
+
parts.append(f'<style>{_CSS}</style></head>'
|
|
844
|
+
f'<body data-page-kind="drop-browser" style="--hdr-offset: 120px">')
|
|
845
|
+
|
|
846
|
+
parts.append(f'<h1>{_h(area)} / {_h(drop_key)}</h1>')
|
|
847
|
+
parts.append('<header class="strip">')
|
|
848
|
+
parts.append(f'<span>area <strong>{_h(area)}</strong></span>')
|
|
849
|
+
parts.append(f'<span>drop <strong>{_h(drop_key)}</strong></span>')
|
|
850
|
+
parts.append(f'<span>rows <strong>{total_rows:,}</strong></span>')
|
|
851
|
+
parts.append(f'<span>built <strong>{_h(build_timestamp)}</strong></span>')
|
|
852
|
+
parts.append('</header>')
|
|
853
|
+
# drill_dir = <root>/_reports/drill/<area>/<drop>/ → up 4 to reach <root>
|
|
854
|
+
parts.append('<nav class="crumb">'
|
|
855
|
+
'<a href="../../../../index.html" data-link-kind="crumb">root catalog</a>'
|
|
856
|
+
'<a href="../../../index.html" data-link-kind="crumb">dashboard</a>'
|
|
857
|
+
'</nav>')
|
|
858
|
+
|
|
859
|
+
# Summary bar: per-drop aggregates
|
|
860
|
+
n_passes = row_counts.get('passes', 0)
|
|
861
|
+
n_draws = row_counts.get('draws', 0)
|
|
862
|
+
parts.append(reports_base.summary_bar(
|
|
863
|
+
'this drop',
|
|
864
|
+
f'{_h(area)} / {_h(drop_key)}',
|
|
865
|
+
sub=f'{reports_base.fmt_int(n_draws)} draws; {reports_base.fmt_int(n_passes)} passes; {reports_base.fmt_int(len(captures))} captures',
|
|
866
|
+
tone='neutral',
|
|
867
|
+
))
|
|
868
|
+
|
|
869
|
+
if gl_renderer:
|
|
870
|
+
parts.append(f'<div class="device-strip">{_h(gl_renderer)}</div>')
|
|
871
|
+
|
|
872
|
+
parts.append(reports_base.kpi_strip(kpis))
|
|
873
|
+
|
|
874
|
+
by_cat = _categorize(table_specs)
|
|
875
|
+
parts.append(_toc(by_cat))
|
|
876
|
+
|
|
877
|
+
# Compute relative path from drill_dir → data_dir for sidecar/shader_src links.
|
|
878
|
+
data_rel = os.path.relpath(data_dir, drill_dir).replace('\\', '/')
|
|
879
|
+
|
|
880
|
+
table_sections: dict[str, str] = {}
|
|
881
|
+
scripts: list[str] = []
|
|
882
|
+
for name, _, _ in table_specs:
|
|
883
|
+
result = _inline_table_with_data(name, data_dir, sidecar_rel=data_rel)
|
|
884
|
+
if result is None:
|
|
885
|
+
continue
|
|
886
|
+
sec, scr = result
|
|
887
|
+
table_sections[name] = sec
|
|
888
|
+
scripts.append(scr)
|
|
889
|
+
|
|
890
|
+
for cat in _CATEGORY_ORDER:
|
|
891
|
+
if cat == 'sidecars':
|
|
892
|
+
sidecar_html = _sidecar_category(data_dir, sidecar_rel=data_rel)
|
|
893
|
+
if sidecar_html:
|
|
894
|
+
parts.append(sidecar_html)
|
|
895
|
+
continue
|
|
896
|
+
block = _category_block(cat, by_cat.get(cat, []), table_sections,
|
|
897
|
+
is_open=(cat in _DEFAULT_OPEN))
|
|
898
|
+
if block:
|
|
899
|
+
parts.append(block)
|
|
900
|
+
|
|
901
|
+
parts.append(f'<script>window.__labels={labels_json};</script>')
|
|
902
|
+
parts.extend(scripts)
|
|
903
|
+
parts.append(f'<script>{_JS}</script>')
|
|
904
|
+
parts.append('</body></html>')
|
|
905
|
+
|
|
906
|
+
out_path = os.path.join(drill_dir, 'index.html')
|
|
907
|
+
with open(out_path, 'w', encoding='utf-8') as f:
|
|
908
|
+
f.write('\n'.join(parts))
|
|
909
|
+
return out_path
|
|
910
|
+
|
|
911
|
+
|
|
912
|
+
def render_root(root: str) -> str:
|
|
913
|
+
from .. import paths as _paths
|
|
914
|
+
cat_pq = _paths.catalog_parquet(root)
|
|
915
|
+
cat_json = _paths.catalog_json(root)
|
|
916
|
+
root_index = _paths.root_index_html(root)
|
|
917
|
+
if not os.path.exists(cat_pq):
|
|
918
|
+
with open(root_index, 'w', encoding='utf-8') as f:
|
|
919
|
+
f.write('<!doctype html><html><body>no catalog yet</body></html>')
|
|
920
|
+
return root_index
|
|
921
|
+
|
|
922
|
+
t = papq.read_table(cat_pq)
|
|
923
|
+
summary = {}
|
|
924
|
+
if os.path.exists(cat_json):
|
|
925
|
+
with open(cat_json, 'r', encoding='utf-8') as f:
|
|
926
|
+
summary = json.load(f)
|
|
927
|
+
|
|
928
|
+
cols = t.column_names
|
|
929
|
+
arrays = [t.column(c).to_pylist() for c in cols]
|
|
930
|
+
n = t.num_rows
|
|
931
|
+
rows = [[arrays[ci][ri] for ci in range(len(cols))] for ri in range(n)]
|
|
932
|
+
|
|
933
|
+
# analysis_out_path column stored relative under _data/. Transform link
|
|
934
|
+
# target to the per-drop browser at _reports/drill/<area>/<drop>/index.html.
|
|
935
|
+
path_idx = cols.index('analysis_out_path') if 'analysis_out_path' in cols else -1
|
|
936
|
+
if path_idx >= 0:
|
|
937
|
+
for r in rows:
|
|
938
|
+
v = str(r[path_idx])
|
|
939
|
+
# Replace leading "_data/" with "_reports/drill/" and append index.html.
|
|
940
|
+
if v.startswith('_data/') or v.startswith('_data\\'):
|
|
941
|
+
drill_rel = '_reports/drill/' + v[len('_data/'):].replace('\\', '/')
|
|
942
|
+
elif os.path.isabs(v):
|
|
943
|
+
drill_rel = os.path.relpath(
|
|
944
|
+
_paths.drop_dir_to_drill_dir(v), root).replace('\\', '/')
|
|
945
|
+
else:
|
|
946
|
+
drill_rel = v.replace('\\', '/')
|
|
947
|
+
r[path_idx] = drill_rel.rstrip('/') + '/index.html'
|
|
948
|
+
|
|
949
|
+
payload = {'cols': cols, 'rows': rows, 'labelCols': {}}
|
|
950
|
+
|
|
951
|
+
parts = ['<!doctype html><html lang="en"><head><meta charset="utf-8">']
|
|
952
|
+
parts.append('<title>capture analysis catalog</title>')
|
|
953
|
+
parts.append(f'<link rel="icon" href="{reports_base._FAVICON_HREF}">')
|
|
954
|
+
parts.append(f'<style>{_CSS}</style></head><body style="--hdr-offset: 120px">')
|
|
955
|
+
parts.append('<header class="strip">')
|
|
956
|
+
parts.append(f'<span>built <strong>{_h(summary.get("build_timestamp", ""))}</strong></span>')
|
|
957
|
+
parts.append(f'<span>drops <strong>{summary.get("drop_count", 0)}</strong></span>')
|
|
958
|
+
parts.append('</header>')
|
|
959
|
+
|
|
960
|
+
# Summary bar: latest drop + area/capture counts
|
|
961
|
+
area_idx = cols.index('area') if 'area' in cols else -1
|
|
962
|
+
date_idx = cols.index('drop_date') if 'drop_date' in cols else -1
|
|
963
|
+
label_idx = cols.index('drop_label') if 'drop_label' in cols else -1
|
|
964
|
+
unique_areas: set = set()
|
|
965
|
+
latest_date = ''
|
|
966
|
+
latest_label = ''
|
|
967
|
+
for r in rows:
|
|
968
|
+
if area_idx >= 0 and r[area_idx]:
|
|
969
|
+
unique_areas.add(str(r[area_idx]))
|
|
970
|
+
if date_idx >= 0 and r[date_idx]:
|
|
971
|
+
dval = str(r[date_idx])
|
|
972
|
+
if dval > latest_date:
|
|
973
|
+
latest_date = dval
|
|
974
|
+
latest_label = str(r[label_idx]) if label_idx >= 0 else ''
|
|
975
|
+
if latest_date:
|
|
976
|
+
head_text = f'{latest_date}'
|
|
977
|
+
if latest_label:
|
|
978
|
+
head_text = f'{latest_date} / {latest_label}'
|
|
979
|
+
parts.append(reports_base.summary_bar(
|
|
980
|
+
'latest drop',
|
|
981
|
+
head_text,
|
|
982
|
+
sub=f'{len(unique_areas)} areas; {n} catalog rows',
|
|
983
|
+
link_href='_reports/index.html',
|
|
984
|
+
link_text='dashboard',
|
|
985
|
+
tone='neutral',
|
|
986
|
+
))
|
|
987
|
+
|
|
988
|
+
# Reports section
|
|
989
|
+
reports_dir = os.path.join(root, '_reports')
|
|
990
|
+
if os.path.isdir(reports_dir):
|
|
991
|
+
dashboard = os.path.join(reports_dir, 'index.html')
|
|
992
|
+
if os.path.exists(dashboard):
|
|
993
|
+
parts.append('<section><h2 id="dashboard">dashboard</h2>'
|
|
994
|
+
'<div class="chip-cluster">'
|
|
995
|
+
'<a href="_reports/index.html" data-link-kind="primary">'
|
|
996
|
+
'cumulative reports dashboard</a>'
|
|
997
|
+
'</div></section>')
|
|
998
|
+
|
|
999
|
+
report_files = sorted(
|
|
1000
|
+
f for f in os.listdir(reports_dir)
|
|
1001
|
+
if f.endswith('.html') and f != 'index.html'
|
|
1002
|
+
)
|
|
1003
|
+
if report_files:
|
|
1004
|
+
parts.append('<section><h2 id="reports">reports</h2>'
|
|
1005
|
+
'<div class="catalog-grid">')
|
|
1006
|
+
for f in report_files:
|
|
1007
|
+
parts.append(f'<a href="_reports/{_h(f)}" data-link-kind="primary">'
|
|
1008
|
+
f'{_h(f[:-5])}</a>')
|
|
1009
|
+
parts.append('</div></section>')
|
|
1010
|
+
|
|
1011
|
+
ab_dir = os.path.join(reports_dir, 'ab')
|
|
1012
|
+
if os.path.isdir(ab_dir):
|
|
1013
|
+
pairs = sorted(
|
|
1014
|
+
d for d in os.listdir(ab_dir)
|
|
1015
|
+
if os.path.isdir(os.path.join(ab_dir, d))
|
|
1016
|
+
)
|
|
1017
|
+
if pairs:
|
|
1018
|
+
parts.append('<section><h2 id="ab">a/b comparisons</h2>'
|
|
1019
|
+
'<div class="pair-list">')
|
|
1020
|
+
for pair in pairs:
|
|
1021
|
+
pair_files = sorted(
|
|
1022
|
+
f for f in os.listdir(os.path.join(ab_dir, pair))
|
|
1023
|
+
if f.endswith('.html')
|
|
1024
|
+
)
|
|
1025
|
+
chips = ''.join(
|
|
1026
|
+
f'<a href="_reports/ab/{_h(pair)}/{_h(f)}" data-link-kind="primary">'
|
|
1027
|
+
f'{_h(f[:-5])}</a>'
|
|
1028
|
+
for f in pair_files
|
|
1029
|
+
)
|
|
1030
|
+
parts.append(
|
|
1031
|
+
f'<div class="pair-group">'
|
|
1032
|
+
f'<h3>{_h(pair)}</h3>'
|
|
1033
|
+
f'<div class="chip-cluster">{chips}</div>'
|
|
1034
|
+
f'</div>'
|
|
1035
|
+
)
|
|
1036
|
+
parts.append('</div></section>')
|
|
1037
|
+
|
|
1038
|
+
parts.append('<section><h2>catalog</h2>')
|
|
1039
|
+
parts.append('<div class="controls">')
|
|
1040
|
+
parts.append('<input type="search" placeholder="filter">')
|
|
1041
|
+
parts.append('<span class="ct visible-count"></span>')
|
|
1042
|
+
parts.append(f'<a class="dl" href="_data/_catalog.csv" data-link-kind="inline">CSV</a>')
|
|
1043
|
+
parts.append(f'<a class="dl" href="_data/_catalog.parquet" data-link-kind="inline">parquet</a>')
|
|
1044
|
+
parts.append('</div>')
|
|
1045
|
+
parts.append(f'<div class="table-scroll" data-table="catalog"></div>')
|
|
1046
|
+
parts.append('</section>')
|
|
1047
|
+
|
|
1048
|
+
parts.append(f'<script>window.__data_catalog={json.dumps(payload, separators=(",", ":"))};</script>')
|
|
1049
|
+
parts.append(f'<script>window.__labels={{}};</script>')
|
|
1050
|
+
parts.append(f'<script>{_JS}</script>')
|
|
1051
|
+
parts.append('</body></html>')
|
|
1052
|
+
|
|
1053
|
+
out_path = root_index
|
|
1054
|
+
with open(out_path, 'w', encoding='utf-8') as f:
|
|
1055
|
+
f.write('\n'.join(parts))
|
|
1056
|
+
return out_path
|