CLI Package

The CLI package provides command-line tools for serving and inspecting Luxar datasets.

See also

This page documents the Python CLI modules (autodoc). For the authoritative catalog of user-facing luxar ... commands and the workflow guides that cover them, see the Luxar CLI Reference.

Luxar CLI package - Command-line interface for Luxar.

Main Application

The main application provides the luxar command-line interface with commands for serving data and inspecting datasets. (There is no separate viewer-build command — the bundled viewer is served automatically when you serve data with --viewer.)

luxar.cli – Command-line interface for building, serving, and inspecting Luxar Zarr scenes.

IMPORTANT: URL Construction

When constructing data URLs for the viewer’s ?src= parameter, DO NOT include trailing slashes. The viewer’s fetch logic treats them differently: - CORRECT: http://host:port (joins correctly: http://host:port/.zmetadata) - WRONG: http://host:port/ (creates double-slash: http://host:port//.zmetadata)

Key Functions

luxar.cli.main.create_server_app(path: str, *, cors_origin: str = 'local', allow_sensitive_path: bool = False, bind_host: str | None = None) → FastAPI[source]

Create a FastAPI server application for serving Zarr data.

This function is used by both the CLI and integration tests to create a configured server instance.

Parameters:
  • path – Path to directory or Zarr dataset to serve

  • cors_origin – Allowed CORS origin. "local" allows loopback origins.

  • allow_sensitive_path – If True, permit serving system directories.

  • bind_host – Bind address, so a LAN bind also allows its own origin under cors_origin="local" (see _add_cors()).

Returns:

FastAPI application instance

This function is particularly useful for:

  • Integration Testing: Create test servers that serve real Zarr data

  • Programmatic Server Creation: Embed Luxar server in larger applications

  • Custom Deployments: Configure and run servers with custom settings

Example usage in tests:

from luxar.cli.main import create_server_app
import uvicorn

# Create server app
app = create_server_app("/path/to/data.luxar.zarr")

# Run with uvicorn
uvicorn.run(app, host="127.0.0.1", port=8000)

Serving

The server application factory and CORS/serving helpers. create_server_app is re-exported from luxar.cli.main (used above) but its real home is luxar.cli.serving.

HTTP serving + path-safety helpers for the luxar CLI.

These are the Typer-free building blocks the serve / viewer / demo commands (and the gsplat view command) share: CORS configuration, the loopback / sensitive-path guards, the directory-listing static handler, and the foreground/background server runners. They live here — not in cli/main.py — so subcommand modules can reach _serve_data / _serve_viewer without importing main (which would form a cycle through the gsplat subcommand mount at the bottom of main.py).

cli/main.py re-exports the public names so existing luxar.cli.main.create_server_app / ._serve_data / ._serve_viewer imports and unittest.mock.patch targets keep resolving unchanged.

class luxar.cli.serving.DirectoryListingStaticFiles(*, directory: str | PathLike[str] | None = None, packages: list[str | tuple[str, str]] | None = None, html: bool = False, check_dir: bool = True, follow_symlink: bool = False)[source]

Mutable data files with JSON listings and forced revalidation.

async __call__(scope: MutableMapping[str, Any], receive: Callable[[], Awaitable[MutableMapping[str, Any]]], send: Callable[[MutableMapping[str, Any]], Awaitable[None]]) → None[source]

Serve this data mount through the revalidation middleware.

async get_response(path: str, scope: MutableMapping[str, Any]) → Any[source]

Override to provide directory listing.

luxar.cli.serving.create_server_app(path: str, *, cors_origin: str = 'local', allow_sensitive_path: bool = False, bind_host: str | None = None) → FastAPI[source]

Create a FastAPI server application for serving Zarr data.

This function is used by both the CLI and integration tests to create a configured server instance.

Parameters:
  • path – Path to directory or Zarr dataset to serve

  • cors_origin – Allowed CORS origin. "local" allows loopback origins.

  • allow_sensitive_path – If True, permit serving system directories.

  • bind_host – Bind address, so a LAN bind also allows its own origin under cors_origin="local" (see _add_cors()).

Returns:

FastAPI application instance

class luxar.cli.serving.ServedStore(host: str, port: int)[source]

URLs of a store + viewer served by served_store().

__init__(host: str, port: int) → None[source]
data_url

The data root — what goes into the viewer’s ?src= (no trailing slash).

viewer_url

The viewer’s index.html (trailing slash, so a relative ?src resolves).

luxar.cli.serving.served_store(path: str | Path, *, host: str = '127.0.0.1', port: int | None = None, cors_origin: str = 'local', ready_timeout: float = 30.0) → Iterator[ServedStore][source]

Serve path at / and the built viewer under /viewer — and STOP.

The serve-family commands all end in a blocking uvicorn.run on a daemon thread, which is right for a terminal and useless for a caller that needs the server for the duration of one job (luxar env bake). This is the same single-process layout luxar export ships, run through a uvicorn.Server handle so the with block’s exit shuts it down, and readiness is a polled /health rather than a sleep. Needs a built viewer (ensure_viewer_built()).

Utilities

Utility functions for port management, viewer building, and dataset inspection.

luxar.cli_utils – Helper utilities for the Luxar CLI.

luxar.cli.utils.deprecated_option(value: T, old_flag: str, new_flag: str | None, *, since: str, remove_after: str) → T[source]

Report a deprecated CLI flag on stderr and hand its value back.

The CLI half of luxar.utils.deprecation. Pair it with a hidden typer alias that defaults to None (the idiom already used for --method in luxar.cli.lod):

reveal_center: Optional[str] = typer.Option(None, "--reveal-center"),
legacy_centre: Optional[str] = typer.Option(
    None, "--reveal-centre", hidden=True
),
...
reveal_center = reveal_center or deprecated_option(
    legacy_centre, "--reveal-centre", "--reveal-center",
    since="2026.10.01", remove_after="2027.04.01",
)

Nothing is printed when value is None — the alias was not typed — so the modern path stays silent. The notice goes to stderr through typer.echo(err=True) rather than warnings.warn: Python hides DeprecationWarning by default outside __main__, and a command the user just typed must tell them about the rename now, not in a future release when the alias disappears.

Parameters:
  • value – What typer parsed for the hidden alias (None = not given).

  • old_flag – The deprecated spelling, e.g. "--reveal-centre".

  • new_flag – The replacement, or None when the flag is going away.

  • since – Release that introduced the deprecation (YYYY.MM.DD).

  • remove_after – Earliest release or date the alias may be removed after.

