Files
orisys-app/tools/scene_assets/bench_scene.py
T
ykcheungab 5cd92254e1 feat(studio-3d): 阶段 5 —— 发布保护(签名/可选加密/平台锁定)与性能长时报告
- What: 新增 `scene3d/protect.py`(封套 v2:Ed25519 签名 + 可选 AES-256-GCM,nonce/salt 每包唯一、
  元数据作 AAD、明文摘要进签名、KeyProvider 只问 get_content_key)与 `record/visualization3d.py`
  (session.json 的 visualization3d 块:写入版本化、读取时缺块=不支持);`package.py` 支持 v2
  (payload=plain 仅签名 / aes-gcm 签名+加密)并继续读 v1,新增解压比上限与"写入后自读回"校验;
  `contracts.py` 新增 PlatformLock/PlatformInfo/check_platform_lock、Sealing、platform_mismatch 诊断码;
  `Scene3DView` 在装配前查 OS/架构、拿到 capabilities 后查图形半区,违反即撤下场景并在面板显示原因;
  工具 `compile_scene.py` 新增 --sign-key/--write-verify-key/--encrypt-key-id/--key-file/--lock-from-probe,
  `validate_scene.py` 新增 --key-file/--key-id/--verify-key/--require-signature(发布门禁),
  新增 `bench_scene.py`(p50/p95 分阶段 + --cpu-only + --vertices N + --package)与
  `soak_scene.py`(RSS 四分位速率 + 队列上界 + 抽稀采样);两份报告落到 docs/architecture/。
- Why: 规格 §5.2 的保护目标是"提高复制成本"而不是"防提取",所以要的是签名(来源)+ 可选认证加密(机密性)
  + 密钥隔离,而不是自制加密或把 ZIP 当加密;§12 的"包保护/性能/长时"三行需要可复现的数字与判定标准,
  而不是一次性的观感。平台锁定还必须只降级三维:锁定不通过时二维照常。
- Verification: shared 全量通过(新增 test_scene3d_protect.py 35 项:封套往返/篡改/密钥/锁定/记录块/
  compile→validate 端到端发布流程;test_scene3d_package.py 20 项含 v1↔v2 兼容);apps/studio 全量通过
  (test_scene3d_view.py 30 项含锁定拒绝与 session.json 块);ui_check 566 项 0 失败;locale_check 0 缺失;
  真实资产端到端:1032 顶点编译出 signed+sealed+locked 包(56 927 B 密文),validate --require-signature 退出 0,
  改一个字节退出 2;性能报告(perf-report-windows-dev.json):1032 顶点 compose p50 0.50 ms(30 fps 上限内),
  20022 顶点/39480 三角形 compose p50 6.15 ms(约 33 ms 帧的 18%),采集线程 submit p95 < 0.4 ms(合成器被人为放慢到 50 ms);
  长时报告(soak-report-windows-dev.json):无窗口 10 min/60 fps 尾段 0.354 MB/min,GUI 4 min/30 fps 尾段 0.809 MB/min,
  队列上界(mailbox=1、单飞行合成)两者均成立。过程中修掉:GUI 进程里的合成器计时被 GIL/vsync 污染(98.8 ms vs 真值 6.1 ms)、
  跨线程读 last_stats 撕裂、key id 无 provider 时会静默写明文包、soak 工具自身记时间列表会无界增长、RSS 读取失败伪装成零增长、
  平台锁定不是终态诊断。
- Not in this change: ARM 板卡的能力报告与 2 小时门禁运行(按用户要求暂缓;命令与判定标准已写入阶段 5 文档);
  GPU 显存随时间的测量(无可移植 API,报告说明渲染器缓冲估计恒定且更新路径不新建 GPU 对象);
  `cryptography` 在此环境无法联网安装,本机验收用的是本机已有的 cp310 构建(pyproject 里已声明为可选 extra
  `protected-assets`)。
2026-09-14 16:44:33 +08:00

668 lines
25 KiB
Python

