Files
2026-07-13 13:05:14 +08:00

301 lines
9.1 KiB
Python
Executable File

#!/usr/bin/env python3
"""
Generate comparison between examples and their related screenshots.
This script builds/gather RRDs and corresponding screenshots and displays
them side-by-side. It pulls from the following sources:
- The screenshots listed in .fbs files (crates/store/re_sdk_types/definitions/rerun/**/*.fbs),
and the corresponding snippets in the docs (docs/snippets/*.rs)
- The `rerun.io/viewer` examples, as built by the `re_dev_tools`/`build_examples` script.
The comparisons are generated in the `compare_screenshot` directory. Use the `--serve`
option to show them in a browser.
"""
from __future__ import annotations
import argparse
import http.server
import json
import multiprocessing
import os
import shutil
import subprocess
import threading
from dataclasses import dataclass
from functools import partial
from io import BytesIO
from pathlib import Path
from typing import TYPE_CHECKING, Any
import requests
from jinja2 import Template
from PIL import Image
if TYPE_CHECKING:
from collections.abc import Iterable
BASE_PATH = Path("compare_screenshot")
SCRIPT_DIR_PATH = Path(__file__).parent
STATIC_ASSETS = SCRIPT_DIR_PATH / "assets" / "static"
TEMPLATE_DIR = SCRIPT_DIR_PATH / "assets" / "templates"
INDEX_TEMPLATE = Template((TEMPLATE_DIR / "index.html").read_text())
EXAMPLE_TEMPLATE = Template((TEMPLATE_DIR / "example.html").read_text())
RERUN_DIR = SCRIPT_DIR_PATH.parent.parent
SNIPPETS_DIR = RERUN_DIR / "docs" / "snippets"
def measure_thumbnail(url: str) -> Any:
"""Downloads `url` and returns its width and height."""
response = requests.get(url)
response.raise_for_status()
image = Image.open(BytesIO(response.content))
return image.size
def run(
args: list[str],
*,
env: dict[str, str] | None = None,
timeout: int | None = None,
cwd: str | Path | None = None,
) -> None:
print(f"> {subprocess.list2cmdline(args)}")
result = subprocess.run(args, env=env, cwd=cwd, timeout=timeout, check=False, capture_output=True, text=True)
assert result.returncode == 0, (
f"{subprocess.list2cmdline(args)} failed with exit-code {result.returncode}. Output:\n{result.stdout}\n{result.stderr}"
)
@dataclass
class Example:
name: str
title: str
rrd: Path
screenshot_url: str
def copy_static_assets(examples: list[Example]) -> None:
# copy root
dst = BASE_PATH
print(f"\nCopying static assets from {STATIC_ASSETS} to {dst}")
shutil.copytree(STATIC_ASSETS, dst, dirs_exist_ok=True)
# copy examples
for example in examples:
dst = BASE_PATH / "examples" / example.name
shutil.copytree(
STATIC_ASSETS,
dst,
dirs_exist_ok=True,
ignore=shutil.ignore_patterns("index.html"),
)
def build_python_sdk() -> None:
print("Building Python SDK…")
run(["pixi", "run", "py-build", "--features", "web_viewer"])
# ====================================================================================================
# SNIPPETS
#
# We scrape FBS for screenshot URL and generate the corresponding snippets RRD with compare_snippet_output.py
# ====================================================================================================
def extract_snippet_urls_from_fbs() -> dict[str, str]:
fbs_path = SCRIPT_DIR_PATH.parent.parent / "crates" / "store" / "re_sdk_types" / "definitions" / "rerun"
urls = {}
for fbs in fbs_path.glob("**/*.fbs"):
for line in fbs.read_text().splitlines():
if line.startswith(r"/// \example"):
name = line.split()[2]
idx = line.find('image="')
if idx != -1:
end_idx = line.find('"', idx + 8)
if end_idx == -1:
end_idx = len(line)
urls[name] = line[idx + 7 : end_idx]
return urls
SNIPPET_URLS = extract_snippet_urls_from_fbs()
def build_snippets() -> None:
cmd = [
str(SNIPPETS_DIR / "compare_snippet_output.py"),
"--no-py",
"--no-cpp",
"--no-py-build",
"--no-cpp-build",
]
for name in SNIPPET_URLS:
run([*cmd, name], cwd=RERUN_DIR)
def collect_snippets() -> Iterable[Example]:
for name in sorted(SNIPPET_URLS):
rrd = SNIPPETS_DIR / "all" / f"{name}_rust.rrd"
if rrd.exists():
yield Example(name=name, title=name, rrd=rrd, screenshot_url=SNIPPET_URLS[name])
else:
print(f"WARNING: Missing {rrd} for {name}")
# ====================================================================================================
# DEMO EXAMPLES
#
# We run the `re_dev_tools`/`build_examples` script and scrap the output "example_data" directory.
# ====================================================================================================
def build_examples() -> None:
# fmt: off
cmd = [
"pixi", "run", "-e", "examples",
"cargo", "run", "--locked",
"-p", "re_dev_tools", "--",
"build-examples", "rrd", "example_data",
# TODO(andreas): nightly channel would be better, but the dependencies that requires make things hard to get to run.
"--channel", "main",
]
run(cmd, cwd=RERUN_DIR)
cmd = [
"pixi", "run", "-e", "examples",
"cargo", "run", "--locked",
"-p", "re_dev_tools", "--",
"build-examples", "manifest", "example_data/examples_manifest.json",
# TODO(andreas): nightly channel would be better, but the dependencies that requires make things hard to get to run.
"--channel", "main",
]
run(cmd, cwd=RERUN_DIR)
# fmt: on
def collect_examples() -> Iterable[Example]:
example_dir = RERUN_DIR / "example_data"
assert example_dir.exists(), "Examples have not been built yet."
manifest = json.loads((example_dir / "examples_manifest.json").read_text())
for example in manifest:
name = example["name"]
rrd = example_dir / f"{name}.rrd"
assert rrd.exists(), f"Missing {rrd} for {name}"
yield Example(
name=name,
title=example["title"],
rrd=rrd,
screenshot_url=example["thumbnail"]["url"],
)
def render_index(examples: list[Example]) -> None:
BASE_PATH.mkdir(exist_ok=True)
index_path = BASE_PATH / "index.html"
print(f"Rendering index.html -> {index_path}")
index_path.write_text(INDEX_TEMPLATE.render(examples=examples))
def render_examples(examples: list[Example]) -> None:
print("Rendering examples")
for example in examples:
target_path = BASE_PATH / "examples" / example.name
target_path.mkdir(parents=True, exist_ok=True)
index_path = target_path / "index.html"
print(f"{example.name} -> {index_path}")
index_path.write_text(EXAMPLE_TEMPLATE.render(example=example, examples=examples))
shutil.copy(example.rrd, target_path / "data.rrd")
class CORSRequestHandler(http.server.SimpleHTTPRequestHandler):
def end_headers(self) -> None:
self.send_header("Access-Control-Allow-Origin", "*")
super().end_headers()
def serve_files() -> None:
def serve() -> None:
print("\nServing examples at http://127.0.0.1:8080/\n")
server = http.server.HTTPServer(
server_address=("127.0.0.1", 8080),
RequestHandlerClass=partial(
CORSRequestHandler,
directory=str(BASE_PATH),
),
)
server.serve_forever()
def serve_rerun() -> None:
import rerun as rr
os.environ["RUST_LOG"] = "rerun=warn"
rr.init("rerun_example_screenshot_compare")
connect_to = rr.serve_grpc()
rr.serve_web_viewer(open_browser=False, connect_to=connect_to)
threading.Thread(target=serve, daemon=True).start()
# use a sub-process so the target can change env variables without affecting the parent
process = multiprocessing.Process(target=serve_rerun, daemon=True)
process.run()
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument(
"--serve",
action="store_true",
help="Serve the app on this port after building [default: 8080]",
)
parser.add_argument("--skip-build", action="store_true", help="Skip building the Python SDK.")
parser.add_argument("--skip-example-build", action="store_true", help="Skip building the RRDs.")
args = parser.parse_args()
if not args.skip_build:
build_python_sdk()
if not args.skip_example_build:
build_snippets()
build_examples()
examples = list(collect_snippets()) + list(collect_examples())
assert len(examples) > 0, "No examples found"
render_index(examples)
render_examples(examples)
copy_static_assets(examples)
if args.serve:
serve_files()
while True:
try:
print("Press enter to reload static files")
input()
render_examples(examples)
copy_static_assets(examples)
except KeyboardInterrupt:
break
if __name__ == "__main__":
main()