Returns:

value unchanged, so the call can sit inline in an assignment.

luxar.cli.utils.primary_lan_address() → str | None[source]

Return this machine’s primary outbound IPv4 address, or None.

Asks the routing table which local interface would carry traffic towards the outside world. Nothing is sent: connect() on a datagram socket only fixes the route, so this is free and works on a machine with no route at all (it simply returns None). The peer is an RFC 5737 documentation address that is guaranteed never to be routed anywhere.

This is a best guess, not the only answer: a machine can hold several addresses on the same LAN (Wi-Fi and Ethernet both up, say) and any of them may be the one a tablet dials. The default route is the most useful single choice for a URL, and it is only ever used where one address is unavoidable — printing a URL. The CORS allowance deliberately does NOT build on this, because a guess there would reject a client that arrived on a sibling address; see _SameHostCORSMiddleware in serving.py.

luxar.cli.utils.advertised_host(bind_host: str) → str[source]

Return the address to put in a URL for a server bound to bind_host.

A concrete bind address is already the answer. A wildcard bind is not: http://0.0.0.0:5173 is not reachable from another machine (and on some platforms not from this one), so a printed URL containing it is simply wrong. Resolve it to the primary LAN address, which is what a tablet on the same network needs, and fall back to loopback on a machine with no route — where loopback is the only thing that could have worked.

luxar.cli.utils.origin_hostname(origin: str) → str[source]

The hostname of an Origin header, or "" if malformed.

Port deliberately excluded. This answers “is this origin the same machine as the request’s Host?”, and the viewer and the data server are two different ports on one host by construction, so a port-sensitive comparison would reject every real pairing.

Anything that is not a clean http(s)://host[:port] returns "" and therefore matches nothing — including null (a sandboxed iframe), an origin carrying a path or fragment, and one with an embedded CRLF.

Contrast origin_authority(), which keeps the port for the control hub, where the socket is served from the very port that served the page.

luxar.cli.utils.authority_hostname(authority: str) → str[source]

The hostname of a Host header value, or "" if malformed.

Fails closed: a Host this cannot parse yields no match rather than a guess, so a malformed or smuggled header cannot widen an allowance.

luxar.cli.utils.origin_authority(origin: str) → str[source]

The lowercased host or host:port of an origin, "" if malformed.

The control hub compares this against its request’s Host — port INCLUDED, because the control WebSocket is served from the same port as the page that opens it, so a differing port there is a genuine mismatch rather than the expected two-port pairing origin_hostname() allows.

luxar.cli.utils.open_browser(url: str, suppress_errors: bool = False) → bool[source]

Open a URL in the default web browser.

Parameters:
  • url – URL to open.

  • suppress_errors – If True, don’t print error messages.

Returns:

True if successful, False otherwise.

luxar.cli.utils.check_port_available(port: int, host: str = '127.0.0.1') → bool[source]

Check if a port is available for binding.

The probe sets SO_REUSEADDR so it matches the bind it predicts: the real server is uvicorn’s loop.create_server, and asyncio passes reuse_address=True on POSIX. A plain probe is stricter than that — a lingering non-listening socket left by a previous run (TIME_WAIT / FIN_WAIT2) makes it report “busy” on a port the server would have bound fine, so a serve-family command shifts to the next port with only a warning — which a harness that discards the server’s stdout never sees, leaving it to wait out its timeout on a port nothing came up on. On Linux a LISTENING socket on the same address still conflicts regardless of SO_REUSEADDR, so a genuinely running server is still detected.

The option is applied under asyncio’s own condition rather than unconditionally: on Windows asyncio deliberately omits it, because there SO_REUSEADDR permits binding over an active listener — the probe would call an occupied port free and the real bind would then fail hard, trading a warned port shift for a crash.

The address family follows the host: an IPv6 literal (::1, ::) needs an AF_INET6 socket, and probing it on AF_INET fails for every port — which pick_port reports as “No available ports found”, so luxar serve --host ::1 died on a completely free port. Only literals are switched; names (localhost) stay on AF_INET as before rather than inheriting whatever order the resolver happens to return.

Parameters:
  • port – Port number to check.

  • host – Host address to check.

Returns:

True if port is available, False if in use.

luxar.cli.utils.find_available_port(start_port: int = 8000, max_attempts: int = 100, *, end_port: int | None = None, host: str = '127.0.0.1') → int | None[source]

Find an available port starting from the given port.

Parameters:
  • start_port – Port to start searching from.

  • max_attempts – Maximum number of ports to try.

  • end_port – Inclusive upper bound for the search; overrides max_attempts when given (capped at 65535).

  • host – Host address the port must be bindable on — pass the same host the server will bind so availability is checked on the interface actually used.

Returns:

Available port number, or None if none found.

luxar.cli.utils.pick_port(requested: int, host: str = '127.0.0.1', label: str = '') → int | None[source]

Resolve a usable port near requested, warning when it shifts.

Wraps find_available_port() with the uniform “port busy” warning every serve-family command should print, so callers can’t silently bind a different port than the user asked for.

Returns:

The chosen port, or None if no port is available.

luxar.cli.utils.dataset_title(path: Path | str | None) → str | None[source]

A human-recognizable title for a dataset path, or None.

Peels an archive extension (.zip, .tar.gz, .tgz) and then a dataset suffix (.luxar.zarr, .gsplats.zarr, .zarr) off the file name — global_rivers_earth from global_rivers_earth.luxar.zarr, and equally from global_rivers_earth.luxar.zarr.zip. Both layers matter: gsplat view takes .gsplats.zarr.zip / .gsplats.zarr.tar.gz archives, and a shipped demo scene is a .luxar.zarr.zip.

Serve-family commands pass the result as the viewer’s ?title= parameter so every browser tab names the scene it shows (several demo/dev tabs are otherwise indistinguishable). A scene’s authored viewer_config.title overrides it in the viewer.

luxar.cli.utils.append_title_param(viewer_url: str, title: str | None) → str[source]

Append &title=<url-encoded> to a viewer URL, or return it unchanged.

The viewer URL always already carries ?src=, so & is the right separator. Shared by every serve-family command so the tab title is spelled identically wherever a viewer URL is printed or opened.

luxar.cli.utils.wait_for_server(host: str, port: int, thread: Thread | None = None, timeout: float = 5.0, poll_interval: float = 0.05) → bool[source]

