Packages
Server-side toolkit for 3D Gaussian splat assets: validate a PLY, convert to compressed SOG, extract metadata, and derive a framing camera. Orchestration in Elixir, heavy lifting shelled out.
Current section
Files
Jump to
Current section
Files
splat_tools
CHANGELOG.md
CHANGELOG.md
# Changelog
All notable changes to this project are documented here.
The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.1.2] - 2026-08-22
Found by the review round covering the promote rewrite, which had shipped
without one — the only fix round of this package that ever did.
### Fixed
- **A failed promotion could destroy the failing file's original.** The
rollback restored every file that had already moved — and forgot the one
that failed. When its stash had succeeded and the forward rename then
failed (an immutable staged file, an I/O error, the staging sweeper firing
mid-promote), the original sat in the staging directory's backup folder,
and the cleanup deleted it with the rest: a failed conversion that silently
*removed* a pre-existing destination file. The failing name's backup is now
restored too — and only the backup, since a full undo would hand a
still-intact original to the cleanup when the stash was what failed.
- **A dangling symlink at a destination name was never stashed.**
`File.exists?/1` follows links, so it counted as absent, and a rollback
could not put it back. `stash` and `undo` now rename unconditionally and
ignore only ENOENT, which operates on the link itself.
## [0.1.1] - 2026-08-21
### Fixed
- **A configured executable that is not executable was reported as
available.** `File.regular?/1` says yes to a file whose executable bit is
gone — which happens to anything that has been through a zip, or copied by
a tool that does not preserve modes. `available?/0` then said the tool was
ready and `run/2` and `version/0` raised `:eacces` out of functions whose
specs promise an error tuple. The PATH branch already applied this test via
`System.find_executable/1`; the configured branch now matches it — the same
`type=regular` and `band 8#111` test OTP itself uses in `os.erl`.
- The error for a file that exists but cannot be executed said to reinstall
with npm. The fix is `chmod +x`, and it now says so.
- `config :splat_tools, executable: nil` raised a `FunctionClauseError` out of
`available?/0` rather than reporting the tool as missing.
## [0.1.0] - 2026-08-21
Initial release.
### Fixed — first review pass
Found by an external review panel and verified against the real converter
before acting; roughly a third of what the panel reported turned out to be
wrong, so each item below was reproduced first.
- **A stale file at the destination was accepted as fresh output.** A zero
exit status is not proof that an invocation wrote anything — given an
argument it reads as a flag, the tool prints something and exits zero
without touching its input — so a file left by an earlier run was reported
as this run's result. Output is now staged through a uniquely named
temporary sibling and renamed into place.
- **A second conversion to the same path always failed.** `splat-transform`
refuses to overwrite, so any retry was permanently poisoned by its own first
attempt's output. Staging fixes this too.
- **A timed-out conversion left the converter running.** `Task.shutdown/2`
only kills the process doing the waiting; the child kept its CPU and kept
writing. The command now runs under a `Port`, so the OS process can actually
be signalled — and, incidentally, a crash in the runner no longer takes its
caller down with it, which the linked `Task` did.
- **`prepare/3` silently discarded every camera option.** `:percentile`,
`:fov`, `:margin` and `:elevation` were accepted and dropped on the floor.
- **The framing distance used `tan` where the geometry needs `sin`.** The
limiting ray is tangent to the bounding sphere, so `d = r / sin(θ)`. The
camera sat closer than the tangent line and clipped the scene — masked by
the default margin, visible at `margin: 0.0` or a wide field of view.
- **The derived field of view was computed and never sent to the renderer.**
The camera was solved for 50° and rendered at the tool's default 60°.
- **`:percentile` and `:fov` were unvalidated.** A percentile at or above 0.5
returned a low above its high; a negative one returned `nil` and raised
several frames later; `fov: 0.0` divided by zero and `fov: 200.0` placed the
camera inside the scene.
- **Non-finite values were filtered per column, not per row.** `--filter-nan`
drops whole splats, so a row of `{NaN, 1.0e9, 0.0}` was absent from the
converted file while still contributing its enormous `y` to the bounds, and
the camera framed geometry that was not there.
- **The splat classifier counted property prefixes.** `scale_cat`,
`scale_dog`, `scale_eel` satisfied the scale requirement, and a file with
float64 positions was admitted and then decoded as float32 pairs, producing
bounds that were wrong without looking wrong. Names are now matched exactly
and positions must be float32.
- **`:name` could escape the output directory.** `name: "../../elsewhere"`
wrote outside the directory the caller named.
- **A failed preview left the converted file behind**, so the next attempt was
a retry over a previous run's output.
- **`extract_json/1` took the first `{` anywhere in the output**, which found
the banner's decoration rather than the payload, and returned only the first
element of a top-level array. It now tries every balanced candidate and
returns the largest that actually parses — decoded, rather than as a
substring for the caller to parse again.
- **The reported command could not be re-run.** Arguments were joined
unquoted, so a path containing a space came back as two arguments.
- **A preview was rendered from the source PLY**, meeting the dirty data the
conversion had just cleaned. It now renders from the converted file.
- Column extraction now asks `ply` for only the three position properties
rather than decoding all sixty and discarding fifty-seven.
### Fixed — second review pass
Three of these were created by the first fix round, which is the argument for
reviewing again after fixing rather than only before.
- **A multi-part output name silently lost its format.** `Path.extname/1` sees
only the last extension, so staging renamed `out.compressed.ply` to
`out.compressed.tmp-N.ply` — which the converter reads as a plain `.ply`.
Zero exit status, a file at the right path, and no compression. Output is
now staged through a temporary *directory*, so the tool sees the exact name
the caller asked for; that also collects the `.webp` sidecars a `meta.json`
conversion writes, which a renamed sibling could never have moved.
- **A failed preview destroyed the converted file.** Staging made the
conversion overwrite the destination, and the preview failure path then
deleted it — so a deliverable that existed before the call was replaced and
removed. A preview failure no longer fails the conversion at all, which is
what `:preview`'s own documentation always said it should do; the reason
arrives in `asset.preview_error`.
- **`sh_bands` reported the request rather than the file.** `-H n` caps the
bands, so asking for 3 from a file carrying 0 still yields 0 — and this is
the number the docs tell callers to persist.
- **`extract_json/1` was quadratic.** It began a fresh balanced walk at every
`{` and `[`, and an unmatched opener scanned to end of output each time: a
1.1 MB log with a stray brace on 5% of its lines took 5.6 seconds. It is now
a single stack-based pass.
- **A timed-out conversion retained everything it had printed.** `yes` under a
300 ms timeout produced a 64 MB error struct. Only the tail is ever shown,
so only the tail is kept.
- **A timeout left port messages in the caller's mailbox**, where they surface
as unexpected `handle_info` callbacks in a GenServer or Oban worker; and a
caller that traps exits blocked until the deadline and got a bogus timeout,
because the loop had no clause for the port's `EXIT`.
- **Every timeout reported "exceeded 0s"** — the span measured the time
remaining when it fired rather than the budget that was exceeded.
- **A temporary file survived a caller killed mid-run**, accumulating with
nothing to collect it, and its name drew on a node-local counter that two
releases in the same directory pick identically.
- **A malformed `:camera` raised a `FunctionClauseError`** where every other
invalid option in the module returns an error tuple.
### Fixed — third review pass
Two of these were introduced by the second fix round, in the two subsystems it
was rewriting. The pattern is now confirmed three times over.
- **The retained output kept the *start*, not the tail.** The accumulator is
newest-first, so flattening it without reversing gave the output in reverse
block order, and taking a suffix of that kept the oldest bytes — discarding
the converter's actual complaint, which is the only reason the output is
attached to an error at all. The trimmed value also froze on the first
retained region and was never refreshed. The comment above it claimed the
opposite.
- **A failed promotion left an asset from two different runs.** Files were
renamed one at a time with no rollback, so a failure part way through left
some of this run's output and some of the last one's at the destination —
reported as an error, and indistinguishable on disk from a good result.
Destinations are now checked before anything moves, and anything already
moved is put back.
- **`File.ls!` raised out of `convert/3`**, whose whole contract is an error
tuple.
- **The mailbox fix covered only the case that cannot happen.** A port links to
its owner, so a caller that traps exits — a GenServer or an Oban worker, the
stated motivation — receives `{:EXIT, port, :normal}` after an ordinary
*successful* run. Draining happened only on the timeout path, and the test
used a non-trapping process, so it passed vacuously.
- **`div(timeout, 1000)` reported "exceeded 0s"** for any sub-second budget.
- **A malformed `:camera` still raised**, just with a different exception than
before, while the comment above it said it must not raise at all. It now
returns an error tuple like every other invalid option.
- **One stray `"` poisoned the whole JSON scan**, because string state was
carried across the entire output. JSON forbids a raw newline inside a
string, so a newline now ends that state.
- **A fixed cap of twenty candidates could step past the payload** — thirty
steps of an ASCII progress bar are thirty small candidates. Bounded by
cumulative bytes instead.
- **`add_chunk/2` was quadratic in chunk count** (`IO.iodata_length/1` per
chunk); it carries a running size now.
- `timeout: :infinity` raised an `ArithmeticError`.
- Added `sweep_staging/1`, because `try/after` does **not** run when the
calling process is killed — the comment claiming it did was wrong, and the
leftover is a hidden directory inside the caller's own output directory.
- Corrected the module documentation, which still described the first fix
round's renamed-sibling mechanism, and now states plainly that `convert/3`
replaces the destination and does not remove an earlier run's other outputs.
### Fixed — fourth review pass
- **A failed promotion destroyed the previous run's file.** The rollback added
last round moved this run's files back into the staging directory — which is
deleted when the conversion ends. So a promotion that failed part way left
the destination missing a file it had before the call. A locked or immutable
file passes the pre-flight (`lstat` reports it as ordinary) and fails at the
rename, which is all it takes. Existing files are now moved aside first and
put back on failure. A stale asset is recoverable; one with a hole in it is
not.
- **The mailbox fix still leaked about one run in a hundred.** Draining with
`receive ... after 0` is a race — the exit status and the link signal are
delivered separately, so the process can wake on the status and drain before
the signal has arrived. The port is now unlinked when it is opened, so no
signal is ever sent. Third attempt at this defect, and the first without a
race.
- **`sweep_staging/1` deleted the working directory of a running conversion.**
Its own documentation says to run it on a schedule; doing so broke
conversions and reported that they had written no file. It now only removes
directories older than `:older_than` (default one hour), and only
directories — a plain file sharing the prefix was being deleted too.
- **`extract_json/1` gave up instead of skipping** when the largest candidate
exceeded the whole budget, losing a payload that was present and affordable.
### Changed
- `Camera.from_bounds/2` returns `{:ok, camera}` rather than a bare camera,
and out-of-range options return `{:error, %SplatTools.Error{}}` rather than
raising — the package now has one error contract instead of two.
- `asset.splat_count` is now `asset.source_splat_count`, with a
`nan_filtered` flag beside it, because it counts the rows in the *source*
header and the delivered file usually holds fewer.
- `SplatTools.Transform` takes its executable from
`config :splat_tools, executable:`, so a project-local install is reachable.
### Added
- `SplatTools.inspect_file/1` — classify a PLY as `:splat`, `:mesh` or
`:point_cloud` from its header alone, with splat count, SH bands and record
size. Cheap on any file size.
- `SplatTools.prepare/3` — validate, convert to SOG, and return everything
worth persisting, including a derived camera.
- `SplatTools.measure/2` — bounds and a framing camera without converting.
- `SplatTools.Camera` — percentile-based bounds and camera derivation, skipping
non-finite values.
- `SplatTools.Transform` — a wrapper over `splat-transform` with a real
timeout, output verification, and errors carrying the command that failed.
- `SplatTools.Error` — structured errors that name the command and quote the
tool's own output.