Files
orisys-app/tools/scene_assets/compile_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

258 lines
11 KiB
Python

"""Compile a legacy export into a runtime package (stages 2 and 5 of the 3D plan).
Offline only: this is the tool that bakes the coordinate transform, the unit
scale, the UV sampling table and the identity hashes into one file, so the runtime
never repeats that work. Stage 5 adds the three release protections the design
document asks for, and each is optional:
* ``--sign-key``: sign the package with an Ed25519 private key that stays on the
build machine. The client carries only the matching public key.
* ``--encrypt-key-id`` + ``--key-file``: additionally seal the content with
AES-256-GCM. Encryption never replaces signing; it is added to it.
* ``--lock-*`` (or ``--lock-from-probe``): record where the package may be
displayed. A release compiled for a GPU must not silently render on llvmpipe.
Units are an argument, never a default. The legacy mesh is 15.8 x 15.0 x 7.5 source
units; whether one of those is a millimetre is a physical claim about the sensor,
and ``validate_scene.py`` prints the measured candidates so a person can decide.
Examples::
# an engineering package: plain, unsigned, unlocked
python tools/scene_assets/compile_scene.py \\
--legacy-dir sensors/finger/assets/30fps-0703 \\
--map sensors/finger/assets/30fps-0703/unroll_map_uv_s0.4.npz \\
--meters-per-source-unit 0.001 \\
--output build/finger-display-1.0.0.oryscene
# a release package: signed, sealed, locked to the machine we probed
python tools/scene_assets/compile_scene.py \\
--legacy-dir sensors/finger/assets/30fps-0703 \\
--map sensors/finger/assets/30fps-0703/unroll_map_uv_s0.4.npz \\
--meters-per-source-unit 0.001 \\
--sign-key keys/build-ed25519.pem --sign-key-id build-2024 \\
--encrypt-key-id finger-display --key-file keys/finger-display.key \\
--lock-from-probe docs/architecture/gl-report-windows-dev.json \\
--output build/finger-display-1.0.0.oryscene
"""
from __future__ import annotations
import argparse
import json
import sys
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 _platform_lock_from_args(args):
"""The lock the command line describes, from a probe report or from flags."""
from orisys_app_shared.scene3d.contracts import PlatformLock
if args.lock_from_probe is not None:
report = json.loads(Path(args.lock_from_probe).read_text(encoding="utf-8"))
context = report.get("context") or {}
host = report.get("host") or {}
os_family = host.get("os_family") or host.get("system") or ""
machine = host.get("machine") or ""
return PlatformLock(
os_family=(str(os_family).lower(),) if os_family else (),
machine=(str(machine).lower(),) if machine else (),
gl_profile=(
str(context.get("profile_used")) if context.get("profile_used") else None
),
allowed_vendors=(str(context["vendor"]),) if context.get("vendor") else (),
allowed_renderers=(str(context["renderer"]),) if context.get("renderer") else (),
forbid_software_renderer=not args.allow_software_renderer,
min_texture_size=int(context.get("max_texture_size") or 0),
note=str(args.lock_note or f"qualified from {Path(args.lock_from_probe).name}"),
)
lock = PlatformLock(
os_family=tuple(v.lower() for v in (args.lock_os or ())),
machine=tuple(v.lower() for v in (args.lock_machine or ())),
gl_profile=args.lock_gl_profile,
allowed_vendors=tuple(args.lock_vendor or ()),
allowed_renderers=tuple(args.lock_renderer or ()),
forbid_software_renderer=not args.allow_software_renderer,
min_texture_size=int(args.lock_min_texture or 0),
note=str(args.lock_note or ""),
)
return None if lock.is_unrestricted() and not args.lock_note else lock
def _sealing_from_args(args):
"""The key material the command line points at, or ``None`` for a plain package."""
from orisys_app_shared.scene3d.contracts import Sealing
from orisys_app_shared.scene3d import protect
signing_pem = None
if args.sign_key is not None:
path = Path(args.sign_key)
if not path.is_file():
raise SystemExit(f"the signing key is missing: {path}")
signing_pem = path.read_bytes()
# Prove the key is usable before spending a compile on it.
protect.public_key_pem(signing_pem)
provider = None
if args.encrypt_key_id:
if args.key_file is None:
raise SystemExit("--encrypt-key-id also needs --key-file")
provider = protect.load_key_file(Path(args.key_file), key_id=args.encrypt_key_id)
if signing_pem is None and provider is None:
return None
verify = protect.public_key_pem(signing_pem) if signing_pem is not None else None
if verify is not None and args.write_verify_key is not None:
# The client's half of the pair. Publishing it is part of the release, and
# writing it here keeps it from being re-derived differently somewhere else.
target = Path(args.write_verify_key)
target.parent.mkdir(parents=True, exist_ok=True)
target.write_bytes(verify)
return Sealing(
key_id=str(args.encrypt_key_id or ""),
provider=provider,
signing_key_pem=signing_pem,
signing_key_reference=str(args.sign_key_id or "build-key"),
verify_key_pem=verify,
)
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
parser.add_argument("--legacy-dir", type=Path, required=True, help="legacy NPZ export")
parser.add_argument("--map", type=Path, required=True, help="field map (npz)")
parser.add_argument(
"--meters-per-source-unit",
type=float,
required=True,
help="stated unit scale (the importer never guesses it)",
)
parser.add_argument("--output", type=Path, required=True, help="package to write")
parser.add_argument("--asset-id", default="finger-display")
parser.add_argument("--version", default="1.0.0")
parser.add_argument("--lod-id", default="lod0")
parser.add_argument("--sensor-model", default="finger")
parser.add_argument(
"--transform",
type=Path,
default=None,
help="optional 4x4 rigid transform (npy) from source to sensor coordinates",
)
protection = parser.add_argument_group(
"release protection (stage 5)", "all optional; a plain package is the default"
)
protection.add_argument("--sign-key", type=Path, default=None, help="Ed25519 private key (PEM)")
protection.add_argument("--sign-key-id", default="", help="name recorded in the envelope")
protection.add_argument(
"--write-verify-key",
type=Path,
default=None,
help="write the public half of --sign-key here (this is what the client ships)",
)
protection.add_argument("--encrypt-key-id", default="", help="content key id to seal with")
protection.add_argument("--key-file", type=Path, default=None, help="key for --encrypt-key-id")
protection.add_argument(
"--lock-from-probe",
type=Path,
default=None,
help="take the platform lock from a probe_gl.py report",
)
protection.add_argument("--lock-os", action="append", default=None, help="repeatable")
protection.add_argument("--lock-machine", action="append", default=None, help="repeatable")
protection.add_argument("--lock-gl-profile", default=None, choices=(None, "desktop33", "gles30"))
protection.add_argument("--lock-vendor", action="append", default=None, help="repeatable")
protection.add_argument("--lock-renderer", action="append", default=None, help="repeatable")
protection.add_argument("--lock-min-texture", type=int, default=0)
protection.add_argument("--lock-note", default="")
protection.add_argument(
"--allow-software-renderer",
action="store_true",
help="do not forbid a software rasteriser (the design document says do)",
)
parser.add_argument("--json", action="store_true")
args = parser.parse_args(argv)
_bootstrap_paths()
import numpy as np
from orisys_app_shared.scene3d.contracts import ThreeDError
from orisys_app_shared.scene3d.legacy import LegacyFingerImporter
from orisys_app_shared.scene3d.package import describe_envelope, write_package
transform = (
np.load(args.transform, allow_pickle=False)
if args.transform is not None
else np.eye(4)
)
try:
sealing = _sealing_from_args(args)
lock = _platform_lock_from_args(args)
except ThreeDError as exc:
print(f"rejected: {exc.diagnostic.code}: {exc.diagnostic.message}", file=sys.stderr)
return 2
try:
assets = LegacyFingerImporter().convert(
args.legacy_dir,
args.map,
transform_sensor_from_source=transform,
meters_per_source_unit=args.meters_per_source_unit,
asset_id=args.asset_id,
asset_version=args.version,
lod_id=args.lod_id,
)
if lock is not None:
from dataclasses import replace
# Part of the asset, so it travels into the manifest and the signature.
assets = replace(assets, platform_lock=lock)
report = write_package(
assets, args.output,
sensor_model=args.sensor_model,
hardware_revisions=("*",),
sealing=sealing,
)
except ThreeDError as exc:
print(f"rejected: {exc.diagnostic.code}: {exc.diagnostic.message}", file=sys.stderr)
return 2
envelope = describe_envelope(report.output)
summary = {
"status": "ok",
"output": str(report.output),
"asset_hash": report.asset_hash,
"vertices": report.part_vertex_counts,
"triangles": report.part_triangle_counts,
"geometry_id": assets.geometry_id,
"field_domain_id": assets.field_domain_id,
"envelope": envelope,
"platform_lock": (
assets.platform_lock.to_dict() if assets.platform_lock is not None else None
),
}
if args.json:
print(json.dumps(summary, indent=2, ensure_ascii=False))
else:
print(
f"wrote {report.output} vertices={report.part_vertex_counts} "
f"triangles={report.part_triangle_counts}"
)
print(f" geometry_id={assets.geometry_id}")
print(f" asset_hash={report.asset_hash}")
print(
f" envelope=v{envelope['envelope_version']} {envelope['payload']}"
f"{' signed(' + str(envelope['signing_key']) + ')' if envelope['signed'] else ' unsigned'}"
+ (f" sealed for key {envelope['key_id']}" if envelope["sealed"] else "")
)
if summary["platform_lock"]:
print(f" platform_lock={json.dumps(summary['platform_lock'], ensure_ascii=False)}")
return 0
if __name__ == "__main__":
sys.exit(main())