Poll until (host, port) accepts a TCP connection.

Replaces the old fixed time.sleep(1) startup delays: returns as soon as the server is actually reachable, and fails fast when the optional thread running the server has died (e.g. lost a bind race) instead of letting the caller open a browser onto a dead server.

Parameters:
  • host – Host the server binds; all-interfaces sentinels (0.0.0.0 / ::) are probed via loopback.

  • port – Port the server binds.

  • thread – Server thread to watch; a dead thread returns False early.

  • timeout – Total seconds to wait before giving up.

  • poll_interval – Delay between connection attempts.

Returns:

True once the server accepts a connection, False on timeout or thread death.

luxar.cli.utils.check_viewer_built() → bool[source]

Check if the Luxar viewer is built.

Returns:

True if viewer dist directory exists, False otherwise.

luxar.cli.utils.get_viewer_dist_path() → Path[source]

Get the path to the viewer distribution directory.

Checks two locations in order: 1. Bundled viewer inside the installed package (luxar/_viewer_dist/) 2. Development source tree (packages/luxar-viewer/dist/)

Returns:

Path to the viewer dist directory.

luxar.cli.utils.exit_code_from(returncode: int) → int[source]

Map a subprocess returncode to a shell-conventional exit code.

POSIX subprocess.run reports a signal-killed child as -N (signal number). Passing that straight to typer.Exit/sys.exit truncates to 256 - N (SIGKILL → 247), which no tooling recognizes; the shell convention is 128 + N (SIGKILL → 137). Non-negative codes pass through.

luxar.cli.utils.ensure_viewer_built(auto_build: bool = True) → bool[source]

Return True when a CURRENT viewer dist exists, building it if possible.

The serve-family commands previously disagreed on what to do when the viewer wasn’t built (warn-and-skip vs auto-build vs error). This is the single policy: auto-build via pnpm only when running from a dev source tree; from an installed wheel the bundled _viewer_dist should already exist, so a missing viewer is a packaging problem, not a build step.

In a dev tree “built” also means “not stale”: a dist older than the newest viewer source triggers a rebuild too. A FAILED rebuild of a merely-stale dist degrades to serving the stale build — with a loud warning — instead of taking serving down entirely.

luxar.cli.utils.build_viewer() → bool[source]

Build the Luxar viewer using pnpm.

Returns:

True if build successful, False otherwise.

luxar.cli.utils.format_tree_node(name: str, depth: int, is_last: bool, prefix: str = '', node_type: str | None = None, attrs: dict[str, Any] | None = None) → str[source]

Format a tree node for display.

Parameters:
  • name – Node name.

  • depth – Current depth in tree.

  • is_last – Whether this is the last child.

  • prefix – Prefix for the current line.

  • node_type – Type of node (“scene”, “group”, “points”, “lines”, “gsplats”, or “mesh”). Unknown values are rendered without a type icon.

  • attrs – Node attributes to display.

Returns:

Formatted tree node string.

luxar.cli.utils.format_memory_size(bytes_size: float) → str[source]

Format bytes size to human-readable string.

Parameters:

bytes_size – Size in bytes.

Returns:

Human-readable size string.

luxar.cli.utils.get_zarr_info(store_path: Path, detailed: bool = False) → dict[str, Any][source]

Get detailed information about a Zarr store.

Parameters:
  • store_path – Path to the Zarr store.

  • detailed – If True, include extra per-object details (shapes, dtypes).

Returns:

Dictionary with store information.

luxar.cli.utils.validate_zarr_store(store_path: Path) → tuple[bool, str | None][source]

Validate that a path is a valid Zarr store.

Parameters:

store_path – Path to validate.

Returns:

Tuple of (is_valid, error_message).

Network Simulation

Network simulation middleware for testing viewer performance under various network conditions.

Network simulation middleware for testing Luxar viewer under various network conditions.

This module provides ASGI middleware to simulate realistic network conditions including: - Bandwidth throttling (limit bytes per second) - Network latency (fixed delay + optional jitter) - Packet loss (random request dropping)

⚠️ WARNING: For development and testing only. Never use in production.

class luxar.cli.network_simulation.NetworkProfile[source]

Network profile configuration.

name: str
bandwidth: str
latency: str
jitter: float
packet_loss: float
description: str
luxar.cli.network_simulation.parse_bandwidth(bandwidth_str: str) → float[source]

Parse bandwidth string to Mbps.

Parameters:

bandwidth_str – Bandwidth with unit (e.g., “1mbps”, “500kbps”, “2.5gbps”)

Returns:

Bandwidth in megabits per second

Raises:

ValueError – If format is invalid

Examples

>>> parse_bandwidth("1mbps")
1.0
>>> parse_bandwidth("500kbps")
0.5
>>> parse_bandwidth("2.5gbps")
2500.0
luxar.cli.network_simulation.parse_latency(latency_str: str) → float[source]

Parse latency string to milliseconds.

Parameters:

latency_str – Latency with unit (e.g., “100ms”, “1s”, “0.5s”)

Returns:

Latency in milliseconds

Raises:

ValueError – If format is invalid

Examples

>>> parse_latency("100ms")
100.0
>>> parse_latency("1s")
1000.0
>>> parse_latency("0.5s")
500.0
luxar.cli.network_simulation.parse_jitter(jitter_str: str) → float[source]

Parse jitter string to decimal (0.0-1.0).

Parameters:

jitter_str – Jitter as percentage or decimal (e.g., “10%”, “0.1”)

Returns:

Jitter as decimal (0.0-1.0)

Raises:

ValueError – If format is invalid or out of range

Examples

>>> parse_jitter("10%")
0.1
>>> parse_jitter("0.15")
0.15
>>> parse_jitter("25%")
0.25
luxar.cli.network_simulation.parse_packet_loss(loss_str: str) → float[source]

Parse packet loss string to decimal (0.0-1.0).

Parameters:

loss_str – Packet loss as percentage or decimal (e.g., “1%”, “0.01”)

Returns:

Packet loss rate as decimal (0.0-1.0)

Raises:

ValueError – If format is invalid or out of range

Examples

>>> parse_packet_loss("1%")
0.01
>>> parse_packet_loss("0.05")
0.05
>>> parse_packet_loss("10%")
0.1
luxar.cli.network_simulation.parse_network_options(profile: str | None = None, bandwidth: str | None = None, latency: str | None = None, jitter: str | None = None, packet_loss: str | None = None) → tuple[float | None, float | None, float, float][source]

