301 lines
9.1 KiB
Python
Executable File
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()
|