SplatTools.Transform (SplatTools v0.1.0)

Copy Markdown View Source

Runs splat-transform, the MIT-licensed converter from PlayCanvas.

This module builds arguments, runs the command, and interprets what comes back. It never touches splat data itself — a gigabyte PLY is 4.2 million rows and 260 million float extractions, which is not a BEAM workload under any strategy, and the encoder it would have to reimplement involves k-means clustering and a spatial sort that no Elixir or Rust library provides.

That is the same shape as every other media type: shell out for the codec, own the orchestration.

Install

npm install -g @playcanvas/splat-transform

Requires Node 22 or newer. To point at a local install instead of PATH:

config :splat_tools, executable: "./node_modules/.bin/splat-transform"

Running a subprocess properly

Two things here are less obvious than they look.

The command runs under a Port rather than System.cmd/3, because System.cmd/3 has no timeout and a Task wrapped around it only makes the waiter killable — Task.shutdown/2 returns while the converter keeps running, holding its CPU and its output file. A port exposes the OS pid, so a timeout can actually signal the process.

And output is written into a temporary directory beside the destination and moved into place on success. A zero exit status is not evidence that this invocation produced anything: given an argument it treats as a flag, the tool prints something, exits zero, and never reads its input — and a stale file left at the destination by an earlier run would otherwise be accepted as a fresh result.

A directory rather than a renamed file because the converter picks its format from the whole filename: .compressed.ply is not .ply, and meta.json is a third format that writes .webp files beside itself. Renaming to stage changes the name, which silently changes the format.

Note that convert/3 replaces whatever is at the destination. Files an earlier run wrote under other names are not removed — converting to meta.json twice with different settings can leave an orphan from the first.

Summary

Functions

Whether the converter can be found.

Converts a splat file to another format, chosen by the output extension.

The executable this module runs.

Pulls the JSON value out of decorated command output.

Runs splat-transform with the given arguments.

Reports per-column statistics as parsed JSON.

Removes staging directories left behind in directory.

The installed version, or {:error, _} if the tool is missing.

Functions

available?()

@spec available?() :: boolean()

Whether the converter can be found.

convert(input, output, opts \\ [])

@spec convert(Path.t(), Path.t(), keyword()) ::
  {:ok, Path.t()} | {:error, SplatTools.Error.t()}

Converts a splat file to another format, chosen by the output extension.

.sog is the web delivery format — roughly 45× smaller than the source PLY on a real capture, and readable by browser viewers.

The output is staged in a temporary directory beside the destination and moved into place only once it is complete, so a failure never leaves a partial file at output and never passes off an earlier run's file as this one's work. An existing file at output is replaced.

Options

  • :sh_bands — spherical-harmonic bands to keep, 0..3. Dropping them is a build-time optimisation far more than a bandwidth one: SOG's palette already compresses SH to about 2 bytes per splat, so 0 makes encoding roughly 25× faster while the file shrinks only about a quarter.
  • :filter_nan — drop splats with non-finite values. Real training output contains them. Defaults to true.
  • :timeout — milliseconds, default 45 minutes.
  • :extra_args — appended verbatim, for flags this wrapper does not model.

Examples

SplatTools.Transform.convert("capture.ply", "capture.sog")
SplatTools.Transform.convert("capture.ply", "lite.sog", sh_bands: 0)

executable()

@spec executable() :: String.t()

The executable this module runs.

Configurable so a project-local install is reachable:

config :splat_tools, executable: "./node_modules/.bin/splat-transform"

extract_json(output)

@spec extract_json(String.t()) :: term() | nil

Pulls the JSON value out of decorated command output.

The payload is sandwiched between a version banner and a summary line, so it is neither at the start nor at the end. Every balanced candidate is tried and the largest one that actually parses wins — taking the first { finds {v1.2} in the banner, and taking everything after it swallows the summary.

Returns the decoded value, or nil if nothing in the output is JSON.

render_preview(input, output, opts \\ [])

@spec render_preview(Path.t(), Path.t(), keyword()) ::
  {:ok, Path.t()} | {:error, SplatTools.Error.t()}

Renders a preview image.

Output must be .webp — the renderer writes nothing else, and a .png path fails at the tool rather than here.

⚠️ Requires a GPU or a software Vulkan driver. There is no CPU rasterisation path: without a WebGPU device the tool reports "writeImage requires a createDevice function". On a headless server that means installing mesa-vulkan-drivers and libvulkan1 for llvmpipe.

Options

  • :camera — a SplatTools.Camera pose. Strongly recommended: the default camera is a fixed position that points at nothing in most real scenes. Its fov is passed through, so the framing the camera was solved for is the framing that gets rendered.
  • :resolution{width, height}, default {1024, 576}.

run(args, opts \\ [])

@spec run(
  [String.t()],
  keyword()
) :: {:ok, String.t()} | {:error, SplatTools.Error.t()}

Runs splat-transform with the given arguments.

Exposed because this wrapper does not model every flag, and shelling out to a tool you cannot reach past is worse than no wrapper.

stats(input, opts \\ [])

@spec stats(
  Path.t(),
  keyword()
) :: {:ok, map()} | {:error, SplatTools.Error.t()}

Reports per-column statistics as parsed JSON.

Useful for a quick look, but SplatTools.Camera.bounds/2 is the better source for framing: these are min/max, and a single stray splat inflates them enough to ruin a camera.

sweep_staging(directory, opts \\ [])

@spec sweep_staging(
  Path.t(),
  keyword()
) :: non_neg_integer()

Removes staging directories left behind in directory.

Output is staged through a hidden directory that is deleted when the conversion ends, however it ends — except when the calling process is killed outright, because no cleanup runs then. Those leftovers accumulate with nothing to collect them, so call this at startup, or on a schedule, for any directory conversions write into.

Only directories older than :older_than milliseconds are removed, default one hour. That is not a nicety: a conversion of a large capture runs for minutes, and a sweep with no age limit deletes the staging directory out from under one that is still running — which then fails, reporting that it wrote no file.

Returns the number of directories removed.

SplatTools.Transform.sweep_staging("priv/static/scans")
SplatTools.Transform.sweep_staging("priv/static/scans", older_than: :timer.hours(6))

version()

@spec version() :: {:ok, String.t()} | {:error, SplatTools.Error.t()}

The installed version, or {:error, _} if the tool is missing.