Parse and merge network simulation CLI parameters.

Loads a profile first (if given), then overrides with individual params.

Parameters:
  • profile – Profile name (e.g., “3g”, “satellite”).

  • bandwidth – Bandwidth string (e.g., “1mbps”, “500kbps”).

  • latency – Latency string (e.g., “100ms”, “1s”).

  • jitter – Jitter string (e.g., “10%”, “0.1”).

  • packet_loss – Packet loss string (e.g., “1%”, “0.01”).

Returns:

Tuple of (bandwidth_mbps, latency_ms, jitter_percent, packet_loss_rate).

Raises:

ValueError – If any parameter has invalid format.

luxar.cli.network_simulation.has_network_simulation(bandwidth_mbps: float | None, latency_ms: float | None, jitter_percent: float, packet_loss_rate: float) → bool[source]

Check if any network simulation parameters are active.

Returns True when at least one of bandwidth, latency, jitter, or packet loss is set to a non-zero/non-None value.

luxar.cli.network_simulation.print_network_params(bandwidth_mbps: float | None, latency_ms: float | None, jitter_percent: float, packet_loss_rate: float, qualifier: str = '') → None[source]

Print active network simulation parameters.

Parameters:
  • bandwidth_mbps – Bandwidth in Mbps.

  • latency_ms – Latency in milliseconds.

  • jitter_percent – Jitter as decimal (0.0-1.0).

  • packet_loss_rate – Packet loss as decimal (0.0-1.0).

  • qualifier – Optional qualifier for the message (e.g., “data server only”).

luxar.cli.network_simulation.load_network_profile(profile_name: str) → NetworkProfile[source]

Load network profile by name.

Parameters:

profile_name – Profile identifier (e.g., “3g”, “4g”, “satellite”)

Returns:

NetworkProfile dictionary with all parameters

Raises:

ValueError – If profile name is not recognized

class luxar.cli.network_simulation.NetworkSimulationMiddleware(app: Any, bandwidth_limit_mbps: float | None = None, latency_ms: float | None = None, jitter_percent: float = 0.0, packet_loss_rate: float = 0.0)[source]

ASGI middleware to simulate realistic network conditions.

This middleware simulates: - Bandwidth throttling (limits bytes per second) - Network latency (fixed delay + optional jitter) - Packet loss (random request dropping)

The middleware intercepts HTTP requests and responses, applying the configured simulation parameters.

⚠️ WARNING: For development and testing only. Never use in production.

__init__(app: Any, bandwidth_limit_mbps: float | None = None, latency_ms: float | None = None, jitter_percent: float = 0.0, packet_loss_rate: float = 0.0)[source]

Initialize network simulation middleware.

Parameters:
  • app – ASGI application to wrap

  • bandwidth_limit_mbps – Maximum bandwidth in megabits per second

  • latency_ms – Fixed latency to add to each response (milliseconds)

  • jitter_percent – Latency variation as percentage (0.0-1.0)

  • packet_loss_rate – Probability of dropping a request (0.0-1.0)

async __call__(scope: Dict[str, Any], receive: Callable, send: Callable) → None[source]

ASGI middleware entry point.

Network Profiles

The network simulation supports several built-in profiles that simulate real-world network conditions:

  • 3g: Mobile 3G connection (384 kbps, 300ms latency, 1% packet loss)

  • 4g: Mobile 4G/LTE (10 Mbps, 100ms latency, 0.5% packet loss)

  • 5g: 5G mobile (100 Mbps, 30ms latency, 0.1% packet loss)

  • satellite: Satellite internet (25 Mbps, 600ms latency, 1% packet loss)

  • rural: Rural DSL (1 Mbps, 100ms latency, 2% packet loss)

  • congested: Congested network (2 Mbps, 200ms latency, 3% packet loss)

  • slow-broadband: Slow broadband (5 Mbps, 50ms latency, 0.5% packet loss)

  • broadband: Home broadband (50 Mbps, 20ms latency, 0.1% packet loss)

  • fast-broadband: Fast broadband (200 Mbps, 10ms latency, 0.05% packet loss)

Example usage:

# Simulate 3G connection
luxar serve data.luxar.zarr --profile 3g --viewer

# Custom slow connection
luxar serve data.luxar.zarr --bandwidth 500kbps --latency 200ms --packet-loss 2%

# Override profile settings
luxar serve data.luxar.zarr --profile 4g --latency 300ms

See the Network Simulation for Luxar CLI - Technical Specification for detailed specifications.

Export

Export Luxar scenes as standalone offline viewer bundles.

Export a Luxar zarr scene + viewer into a standalone offline folder.

The exported folder contains everything needed to view the scene:

  • The Luxar viewer (HTML, JS, CSS, WASM) – including the touch-panel page

  • The zarr dataset (copied as-is)

  • A serve.py script (Python 3 stdlib only), which can also host the remote-control relay so the folder alone runs a kiosk

  • A README.txt with usage instructions

Usage:

luxar export my_scene.luxar.zarr -o my_export/
cd my_export && python3 serve.py
cd my_export && python3 serve.py --control --host 0.0.0.0   # kiosk
luxar.cli.export.export_scene(source: Path, output: Path, data_dir_name: str = 'data', overwrite: bool = False) → Path[source]

Export a zarr scene with viewer into a standalone folder.

Parameters:
  • source – Path to the source zarr store.

  • output – Path to the output folder.

  • data_dir_name – Name for the data directory inside output.

  • overwrite – Whether to overwrite existing output.

Returns:

Path to the created output folder.

Raises:
class luxar.cli.export.SceneFacts(title: str | None = None, has_control_panel: bool = False, chapter_dimension: str | None = None, chapter_count: int = 0, panel_tiles: int = 0, dimensions: tuple[str, ...] = (), displayed: tuple[str, ...] = (), citation: str | None = None)[source]

What the exporter can learn about a scene from its own attributes.

Read through luxar._zarr_compat.read_node_attrs(), never by naming a metadata document: the attributes live in .zattrs at zarr format 2 and nested under attributes in zarr.json at format 3, and a tree holding both is the normal steady state here. A literal filename would answer “no control panel” for half the stores in existence, and the export would look like it worked.

title: str | None = None
has_control_panel: bool = False

