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-transformRequires 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.
Renders a preview image.
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
@spec available?() :: boolean()
Whether the converter can be found.
@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, so0makes 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 totrue.: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)
@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"
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.
@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— aSplatTools.Camerapose. Strongly recommended: the default camera is a fixed position that points at nothing in most real scenes. Itsfovis passed through, so the framing the camera was solved for is the framing that gets rendered.:resolution—{width, height}, default{1024, 576}.
@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.
@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.
@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))
@spec version() :: {:ok, String.t()} | {:error, SplatTools.Error.t()}
The installed version, or {:error, _} if the tool is missing.