"""Measure the 3D view's cost on this machine, per stage and as a percentile.
Stage 5 deliverable (``docs/architecture/studio-3d-stages.md``): the design
document's performance row asks for p50/p95 of the *compose*, the *upload*, the
*draw* and the total response latency, reported together with the resolution, the
mesh and the GL context they were measured on. A single average would hide exactly
the thing that matters here — the tail is where a display pipeline either keeps its
frame rate or visibly stutters.
What is measured, and why each one is separate:
``submit_ms`` what the **acquisition thread** pays per frame. This is the only
number that can damage the measurement, so it is measured with the
composer deliberately slowed down: the display slowing must not
move this number at all.
``compose_ms`` the CPU worker: sample the UV field onto the vertices, deform,
recompute normals.
``upload_ms`` the GPU buffer update for the sensing surface.
``paint_ms`` Qt's paint, including the widget's own bookkeeping.
``latency_ms`` from ``submit`` to the frame being on screen — the number an
operator actually perceives, and the one that includes every
queue in between.
Run it with a real platform (offscreen cannot create a GL context)::
QT_QPA_PLATFORM=windows python tools/scene_assets/bench_scene.py \\
--frames 600 --output docs/architecture/perf-report-windows-dev.json
Exit codes: 0 = measured on a GPU, 3 = software renderer, 4 = no usable context.
"""
from __future__ import annotations
import argparse
import json
import sys
import time
from pathlib import Path
def _bootstrap_paths() -> None:
here = Path(__file__).resolve()
repo = here.parents[2]
for path in (repo / "apps" / "studio" / "src", repo / "shared" / "src"):
if path.is_dir() and str(path) not in sys.path:
sys.path.insert(0, str(path))
def _percentiles(values: list[float]) -> dict[str, float]:
if not values:
return {"p50": 0.0, "p95": 0.0, "max": 0.0, "mean": 0.0, "n": 0}
ordered = sorted(values)
def pick(fraction: float) -> float:
index = max(0, min(len(ordered) - 1, int(round(fraction * len(ordered))) - 1))
return float(ordered[index])
return {
"p50": round(pick(0.50), 4),
"p95": round(pick(0.95), 4),
"max": round(float(ordered[-1]), 4),
"mean": round(sum(ordered) / len(ordered), 4),
"n": len(ordered),
}
def _grid_scene(target_vertices: int):
"""A plane grid with a bilinear binding, for the design document's budget.
The design's performance row is written against "about 20k vertices and 40k
triangles" for the sensing surface, and the shipped finger export is 1032 — so a
report that only measured the PoC scene would answer a question nobody asked. This
builds a grid of roughly ``target_vertices`` vertices whose UV domain *is* the
grid, with the same measured pixel convention the real binding uses
(``x = u(W-1)``, ``y = (1-v)(H-1)``) and a genuine four-tap bilinear table, so the
sampling cost in the report is the real one rather than a nearest-neighbour
stand-in.
"""
import numpy as np
from orisys_app_shared.scene3d.contracts import (
CameraPreset,
Material,
MeshPart,
SceneAssets,
SurfaceBinding,
freeze,
)
from orisys_app_shared.scene3d.digest import (
field_domain_id as compute_domain_id,
geometry_id as compute_geometry_id,
)
from orisys_app_shared.scene3d.legacy import vertex_normals
columns = max(2, int(round(target_vertices ** 0.5)))
rows = max(2, int(round(target_vertices / columns)))
count = rows * columns
xs = np.linspace(-0.008, 0.008, columns, dtype=np.float64)
ys = np.linspace(-0.008, 0.008, rows, dtype=np.float64)
grid_x, grid_y = np.meshgrid(xs, ys)
# A gentle dome, so the normals are not all identical and the shading path is used.
radius = np.sqrt(grid_x**2 + grid_y**2)
z = 0.001 * np.cos(np.clip(radius / 0.008, 0.0, 1.0) * np.pi / 2.0)
positions = np.stack([grid_x, grid_y, z], axis=-1).reshape(-1, 3).astype(np.float32)
faces: list[tuple[int, int, int]] = []
for i in range(rows - 1):
for j in range(columns - 1):
a = i * columns + j
b = a + 1
c = a + columns
d = c + 1
faces.append((a, c, b))
faces.append((b, c, d))
triangles = np.asarray(faces, dtype=np.uint32)
normals = vertex_normals(positions.astype(np.float64), triangles.astype(np.int64))
height, width = rows, columns
index = np.arange(count, dtype=np.int64)
row_of = index // columns
column_of = index % columns
# Vertex (row, column) sits *between* pixels, so every vertex needs four taps.
u = (column_of + 0.5) / columns
v = 1.0 - (row_of + 0.5) / rows
x = u * (width - 1)
y = v * (height - 1)
x0 = np.clip(np.floor(x).astype(np.int64), 0, width - 2)
y0 = np.clip(np.floor(y).astype(np.int64), 0, height - 2)
x1, y1 = x0 + 1, y0 + 1
fx = (x - x0).astype(np.float32)
fy = (y - y0).astype(np.float32)
indices = np.stack(
[
y0 * width + x0,
y0 * width + x1,
y1 * width + x0,
y1 * width + x1,
],
axis=-1,
).astype(np.int32)
weights = np.stack(
[
(1.0 - fx) * (1.0 - fy),
fx * (1.0 - fy),
(1.0 - fx) * fy,
fx * fy,
],
axis=-1,
).astype(np.float32)
domain_id = compute_domain_id(shape_hw=(height, width), flip_v=True, source_scale=1.0)
preset = CameraPreset(
name="bench",
eye_sensor_m=(0.02, -0.02, 0.03),
target_sensor_m=(0.0, 0.0, 0.0),
up_sensor=(0.0, 0.0, 1.0),
vertical_fov_rad=0.7,
)
part = MeshPart(
part_id="sensing_surface",
role="sensing",
positions_sensor_m=freeze(positions, dtype=np.float32),
normals_sensor=freeze(normals, dtype=np.float32),
triangles=freeze(triangles, dtype=np.uint32),
material=Material((0.85, 0.85, 0.88, 1.0), "unlit_scalar", True),
lod_id="lod0",
)
geometry = compute_geometry_id((part,))
binding = SurfaceBinding(
part_id=part.part_id,
geometry_id=geometry,
field_domain_id=domain_id,
shape_hw=(height, width),
sample_indices=freeze(indices, dtype=np.int32),
sample_weights=freeze(weights, dtype=np.float32),
)
bounds = np.stack([positions.min(axis=0), positions.max(axis=0)]).astype(np.float32)
return SceneAssets(
asset_id="bench-grid",
asset_version="0",
asset_hash=domain_id,
geometry_id=geometry,
parts=(part,),
binding=binding,
camera_presets=(preset,),
bounds_sensor_m=freeze(bounds, dtype=np.float32),
field_domain_id=domain_id,
shape_hw=(height, width),
)
def measure_cpu(
*,
frames: int,
vertices: int = 0,
package: Path | None = None,
key_file: Path | None = None,
key_id: str = "",
verify_key: Path | None = None,
) -> tuple[dict, int]:
"""The composer's own cost, with no window in the process.
This is the mode to quote for "how long does one frame take": in the GUI run the
worker thread and the GUI thread compete for the GIL and for a vsync-throttled
swap, so the composer's *wall* timers pick up 15-17 ms of waiting that has nothing
to do with the arithmetic. Removing Qt from the process removes the contention, and
what is left is the number an LOD decision should be made on.
"""
import numpy as np
from orisys_app_shared.scene3d import synthetic
from orisys_app_shared.scene3d.composer import SceneComposer
from orisys_app_shared.scene3d.contracts import (
DisplaySettings,
FieldDescriptor,
FrameIdentity,
SurfaceFieldFrame,
freeze,
)
report: dict = {
"mode": "cpu",
"frames_requested": int(frames),
"scene": (
f"package:{package}"
if package is not None
else (f"grid:{vertices}" if vertices else "synthetic-poc")
),
}
if package is not None:
from orisys_app_shared.scene3d import protect
from orisys_app_shared.scene3d.package import PackagedAssetLoader
provider = (
protect.load_key_file(Path(key_file), key_id=key_id)
if key_file is not None
else None
)
public = Path(verify_key).read_bytes() if verify_key is not None else None
try:
assets = PackagedAssetLoader().load(
Path(package), None, provider, verify_key_pem=public
)
except Exception as exc: # noqa: BLE001
report["verdict"] = "asset_rejected"
report["reason"] = str(exc)
return report, 4
elif vertices:
assets = _grid_scene(int(vertices))
else:
assets = synthetic.poc_scene()
report["mesh"] = {
"sensing_vertices": int(assets.sensing_part().positions_sensor_m.shape[0]),
"sensing_triangles": int(assets.sensing_part().triangles.shape[0]),
"field_domain_hw": [int(assets.shape_hw[0]), int(assets.shape_hw[1])],
}
shape = tuple(assets.shape_hw)
rng = np.random.default_rng(12345)
scalar = freeze(
(rng.random(shape) * 4.0).astype(np.float32), dtype=np.float32
)
valid = freeze(np.ones(shape, dtype=bool), dtype=np.bool_)
frame = SurfaceFieldFrame(
identity=FrameIdentity("bench", 1, 1, 0.0, generation=1),
geometry_id=assets.geometry_id,
field_domain_id=assets.field_domain_id,
scalar=scalar,
scalar_descriptor=FieldDescriptor("flow_magnitude", "px", "approximate", False),
valid=valid,
)
composer = SceneComposer()
settings = DisplaySettings()
for _ in range(5):
composer.compose(assets, frame, settings)
totals: list[float] = []
samples: list[float] = []
normals: list[float] = []
walls: list[float] = []
for _ in range(int(frames)):
started = time.perf_counter()
composer.compose(assets, frame, settings)
walls.append((time.perf_counter() - started) * 1000.0)
stats = composer.last_stats
totals.append(float(stats.total_ms))
samples.append(float(stats.sample_ms))
normals.append(float(stats.normals_ms))
report["timings_ms"] = {
"compose": _percentiles(totals),
"compose_sample": _percentiles(samples),
"compose_normals": _percentiles(normals),
"compose_wall": _percentiles(walls),
}
report["verdict"] = "ok"
return report, 0
def measure(
*,
frames: int,
profile: str,
width: int,
height: int,
slow_compose_ms: float,
max_fps: int,
vertices: int = 0,
package: Path | None = None,
key_file: Path | None = None,
key_id: str = "",
verify_key: Path | None = None,
) -> tuple[dict, int]:
"""Run the assembled view and report what each stage cost."""
import numpy as np
from PyQt5.QtWidgets import QApplication
from orisys_app_shared.scene3d import synthetic
from orisys_app_shared.scene3d.contracts import DisplaySettings
from orisys_studio.scene3d.gl_backend import configure_surface_format
from orisys_studio.scene3d.view import Scene3DView
configure_surface_format(profile) # type: ignore[arg-type]
app = QApplication.instance() or QApplication(sys.argv)
report: dict = {
"profile_requested": profile,
"frames_requested": int(frames),
"viewport_px": [int(width), int(height)],
"slow_compose_ms": float(slow_compose_ms),
"max_fps_requested": int(max_fps),
"scene": (
f"package:{package}"
if package is not None
else (f"grid:{vertices}" if vertices else "synthetic-poc")
),
}
view = Scene3DView()
if not view.available:
report["verdict"] = "no_view"
report["reason"] = view.reason
return report, 4
if package is not None:
from orisys_app_shared.scene3d.package import PackagedAssetLoader
from orisys_app_shared.scene3d import protect
provider = (
protect.load_key_file(Path(key_file), key_id=key_id)
if key_file is not None
else None
)
public = Path(verify_key).read_bytes() if verify_key is not None else None
try:
assets = PackagedAssetLoader().load(
Path(package), None, provider, verify_key_pem=public
)
except Exception as exc: # noqa: BLE001
report["verdict"] = "asset_rejected"
report["reason"] = str(exc)
view.close()
return report, 4
elif vertices:
assets = _grid_scene(int(vertices))
else:
assets = synthetic.poc_scene()
if not view.use_scene(assets, source_id="bench"):
report["verdict"] = "asset_rejected"
report["reason"] = str(view.last_diagnostic)
return report, 4
view.resize(width, height)
view.show()
view.set_enabled(True)
widget = view.gl_widget
renderer = widget._renderer
deadline = time.perf_counter() + 10.0
while time.perf_counter() < deadline and not renderer.part_ids:
widget.repaint()
app.processEvents()
time.sleep(0.005)
capabilities = widget.capabilities()
if not renderer.part_ids or capabilities is None:
report["verdict"] = "no_context"
report["reason"] = str(widget.diagnostic())
view.close()
return report, 4
report["context"] = {
"profile_used": capabilities.profile,
"vendor": capabilities.vendor,
"renderer": capabilities.renderer,
"gl_version": capabilities.gl_version,
"glsl_version": capabilities.glsl_version,
"software_renderer": bool(capabilities.software_renderer),
"max_texture_size": int(capabilities.max_texture_size),
"device_pixel_ratio": float(widget.devicePixelRatioF()),
"qt_platform": capabilities.qt_platform,
}
report["mesh"] = {
"sensing_vertices": int(assets.sensing_part().positions_sensor_m.shape[0]),
"sensing_triangles": int(assets.sensing_part().triangles.shape[0]),
"housing_triangles": int(
assets.part("housing").triangles.shape[0] if assets.part("housing") else 0
),
"field_domain_hw": [int(assets.shape_hw[0]), int(assets.shape_hw[1])],
"estimated_gpu_bytes": int(getattr(renderer, "_estimated_gpu_bytes", 0) or 0),
}
coordinator = view.coordinator
real_composer = coordinator._composer
compose_records: list[tuple[float, float, float, float]] = []
compose_lock = __import__("threading").Lock()
class Slow:
"""The composer, delayed: the point is that ``submit`` does not notice.
The statistics are collected *here*, on the worker thread that produces them,
immediately after ``compose`` returns. Reading ``last_stats`` from the GUI loop
instead produces torn samples — the worker writes ``sample_ms``, then
``normals_ms``, then ``total_ms``, so a reader can see one frame's sampling next
to the previous frame's normals, which reads as a plausible and completely wrong
breakdown.
"""
def __init__(self, delay_s: float, inner) -> None:
self._delay = delay_s
self._inner = inner
self.last_stats = getattr(inner, "last_stats", None)
def compose(self, *args, **kwargs):
started = time.perf_counter()
result = self._inner.compose(*args, **kwargs)
stats = getattr(self._inner, "last_stats", None)
record = (
float(getattr(stats, "total_ms", 0.0)),
float(getattr(stats, "sample_ms", 0.0)),
float(getattr(stats, "normals_ms", 0.0)),
(time.perf_counter() - started) * 1000.0,
)
with compose_lock:
compose_records.append(record)
if self._delay:
time.sleep(self._delay)
return result
def rest_frame(self, *args, **kwargs):
return self._inner.rest_frame(*args, **kwargs)
coordinator._composer = Slow(slow_compose_ms / 1000.0, real_composer)
view.restore_settings(DisplaySettings(max_fps=max_fps))
shape = tuple(assets.shape_hw)
ramp = np.linspace(0.0, 1.0, int(shape[0]) * int(shape[1]), dtype=np.float32)
flow = np.stack([ramp, ramp], axis=-1).reshape(int(shape[0]), int(shape[1]), 2)
class Packet:
"""A packet shaped like the finger adapter expects."""
def __init__(self, index: int) -> None:
self.session_id = 1
self.frame_id = int(index)
self.mono_t = float(index) / 30.0
self.flow = flow + (index % 7) * 0.01
self.raw_frame = np.zeros((4, 4, 3), dtype=np.uint8)
# Measured by wrapping the two places that define the timeline: `present` is the
# moment a composed frame becomes the one that should be on screen, and `paintGL`
# is the moment it actually is. Anything measured by polling the widget's own
# statistics would report the *last published* sample (they are rate-limited to one
# per 500 ms), which is how a benchmark ends up reporting a constant p50 for a
# stage it never sampled.
submits: list[float] = []
latencies: list[float] = []
composes: list[float] = []
samples: list[float] = []
normals: list[float] = []
uploads: list[float] = []
paints: list[float] = []
submitted_at: dict[int, float] = {}
presented_at: dict[int, float] = {}
original_present = widget.present
original_paint = widget.paintGL
def present(frame):
presented_at[int(frame.identity.frame_id)] = time.perf_counter()
original_present(frame)
def paint():
frame = widget._frame # the frame this paint is about to consume
started = time.perf_counter()
original_paint()
paints.append((time.perf_counter() - started) * 1000.0)
if frame is not None:
key = int(frame.identity.frame_id)
if key in submitted_at:
latencies.append((time.perf_counter() - submitted_at[key]) * 1000.0)
widget.present = present
widget.paintGL = paint
try:
for index in range(int(frames)):
started = time.perf_counter()
view.submit_packet(Packet(index))
submits.append((time.perf_counter() - started) * 1000.0)
submitted_at[index] = started
view.tick()
app.processEvents()
# Synchronous paint, the way the GL tests drive the widget: a queued
# `update()` may not be delivered by `processEvents()` for a GL widget,
# which would leave every paint-stage number at zero.
widget.repaint()
uploads.append(float(getattr(renderer, "_last_upload_ms", 0.0) or 0.0))
finally:
widget.present = original_present
widget.paintGL = original_paint
coordinator._composer = real_composer
view.close()
app.processEvents()
from orisys_studio.scene3d.coordinator import MAX_INFLIGHT_COMPOSES
with compose_lock:
records = list(compose_records)
composes = [item[0] for item in records]
samples = [item[1] for item in records]
normals = [item[2] for item in records]
compose_wall = [item[3] for item in records]
coordinator_stats = {
"superseded_display_frames": int(coordinator.superseded_frames),
"stale_frames": int(coordinator.stale_frames),
"invalid_frames": int(coordinator.invalid_frames),
"max_inflight_composes": int(MAX_INFLIGHT_COMPOSES),
"mailbox_capacity": 1,
"frames_presented": len(presented_at),
}
report["timings_ms"] = {
"submit": _percentiles(submits),
"compose": _percentiles(composes),
# Which half of the compose: on a large sensing surface one of the two
# dominates, and "compose" alone cannot say which.
"compose_sample": _percentiles(samples),
"compose_normals": _percentiles(normals),
# Wall clock inside the composer, which includes the artificial delay: the
# difference between this and ``compose`` is the delay the caller asked for.
"compose_wall": _percentiles(compose_wall),
"upload": _percentiles(uploads),
"paint": _percentiles(paints),
"latency": _percentiles(latencies),
}
report["compose_count"] = len(records)
report["compose_note"] = (
"compose/compose_sample/compose_normals are the composer's own timers, "
"collected on the worker thread; compose_wall adds the requested delay. "
"latency is submit-to-paint and therefore includes the frame-rate limiter."
)
report["display"] = coordinator_stats
report["crypto_backend"] = _crypto_version()
if capabilities.software_renderer:
report["verdict"] = "software_renderer"
return report, 3
report["verdict"] = "ok"
return report, 0
def _crypto_version() -> str:
try:
from orisys_app_shared.scene3d.package import backend_version
return str(backend_version())
except Exception: # noqa: BLE001
return "unavailable"
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
parser.add_argument("--frames", type=int, default=600, help="frames to submit")
parser.add_argument("--profile", default="desktop33", choices=("desktop33", "gles30"))
parser.add_argument("--size", default="900x700", help="WxH of the 3D widget")
parser.add_argument(
"--slow-compose-ms",
type=float,
default=50.0,
help="artificial composer delay: the submit budget must not move with it",
)
parser.add_argument("--max-fps", type=int, default=30)
parser.add_argument(
"--vertices",
type=int,
default=0,
help="benchmark a generated grid of about this many sensing vertices "
"(the design document's budget is 20k); 0 = the synthetic PoC scene",
)
parser.add_argument(
"--package",
type=Path,
default=None,
help="benchmark a released .oryscene package instead of a synthetic scene",
)
parser.add_argument("--key-file", type=Path, default=None, help="key for a sealed package")
parser.add_argument("--key-id", default="", help="key id that --key-file holds")
parser.add_argument("--verify-key", type=Path, default=None, help="public key for a signed package")
parser.add_argument("--output", type=Path, default=None, help="write the report here")
parser.add_argument(
"--cpu-only",
action="store_true",
help="measure the composer with no window in the process (no vsync, no GIL "
"contention): the number to quote for the per-frame CPU cost",
)
parser.add_argument("--json", action="store_true")
args = parser.parse_args(argv)
_bootstrap_paths()
width, _, height = args.size.partition("x")
if args.cpu_only:
report, code = measure_cpu(
frames=args.frames,
vertices=args.vertices,
package=args.package,
key_file=args.key_file,
key_id=args.key_id,
verify_key=args.verify_key,
)
else:
report, code = measure(
frames=args.frames,
profile=args.profile,
width=int(width),
height=int(height or 700),
slow_compose_ms=args.slow_compose_ms,
max_fps=args.max_fps,
vertices=args.vertices,
package=args.package,
key_file=args.key_file,
key_id=args.key_id,
verify_key=args.verify_key,
)
report["exit_code"] = int(code)
text = json.dumps(report, indent=2, ensure_ascii=False, default=str)
if args.output is not None:
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text(text + "\n", encoding="utf-8")
if args.json or args.output is None:
print(text)
else:
timings = report.get("timings_ms", {})
print(
f"verdict={report['verdict']} frames={report['frames_requested']} "
f"renderer={(report.get('context') or {}).get('renderer', '')}"
)
for stage in (
"submit",
"compose",
"compose_sample",
"compose_normals",
"compose_wall",
"upload",
"paint",
"latency",
):
if stage in timings:
item = timings[stage]
print(
f" {stage:8s} p50={item['p50']:8.3f} p95={item['p95']:8.3f} "
f"max={item['max']:8.3f} n={item['n']}"
)
print(f" wrote {args.output}")
return code
if __name__ == "__main__":
sys.exit(main())