it carries a viewer_config.control_panel. Decides the exported serve.py’s defaults and what the README leads with.

Type:

A scene authored to be driven from a tablet

chapter_dimension: str | None = None

The dimension the panel steps, and how many stops it names.

chapter_count: int = 0

a chapter only supplies a slot’s sublabel, so a dimension coordinate with no authored chapter still gets a tile, labelled from the dimension’s categories.

Type:

Authored Chapter entries. NOT the number of tiles

panel_tiles: int = 0

Tiles the panel draws – one per coordinate of the chapter dimension. Differs from chapter_count whenever a coordinate carries no authored chapter (an unauthored overview slot, say), so this is the number any operator-facing text must quote.

dimensions: tuple[str, ...] = ()

Dimension names in order, and which of them the viewer displays.

displayed: tuple[str, ...] = ()
citation: str | None = None
property stops: int

Tiles the panel draws, for operator-facing text.

Falls back to the authored chapter count so a SceneFacts built by hand, without going through read_scene_facts, still reports a sensible number rather than zero (which would also drop the README’s scene summary, since that block only renders when there is a count).

__init__(title: str | None = None, has_control_panel: bool = False, chapter_dimension: str | None = None, chapter_count: int = 0, panel_tiles: int = 0, dimensions: tuple[str, ...] = (), displayed: tuple[str, ...] = (), citation: str | None = None) → None
luxar.cli.export.read_scene_facts(source: Path) → SceneFacts[source]

Introspect source for the facts the export needs. Never raises.

A store this cannot read is not an export failure: the folder is still valid, it just gets the generic README and the conservative serve.py defaults. So every lookup is defensive.

luxar.cli.export.THIRD_PARTY_LICENSES = 'THIRD_PARTY_LICENSES.txt'

Emitted into the viewer build by packages/luxar-viewer/scripts/generate-third-party-licenses.mjs.

luxar.cli.export.SERVE_TEMPLATE = PosixPath('/home/runner/work/luxar/luxar/packages/luxar/src/luxar/cli/_export_serve_template.py')

The generated serve.py is this module, copied with two values substituted. It is a real module rather than a string literal so ruff, mypy and the test suite all see it – a WebSocket relay hidden inside an f-string (where every brace has to be doubled) is unreviewable, and this one is the fourth implementation of a relay that three other files already agree on.

luxar.cli.export.QR_MODULE_SOURCE = PosixPath('/home/runner/work/luxar/luxar/packages/luxar/src/luxar/cli/_qr.py')

The QR encoder, copied beside serve.py under this name. serve.py imports it by this exact name, so the two move together.

Native Bundles

Producers for double-clickable native bundles wrapped around the Go-compiled launcher binary (packages/luxar-launcher). Used internally by the luxar export --native subcommand to emit .app bundles on macOS and portable folders on Linux. The bundle layout, Info.plist generator, and launcher-binary lookup all live in this module.

Native bundle producers for luxar export --native.

Wraps a copy of the viewer dist + a zarr scene around a precompiled Go launcher (built via make build-launchers) so end users can double-click the result instead of running a Python server.

Two bundle formats are supported in the v1 prototype:

  • macOS .app: standard <name>.app/Contents/{Info.plist, MacOS, Resources} layout. The launcher lives at Contents/MacOS/launcher; viewer and data at Contents/Resources/{viewer,data}.

  • Linux portable folder: <name>-linux-<arch>/ containing the launcher binary alongside viewer/ and data/ plus a small README. Most desktop file managers happily run the binary on double-click; CLI users can run ./luxar-launcher.

Windows + AppImage are intentionally out of scope for this prototype.

luxar.cli.native_app.validate_bundle_name(name: str) → str[source]

Validate that name is a safe, opaque app/file name — never a path.

