Saved compositions and saved scenes — the Viewer's two kinds of "get this back".
Maintainer, 2026-09-07: "At the end they could choose to save it as a JSON for future
reproducibility. Also we should be integrating scene saving where users can essentially save
scenes — not only the input selection but also the scene for the user — and we should be very
opinionated about that and save it in the Tetravox [scene format]."
Those are two different artefacts and this module keeps them apart on purpose, because they answer
different questions and age differently:
A composition (compositions/<slug>.json) is what a person chose: a subject, a space, and
the inputs ticked in the Menu's tree, by stable id. It is small, it is diffable, and it survives a
re-run of the pipeline — reloading it next month re-resolves today's choices against whatever is on
disk then, and reports what has gone missing rather than failing. This is the reproducibility
artefact: "show me the same thing, from the current data".
A scene (scenes/<slug>.tetravox.json) is what a person was looking at: the native viewer's
serialized ViewSpec — camera, layout, per-layer window, threshold, colormap, opacity, cursor —
after they had adjusted it. It names concrete files. This is the record artefact: "show me exactly
this picture again". It is written in the app's own format, suffix and all, so the standalone
Tetravox app opens it by double-click; a PNG thumbnail is written beside it under the same stem.
Both live in the project (<project>/code/ti-toolbox/viewer/) rather than in browser storage or a
home directory, for the reason the presets already do: the project is the unit people copy, archive
and hand on, and a saved view that did not travel with it would be lost exactly when the work it
describes was passed to someone else.
Why the suffix is not negotiable. .tetravox.json is what the Tetravox app classifies as a
scene; a file ending in anything else — the working name .tvx.json included — is classified as
data and the app tries to read the JSON as a volume, silently, at the last step
(tit/server/routes/viewers.py's note on _SCENE_SUFFIX). This module refuses to write a scene
under any other suffix rather than trust a caller to remember that.
viewer_tree
viewer_tree(request: Request, subject: Annotated[SubjectId | None, Query()] = None, space: str | None = Query(None), simulations: list[str] | None = Query(None)) -> dict[str, Any]
Anatomy, simulations and analyses for subject, as the Menu draws them.
simulations repeats (?simulations=a&simulations=b) and says which ones are expanded, so
analyses are listed only for those. Everything is os.listdir and os.stat; no voxel is read,
because this is redrawn as a person clicks.
Each node carries the kind :func:tit.catalog.classify_view_file decided -- volume,
label-volume, surface, mesh -- and a surface carries its attachments. Surface
rows retain their surface kind; they are never re-labelled as meshes.
Source code in tit/server/routes/viewer_library.py
| @router.get(
"/api/viewer/tree", summary="What one subject offers the Menu's composition tree"
)
def viewer_tree(
request: Request,
subject: Annotated[SubjectId | None, Query()] = None,
space: str | None = Query(None),
simulations: list[str] | None = Query(None),
) -> dict[str, Any]:
"""Anatomy, simulations and analyses for *subject*, as the Menu draws them.
*simulations* repeats (``?simulations=a&simulations=b``) and says which ones are expanded, so
analyses are listed only for those. Everything is `os.listdir` and `os.stat`; no voxel is read,
because this is redrawn as a person clicks.
Each node carries the ``kind`` :func:`tit.catalog.classify_view_file` decided -- ``volume``,
``label-volume``, ``surface``, ``mesh`` -- and a surface carries its ``attachments``. Surface
rows retain their surface kind; they are never re-labelled as meshes.
"""
return viewspec.viewer_tree(
subject,
space,
list(simulations) if simulations else None,
)
|
save_composition
Store body as this composition.
The shape is the client's — a subject, a space and the chosen input ids — and this route does
not validate it beyond "it is an object". That is deliberate: the tree's vocabulary is going to
grow, and a server that rejected an unknown key would make every Menu change a two-repository
change. What it does own is the name, the timestamp and the atomic write.
Source code in tit/server/routes/viewer_library.py
| @router.put("/api/viewer/compositions/{name}", summary="Save one Viewer composition")
def save_composition(
name: str, body: dict[str, Any] | None = Body(None)
) -> dict[str, Any]:
"""Store *body* as this composition.
The shape is the client's — a subject, a space and the chosen input ids — and this route does
not validate it beyond "it is an object". That is deliberate: the tree's vocabulary is going to
grow, and a server that rejected an unknown key would make every Menu change a two-repository
change. What it does own is the *name*, the timestamp and the atomic write.
"""
document = dict(body or {})
document["name"] = name
document["saved_at"] = _now()
document.setdefault("version", 1)
_write_json(
os.path.join(composition_dir(), f"{_slug(name)}{_COMPOSITION_SUFFIX}"), document
)
return document
|
list_scenes
Every saved scene, newest first, with whether it has a thumbnail.
The scene documents themselves are not returned — one is megabytes and the Menu only needs to
draw a row. GET /api/viewer/scenes/{name} is the read.
Source code in tit/server/routes/viewer_library.py
| @router.get("/api/viewer/scenes", summary="Saved Tetravox scenes")
def list_scenes() -> dict[str, Any]:
"""Every saved scene, newest first, with whether it has a thumbnail.
The scene documents themselves are not returned — one is megabytes and the Menu only needs to
draw a row. `GET /api/viewer/scenes/{name}` is the read.
"""
directory = saved_scene_dir()
try:
names = sorted(os.listdir(directory))
except OSError:
names = []
out: list[dict[str, Any]] = []
for entry in names:
if not entry.endswith(_SCENE_SUFFIX):
continue
stem = entry[: -len(_SCENE_SUFFIX)]
path = os.path.join(directory, entry)
try:
st = os.stat(checked_viewer_path(path))
except (OSError, HTTPException):
continue
meta = _read_json(os.path.join(directory, f"{stem}.meta.json")) or {}
saved_at = meta.get("saved_at")
if not isinstance(saved_at, str) or not saved_at:
saved_at = datetime.fromtimestamp(st.st_mtime, timezone.utc).isoformat()
out.append(
{
"name": meta.get("name", stem),
"slug": stem,
"path": path,
"bytes": st.st_size,
"saved_at": saved_at,
"modified_at": datetime.fromtimestamp(
st.st_mtime, timezone.utc
).isoformat(),
"created_at": (
datetime.fromtimestamp(st.st_birthtime, timezone.utc).isoformat()
if getattr(st, "st_birthtime", 0) > 0
else None
),
"subject": meta.get("subject"),
"simulation": meta.get("simulation"),
"field": meta.get("field"),
"has_thumbnail": _has_thumbnail(
os.path.join(directory, f"{stem}.png"), st.st_mtime
),
**_scene_health(path),
}
)
out.sort(key=lambda row: (row.get("saved_at") or "", row["slug"]), reverse=True)
return {"scenes": out}
|
native_scene_destination
Choose the canonical project destination without writing a substitute scene.
Source code in tit/server/routes/viewer_library.py
| @router.post(
"/api/viewer/scenes/{name}/native-destination",
summary="Prepare the active project's native scene save destination",
responses={409: {"description": "A scene with this name already exists"}},
)
def native_scene_destination(name: str) -> dict[str, str]:
"""Choose the canonical project destination without writing a substitute scene."""
target = checked_viewer_path(
os.path.join(saved_scene_dir(), f"{_slug(name)}{_SCENE_SUFFIX}")
)
if os.path.lexists(target):
raise HTTPException(
status_code=409, detail="A scene with this name already exists"
)
os.makedirs(os.path.dirname(target), exist_ok=True)
return {"scene_path": checked_viewer_path(target)}
|
save_scene
Write body's scene as scenes/<slug>.tetravox.json, plus a thumbnail and metadata.
scene is the native viewer's serialize reply — the live ViewSpec, with the camera the
person left it at and every layer's current window. It is written verbatim: the whole point
of saving a scene rather than a composition is that it is a record of a picture, and a server
that re-derived any part of it would be recording something else.
Three files share one stem, so a person moving the scene knows what belongs with it:
<slug>.tetravox.json (the app opens this), <slug>.png (the Menu's row), and
<slug>.meta.json (what it was of, and when).
Source code in tit/server/routes/viewer_library.py
| @router.put(
"/api/viewer/scenes/{name}",
summary="Save the scene the viewer is showing",
# Declared rather than merely raised: `dev/contracts_check.py` holds the server's own OpenAPI
# to being a superset of `contracts/openapi.yaml`, and a status a client is told to expect but
# the document never mentions is exactly the drift that gate exists to catch.
responses={413: {"description": "Scene document is implausibly large"}},
)
def save_scene(name: str, body: dict[str, Any] | None = Body(None)) -> dict[str, Any]:
"""Write *body*'s ``scene`` as ``scenes/<slug>.tetravox.json``, plus a thumbnail and metadata.
``scene`` is the native viewer's ``serialize`` reply — the live ``ViewSpec``, with the camera the
person left it at and every layer's current window. It is written **verbatim**: the whole point
of saving a scene rather than a composition is that it is a record of a picture, and a server
that re-derived any part of it would be recording something else.
Three files share one stem, so a person moving the scene knows what belongs with it:
``<slug>.tetravox.json`` (the app opens this), ``<slug>.png`` (the Menu's row), and
``<slug>.meta.json`` (what it was of, and when).
"""
payload = dict(body or {})
scene = payload.get("scene")
if not isinstance(scene, dict) or not scene.get("layers"):
raise HTTPException(
status_code=422,
detail="A scene must be a ViewSpec, with at least one layer",
)
scene = native_scene(scene)
encoded = json.dumps(scene)
if len(encoded.encode("utf-8")) > _MAX_SCENE_BYTES:
raise HTTPException(
status_code=413, detail="Scene document is implausibly large"
)
slug = _slug(name)
directory = saved_scene_dir()
target = checked_viewer_path(os.path.join(directory, f"{slug}{_SCENE_SUFFIX}"))
thumb_path = checked_viewer_path(os.path.join(directory, f"{slug}.png"))
meta_path = checked_viewer_path(os.path.join(directory, f"{slug}.meta.json"))
_write_json(target, scene)
thumbnail = _thumbnail_bytes(payload.get("thumbnail"))
if thumbnail is not None:
atomic_viewer_write(thumb_path, thumbnail)
meta = {
"name": name,
"slug": slug,
"saved_at": _now(),
"subject": payload.get("subject"),
"simulation": payload.get("simulation"),
"field": payload.get("field"),
"space": payload.get("space"),
}
_write_json(meta_path, meta)
from tit.server.host_path import host_project_dir
from tit.paths import get_path_manager
container_root = get_path_manager().project_dir or ""
host_root = host_project_dir(container_root)
host_path = None
if host_root and container_root and target.startswith(container_root):
host_path = os.path.join(host_root, os.path.relpath(target, container_root))
return {
**meta,
"path": target,
"scene_path": target,
"host_path": host_path,
"has_thumbnail": thumbnail is not None,
"bytes": len(encoded.encode("utf-8")),
}
|
suggest_scene_name
suggest_scene_name(subject: Annotated[SubjectId | None, Query()] = None, simulation: Annotated[EntityName | None, Query()] = None, field: str | None = Query(None)) -> dict[str, Any]
<subject>_<sim>_<field>_<date> — what the Save field is pre-filled with.
Server-side so the name a scene gets does not depend on which client saved it, and so the
date is the project's clock rather than a browser's.
Source code in tit/server/routes/viewer_library.py
| @router.get("/api/viewer/scenes/suggest/name", summary="A default name for a new scene")
def suggest_scene_name(
subject: Annotated[SubjectId | None, Query()] = None,
simulation: Annotated[EntityName | None, Query()] = None,
field: str | None = Query(None),
) -> dict[str, Any]:
"""``<subject>_<sim>_<field>_<date>`` — what the Save field is pre-filled with.
Server-side so the name a scene gets does not depend on which client saved it, and so the
date is the project's clock rather than a browser's.
"""
parts = [p for p in (subject, simulation, field) if p]
parts.append(datetime.now(timezone.utc).strftime("%Y%m%d"))
name = (
_DEFAULT_NAME_SAFE.sub("-", "_".join(str(p) for p in parts)).strip("-_")
or "scene"
)
return {"name": name[:80]}
|