A native bundle name is interpolated directly into on-disk paths (e.g. output / f"{name}.app"). It must therefore be a single, opaque display/file name component and never smuggle in path structure that could escape the requested output directory (issue #686). This rejects path separators, ./.. components, absolute paths, NUL/control characters, and empty/whitespace-only names; ordinary names with spaces and non-ASCII Unicode letters (e.g. "My Scéne 2") stay valid.

Parameters:

name – The candidate app/bundle name.

Returns:

name unchanged when it is valid.

Raises:

ValueError – If name is not a safe, opaque name component.

exception luxar.cli.native_app.LauncherNotBuiltError[source]

Raised when a launcher binary is missing for the requested platform.

luxar.cli.native_app.get_launcher_path(platform_name: str) → Path[source]

Return the path to the launcher binary for platform_name.

Parameters:

platform_name – One of SUPPORTED_PLATFORMS.

Raises:
luxar.cli.native_app.bundle_macos_app(*, viewer_dist: Path, zarr_data: Path, output: Path, app_name: str) → Path[source]

Build a macOS .app bundle at output / f"{app_name}.app".

The launcher binary is the Go-compiled darwin-universal artifact; viewer + data live under Contents/Resources.

luxar.cli.native_app.bundle_linux_folder(*, arch: str, viewer_dist: Path, zarr_data: Path, output: Path, app_name: str) → Path[source]

Build a portable Linux folder at output / f"{app_name}-linux-{arch}/".

Parameters:

arch – "amd64" or "arm64".

luxar.cli.native_app.zip_macos_app(app_path: Path) → Path[source]

Zip a macOS .app bundle next to itself.

Prefers ditto -c -k --keepParent on Darwin so resource forks, extended attributes, and the bundle’s directory layout are preserved exactly. On non-Darwin hosts (e.g. Linux producing a macOS bundle as a cross-build), falls back to zipfile with executable-bit preservation so the embedded launcher remains runnable after extraction.

The archive lands at <app_path>.zip (i.e. Foo.app.zip next to Foo.app). It is built to a sibling temp path and atomically swapped into place (issue #687), so an interrupted zip (Ctrl-C, ENOSPC) never leaves a truncated-but-openable archive, and a prior good .zip survives until the new one is complete.

Returns the path to the produced .zip.

Mesh Commands

Registration surface for the luxar mesh import and luxar mesh lod commands.

The luxar mesh command group.

A pure registration surface, matching gsplat_commands.py: the Typer object plus a series of register_*_commands calls. Command bodies live in cli/mesh_ops/.

Mesh Operations

Implementations behind luxar mesh .... Each module registers one command family on the shared Typer application.

Thematic command groups for luxar mesh.

Mirrors cli/gsplat_ops/: each module exposes a register_*_commands(app) that attaches its commands to the shared app_mesh Typer, so registration order in mesh_commands.py is also --help display order.

luxar.cli.mesh_ops.register_import_commands(app: Typer) → None[source]

Attach the mesh interchange commands to the mesh CLI.

luxar.cli.mesh_ops.register_lod_commands(app: Typer) → None[source]

Attach the mesh LOD commands to the mesh CLI.

GSplat Commands

CLI commands for fitting, converting, rendering, merging, and managing Gaussian splats.

Aggregator for the luxar gsplat command group.

The command implementations live in cli/gsplat_ops/ — one root module or subpackage per thematic group (inspect / transforms / fitting / scene / interchange / benchmark / batch), plus the lod recipe command in cli/lod.py. This module builds the app_gsplat Typer and registers each group onto it, keeping itself a thin registration surface rather than a multi-thousand-line god-file.

main.py mounts app_gsplat.

GSplat Configuration

Configuration loading and validation for GSplat CLI commands.

Configuration system for gsplat CLI commands.

Provides: - Fitting presets (draft/standard/hifi/ultra/n2s) - YAML config loading with priority chain - Commented YAML config dump - Volume file loaders (.npy, .npz, .tiff, .zarr, imageio fallback) - OME-Zarr shape discovery - Utility parsers for CLI arguments

class luxar.cli.gsplat_config.OMEZarrInfo(axes: ~typing.List[str], shape: ~typing.Tuple[int, ...], n_timepoints: int, n_channels: int, channel_axes: ~typing.List[str], channel_shape: ~typing.Tuple[int, ...], spatial_shape: ~typing.Tuple[int, ...], spatial_axes: ~typing.List[str], time_axis: int | None = None, channel_indices: ~typing.Tuple[int, ...] = (), spatial_indices: ~typing.Tuple[int, ...] = (), axis_units: ~typing.List[str | None] = <factory>, axis_scales: ~typing.Tuple[float, ...] | None = None, voxel_size: ~typing.Tuple[float, ...] | None = None, unit: str | None = None, resolution_levels: int = 1, path: ~pathlib.Path | None = None)[source]

Metadata about an OME-Zarr dataset’s structure.

axes: List[str]

Axis labels, e.g. ["t", "c", "z", "y", "x"].

shape: Tuple[int, ...]

Full array shape at highest resolution.

n_timepoints: int

Size of the T dimension (1 if absent).

n_channels: int

Number of flat channel tasks (product of channel-like axes, or 1).

channel_axes: List[str]

Axis labels folded into the flat channel task index.

channel_shape: Tuple[int, ...]

Shape of axes folded into the flat channel task index.

spatial_shape: Tuple[int, ...]

ZYX (or YX) portion of the shape.

spatial_axes: List[str]

Spatial axis labels, e.g. ["z", "y", "x"].

time_axis: int | None = None

Index into shape of the time axis, or None when there is none.

Part of the (time_axis, channel_indices, spatial_indices) trio below: the decomposition discovery ACTUALLY used to derive n_timepoints / n_channels / spatial_shape.

channel_indices: Tuple[int, ...] = ()

Indices into shape of the axes folded into the flat channel index.

In the order they are folded, so decode_flat_channel_index(c, channel_shape) maps position-for-position onto them.

spatial_indices: Tuple[int, ...] = ()

Indices into shape of the spatial axes, in spatial_shape order.

Publishing all three indices closes a standing hazard: a consumer that needs to know which axis played which role had to RE-DERIVE it from axes with a second vocabulary, and the vocabularies disagree in both directions. NGFF metadata is classified by the type field, so a channel axis named stain is a channel here but not to any name-driven rule, while an axis typed view falls through to SPATIAL here but is channel-like to classify_axis_labels(). Two vocabularies deciding the same question is how a consumer silently plans against a layout discovery never reported — read these fields instead of re-classifying the labels.

axis_units: List[str | None]

Physical unit for each source axis, position-for-position with axes.

axis_scales: Tuple[float, ...] | None = None

Composed NGFF scale for every source axis, or None when unavailable.

voxel_size: Tuple[float, ...] | None = None

Physical spacing from coordinateTransformations (spatial axes only).

unit: str | None = None

Physical unit string (e.g. "micrometer").

resolution_levels: int = 1

Number of multiscale levels.

path: Path | None = None

Path to the zarr store.

__post_init__() → None[source]

Keep positional axis metadata aligned with the discovered axes.

__init__(axes: ~typing.List[str], shape: ~typing.Tuple[int, ...], n_timepoints: int, n_channels: int, channel_axes: ~typing.List[str], channel_shape: ~typing.Tuple[int, ...], spatial_shape: ~typing.Tuple[int, ...], spatial_axes: ~typing.List[str], time_axis: int | None = None, channel_indices: ~typing.Tuple[int, ...] = (), spatial_indices: ~typing.Tuple[int, ...] = (), axis_units: ~typing.List[str | None] = <factory>, axis_scales: ~typing.Tuple[float, ...] | None = None, voxel_size: ~typing.Tuple[float, ...] | None = None, unit: str | None = None, resolution_levels: int = 1, path: ~pathlib.Path | None = None) → None
luxar.cli.gsplat_config.build_dimensions_from_data(centers: ndarray, dimension_metadata: Sequence[Mapping[str, Any]] | None = None) → Any[source]

Build a Dimensions object from a gsplat center bounding box.

Parameters:
  • centers – Splat center positions (N, D)

  • dimension_metadata – Optional ordered descriptors to overlay. Any length, shape, or name mismatch is ignored in favor of inferred defaults.

Returns:

Dimensions with ranges matching the data extent

luxar.cli.gsplat_config.decode_flat_channel_index(channel: int, channel_shape: Tuple[int, ...]) → Tuple[int, ...][source]

Decode a flat channel task index into folded channel-axis coordinates.

For data with multiple non-spatial, channel-like axes (for example camera and channel), batch planning treats each axis combination as one flat channel task. This helper uses row-major order to map the flat index back to per-axis coordinates.

luxar.cli.gsplat_config.discover_ome_zarr_shape(path: Path, axes_override: List[str] | None = None, array_key: str | None = None) → OMEZarrInfo[source]

Discover the shape and axis structure of an OME-Zarr dataset.

Which array is read is ONE rule shared with the two volume-loading entry points (_select_zarr_array()), so a re-fit that re-opens a store cannot land on a different (e.g. downsampled) array than the command that read its shape.

Parses that array’s NGFF multiscales metadata in BOTH OME-Zarr layouts — top-level (0.4) and nested under an ome key (0.5); see resolve_ngff_attrs(). The block is read off the GROUP THAT OWNS the selected array when that group’s block is evidence about it (_owner_ngff_attrs()), off the root otherwise. A block whose axes count disagrees with the SELECTED array’s ndim is not metadata about that array (a 5D image beside its 3D labels/…) and is skipped. Falls back to a custom axes attribute — root-first, owner second, see _usable_custom_axes() — then to a shape-based heuristic (5D→TCZYX, 4D→CZYX, 3D→ZYX) for non-NGFF zarr stores. That last fallback GUESSES the T/C roles and recovers no voxel size, so for an ambiguous (≥4D) store it says so on the console — stating whether nothing was declared or something was declared but unusable — and points at axes_override.

Accepts both plain .zarr directories and .zarr.zip archives — zarr’s ZipStore handles the latter transparently.

Parameters:
  • path – Path to the .zarr store or .zarr.zip archive.

  • axes_override – Explicit axis labels (e.g. ["time","channel","z","y","x"]). Overrides axis classification and names while retaining positional NGFF units/scales from the selected array when available.

  • array_key –

    Key path to a specific array within the zarr store (e.g. "h2afva/fused"). When provided, skips auto-selection and navigates directly to this array (which may itself be a group — then its own full-resolution level is taken).

    The datasets[] entry for the voxel size is matched against the array that was SELECTED, whether or not a key was passed — so a coarser pyramid level reports its own spacing, not level 0’s, and that holds for the auto path too (which can perfectly well land on a coarser level, e.g. when level 0’s declared path does not resolve). The match is exact, but on the NORMALISED spellings, so a block writing its own levels explicitly relative ("./0" for the array at "0") still names them; see _selected_dataset().

Returns:

OMEZarrInfo with discovered metadata.

Raises:

ValueError – If the zarr store has no arrays, array_key is not found, or the store is unreadable.

luxar.cli.gsplat_config.dump_default_config(preset: str = 'standard') → str[source]

Generate a fully-commented YAML config with defaults from a preset.

Parameters:

preset – Base preset to use for default values

Returns:

YAML string with all parameters and comments

luxar.cli.gsplat_config.get_fit_defaults() → Dict[str, Any][source]

Extract default parameter values from fit_gaussian_splats signature.

luxar.cli.gsplat_config.load_fit_config(preset: str | None = None, config_path: Path | None = None, cli_overrides: Dict[str, Any] | None = None, command_defaults: Dict[str, Any] | None = None) → Dict[str, Any][source]

Build a merged fit config from preset, YAML file, and CLI overrides.

Priority chain (highest wins):

CLI flags > YAML config > preset > command defaults > function defaults

Parameters:
  • preset – Preset name (“draft”, “standard”, “hifi”, “ultra”, “n2s”) or None

  • config_path – Path to YAML config file or None

  • cli_overrides – Dict of CLI-provided values (None values are ignored)

  • command_defaults – Per-command defaults that displace the harvested function defaults but yield to preset / YAML / CLI (None values are ignored, so a caller can pass a sentinel-free dict). Lets one command carry a different baseline from a bare fit — e.g. the content-tiling path’s near-lossless cull_retention.

Returns:

Merged config dict ready to pass as **kwargs to fit_gaussian_splats

luxar.cli.gsplat_config.load_volume(path: Path, channel: int | None = None, timepoint: int | None = None, array_key: str | None = None, axes: str | None = None, info: Dict[str, Any] | None = None, region: Tuple[slice, ...] | None = None) → ndarray[source]

CLI wrapper around luxar.io.volume.load_volume().

Converts the domain-layer ImportError (raised when an optional reader such as tifffile/imageio is missing) into a clean typer.Exit(1) with an install hint, so the CLI shows a friendly message instead of a traceback. All loading behaviour is identical to the domain function.

luxar.cli.gsplat_config.parse_hex_color(s: str) → Tuple[float, float, float][source]

Parse a hex color string to RGB floats.

Parameters:

s – Hex color like “#ff0080” or “ff0080”

Returns:

Tuple of (R, G, B) floats in [0, 1]

luxar.cli.gsplat_config.parse_seeds(value: str | None) → int | float | None[source]

Parse the –seeds CLI argument.

Parameters:

value – “auto” | None | integer string | float string

Returns:

None (auto), int (exact count), or float (compression ratio)

luxar.cli.gsplat_config.parse_shape(s: str) → Tuple[int, ...][source]

Parse a comma-separated shape string.

Parameters:

s – Shape like “128,128,128”

Returns:

Tuple of ints

class luxar.cli.gsplat_config.FitPreset(*values)[source]

Fitting quality presets.

DRAFT = 'draft'
STANDARD = 'standard'
HIFI = 'hifi'
ULTRA = 'ultra'

GSplat Operations

Implementations behind luxar gsplat ...: fitting/calibration (fitting/), whole-timelapse batch fitting (batch/), post-fit editing and inspection (transforms/ plus the scene/inspect/interchange root modules). The package __init__ is docstring-only — the Typer groups are assembled in luxar.cli.gsplat_commands, and each family’s registration surface is its own commands.py.

Command-group modules for the luxar gsplat CLI.

Each thematic group exposes a register_<group>(app) (or, for batch-fit, an app_batch sub-app) that wires its commands onto the shared app_gsplat Typer defined in cli/gsplat_commands.py. The aggregator imports and calls them — keeping gsplat_commands.py a thin registration surface rather than a 4.7k-line god-file.

Small groups stay as single root modules (scene_commands, inspect_commands, interchange_commands, benchmark) alongside the shared root helpers (recipe_shared, planner, encoding). The three large groups are subpackages whose registration surface is commands.py:

  • fitting/ — fit / cal / render / denoise

  • batch/ — the batch-fit group (local multi-GPU + Slurm)

  • transforms/ — edit-style commands on a fitted .gsplats.zarr

Their __init__.py files are docstring-only: importers name the owning module directly, never a re-export root.

LOD Command

Implementation of the luxar gsplat lod command (recipe-driven LOD topology construction).

luxar gsplat lod --recipe — build a representation topology from a fit.

A thin CLI wrapper over luxar.gsplats.lod.recipes.build_recipe(). It parses the option superset, validates that the options given are relevant to the chosen recipe, fills scale-derived defaults, loads the input, builds the recipe, and writes the output .gsplats.zarr.

The single --recipe flag builds one of the intent-first topologies (flat / stream / levels / tiles / overview / adaptive); it replaced the historical lod additive / lod substitutive / lod pyramid subcommands (now the stream / levels recipes).

luxar.cli.lod.lod_recipe(input_path: Path = <typer.models.ArgumentInfo object>, output_path: Path = <typer.models.ArgumentInfo object>, recipe: str | None = <typer.models.OptionInfo object>, n_lods: int | None = <typer.models.OptionInfo object>, method: str | None = <typer.models.OptionInfo object>, legacy_method: str | None = <typer.models.OptionInfo object>, legacy_substitutive_method: str | None = <typer.models.OptionInfo object>, breakpoints: str | None = <typer.models.OptionInfo object>, target_ms: float | None = <typer.models.OptionInfo object>, bandwidth_mbps: float | None = <typer.models.OptionInfo object>, bytes_per_splat: float | None = <typer.models.OptionInfo object>, truncation_sigmas: float | None = <typer.models.OptionInfo object>, max_n_dense: int | None = <typer.models.OptionInfo object>, max_elements: int | None = <typer.models.OptionInfo object>, parts: int | None = <typer.models.OptionInfo object>, partition_rule: str | None = <typer.models.OptionInfo object>, compression_factor: int | None = <typer.models.OptionInfo object>, levels: int | None = <typer.models.OptionInfo object>, substitutive_method: str | None = <typer.models.OptionInfo object>, lloyd_iterations: int | None = <typer.models.OptionInfo object>, candidate_bins_k: int | None = <typer.models.OptionInfo object>, coverage_inflation: float | None = <typer.models.OptionInfo object>, additive_ladders: bool | None = <typer.models.OptionInfo object>, conserve_mass: bool | None = <typer.models.OptionInfo object>, refine: str | None = <typer.models.OptionInfo object>, refine_iters: int | None = <typer.models.OptionInfo object>, target_path: Path | None = <typer.models.OptionInfo object>, target_channel: int | None = <typer.models.OptionInfo object>, target_timepoint: int | None = <typer.models.OptionInfo object>, target_axes: str | None = <typer.models.OptionInfo object>, target_array_key: str | None = <typer.models.OptionInfo object>, reveal_center: str | None = <typer.models.OptionInfo object>, spatial_dims: str | None = <typer.models.OptionInfo object>, coarsen_dims: str | None = <typer.models.OptionInfo object>, quality_stamps: bool | None = <typer.models.OptionInfo object>, quality_max_pair_splats: int | None = <typer.models.OptionInfo object>, ordering: str = <typer.models.OptionInfo object>, device: str | None = <typer.models.OptionInfo object>, seed: int | None = <typer.models.OptionInfo object>, overwrite: bool = <typer.models.OptionInfo object>, encoding: str = <typer.models.OptionInfo object>, compress: str | None = <typer.models.OptionInfo object>, quiet: bool = <typer.models.OptionInfo object>) → None[source]

Build a representation topology from a fitted gsplat dataset.

One recipe, scale-ordered; every recipe streams by default (each leaf carries a progressive “stream” ladder unless –no-additive). Pick by what you need:


  recipe     structure                        use when
  flat       one bare leaf                    tiny data / debugging
  stream     one leaf + progressive ladder    small data, fast first paint
  levels     coarse->fine replacement levels  zooming across scales
  tiles      spatial tiles (culled), each     large scene, one scale
             with its own ladder
  overview   instant coarse overview level    huge scene, "see everything
             + fine tiles on zoom             first" (detail where you look)
  adaptive   tiles where EVERY tile picks     largest scenes, locally
             its own detail level             adaptive detail

Scale is not the only axis. When first paint is request-constrained, the recipe is picked by how many levels and tiles the viewer fetches EAGERLY, not by N: overview defers its whole fine branch behind a selector, while tiles and adaptive load every part at once. Measured on one 1.58 GiB 4D timelapse, an adaptive build (44 parts) needed ~18x more requests to first paint than the same data as one stacked leaf with a stream ladder.

(Renamed 2026-07: additive->stream, substitutive/pyramid->levels, partitioned->tiles, multiscale->overview, mosaic->adaptive.)

Input must be a fitted / flat .gsplats.zarr (output of luxar gsplat fit). Canonical pipeline: cal -> fit --seeds K* -> lod --recipe ....

 .. admonition:: Examples

luxar gsplat lod fit.gsplats.zarr out.gsplats.zarr –recipe stream –n-lods 6

luxar gsplat lod fit.gsplats.zarr out.gsplats.zarr –recipe tiles

–max-elements 250000

luxar gsplat lod fit.gsplats.zarr out.gsplats.zarr –recipe overview

–compression-factor 8

luxar.cli.lod.register_lod_command(app: Typer) → None[source]

Attach the unified lod command to the gsplat Typer app.

Info Command

Implementation of the luxar info dataset-inspection command.

The luxar info command — inspect a Zarr scene’s hierarchy and stats.

Registered onto the root Typer app by register_info_command() (mirroring the cli/gsplat_ops/*::register_*_commands pattern), so cli/main.py stays a thin entry point. _print_tree / _dfs are module-level helpers; _dfs is re-exported from cli/main.py for the existing test import.

luxar.cli.info_command.register_info_command(app: Typer) → None[source]

Attach the info command to app.

luxar.cli.info_command.HTTP1_RTT_BOUND_CHUNK_BYTES = 32768

Mean chunk payload below which a full load is dominated by per-request round trips on an HTTP/1.1 host (six connections, no multiplexing). Distinct from MIN_CHUNK_BYTES (the optimizer’s re-chunk floor): a 20 KB chunk is above the floor and still costs a 30 ms RTT per 20 KB on such a host.

Optimize Command

Implementation of the luxar optimize re-chunking command — a thin Typer layer over luxar.io.optimize, which owns every decision about what a re-chunk may change and what it must not.

The luxar optimize command — re-chunk an existing store for streaming.

A thin Typer layer over luxar.io.optimize; every decision about what may change and what must not lives there. Registered onto the root app by register_optimize_command(), mirroring info_command.py.

luxar.cli.optimize_command.register_optimize_command(app: Typer) → None[source]

Attach the optimize command to app.