Skip to content

viewers

tit.server.routes.viewers

/api/view/{kind} and /api/view/args (v1).

The ViewSpec is built entirely in :mod:tit.viewspec (layer rules, LUT lookups, the six audit-bug fixes, percentile resolution, the Tetravox ViewSpec v2 scene document); this module only maps the query params to build_view.

D3 (docs/dev/DECISIONS.md § 2026-09-03 (One Docker image and a real development loop)): the external Freeview/Gmsh launch routes (POST /api/viewers/freeview, POST /api/viewers/gmsh), _require_x11 and the viewer job-kind submission they drove are removed -- there is no X11 in this runtime. freeview_args itself stays on the response for one release (deprecated) so an old client mid-migration does not break; :func:view_args still exists to preview it.

V2 (docs/dev/DECISIONS.md § 2026-09-06 (Native panes, job rows and notebooks)): viewing is the host-installed Tetravox desktop app, not an embed served from this origin. POST /api/view/open (bottom of this module) writes the scene document that app opens; it still launches nothing server-side, because the app it writes for is not on this side of the container boundary.

Electrode overlay creation (v1 gap, pages/viewer/PARITY.md #1): the Viewer page's "Create/refresh electrode overlay" action is not a viewer launch -- it is a tools job that runs tit.tools.electrode_overlay as a plain module invocation (tit/jobs/kinds.py's tools branch: simnibs_python -m <module> *args), the same way any other standalone tit.tools.* script would be run. The client submits::

POST /api/jobs
{
  "kind": "tools",
  "config": {
    "module": "tit.tools.electrode_overlay",
    "args": [
      "<sim_dir>/documentation/config.json",
      "<m2m_dir>/T1.nii.gz",
      "<sim_dir>/<TI|mTI>/montage_imgs/electrode_overlay_subject.nii.gz"
    ]
  },
  "subject_ids": ["<id>"]
}

(see tit/tools/electrode_overlay.py::main for the exact positional config/reference/output argv and the optional --montage/ --eeg-positions-dir flags). Once that job succeeds, GET /api/catalog/electrode-overlays?subject=&simulation= (tit.catalog.electrode_overlays) reports the resulting file's presence/path per TI/mTI mode -- tit.viewspec._electrode_overlay_layer already builds the ViewSpec layer for it once the file exists on disk, so no viewspec change was needed for the viewing half of this gap, only the listing half.

view_args

view_args(body: dict[str, Any]) -> dict[str, Any]

{viewspec} -> {freeview_args, freeview_command, scene}.

Exists so the Viewer page's "preview command" panel can show the real command -- generated by the same :func:viewspec.to_freeview_args a launch would use, on the same jailed/percentile-resolved spec -- instead of a hand-rolled TypeScript mirror that can (and did) drift from the server's actual argument grammar (ra_13 finding 9). Read-only: this never submits a job and launches nothing; layer paths are jailed but nothing runs.

Source code in tit/server/routes/viewers.py
@router.post(
    "/api/view/args",
    summary="The exact Freeview argv/command and Tetravox scene for a (possibly edited) ViewSpec",
)
def view_args(body: dict[str, Any]) -> dict[str, Any]:
    """``{viewspec}`` -> ``{freeview_args, freeview_command, scene}``.

    Exists so the Viewer page's "preview command" panel can show the *real*
    command -- generated by the same :func:`viewspec.to_freeview_args` a
    launch would use, on the same jailed/percentile-resolved spec -- instead
    of a hand-rolled TypeScript mirror that can (and did) drift from the
    server's actual argument grammar (ra_13 finding 9). Read-only: this
    never submits a job and launches nothing; layer paths are jailed but
    nothing runs.
    """
    spec = body.get("viewspec")
    if not isinstance(spec, dict):
        raise HTTPException(status_code=422, detail="body.viewspec is required")
    spec = _jail_viewspec_layers(spec)
    viewspec.resolve_percentiles(spec)
    # The same finishing step GET /api/view/{kind} runs, so an edited spec's
    # argv and its scene can never be built by two different sets of rules.
    viewspec.finish_spec(spec)
    args = spec["freeview_args"]
    _reject_option_like_args(args)
    return {
        "freeview_args": args,
        "freeview_command": ["freeview"] + args,
        "scene": spec["scene"],
    }

viewer_scene_dir

viewer_scene_dir() -> str

<project>/code/ti-toolbox/viewer/ -- sibling of config/ and jobs/.

Source code in tit/server/routes/viewers.py
def viewer_scene_dir() -> str:
    """``<project>/code/ti-toolbox/viewer/`` -- sibling of ``config/`` and ``jobs/``."""
    from tit.paths import get_path_manager

    return checked_viewer_path(
        os.path.join(os.path.dirname(get_path_manager().config_dir()), "viewer")
    )

checked_viewer_path

checked_viewer_path(path: str) -> str

path with its parent resolved, or 403 if it or its leaf's target leaves the project.

The leaf is kept as named (:func:tit.paths.resolve_leaf_within), so a delete or replace acts on a stored alias rather than on what it points to.

Source code in tit/server/routes/viewers.py
def checked_viewer_path(path: str) -> str:
    """*path* with its parent resolved, or 403 if it or its leaf's target leaves the project.

    The leaf is kept as named (:func:`tit.paths.resolve_leaf_within`), so a
    delete or replace acts on a stored alias rather than on what it points to.
    """
    from tit.paths import get_path_manager, resolve_leaf_within

    root = get_path_manager().project_dir
    try:
        if not root:
            raise ValueError("project directory not set")
        return resolve_leaf_within(root, path)
    except ValueError as exc:
        raise HTTPException(
            status_code=403, detail="Viewer storage must remain inside the project"
        ) from exc

atomic_viewer_write

atomic_viewer_write(target: str, content: bytes) -> None

Replace a whole document without following a predictable temporary-file symlink.

Source code in tit/server/routes/viewers.py
def atomic_viewer_write(target: str, content: bytes) -> None:
    """Replace a whole document without following a predictable temporary-file symlink."""
    target = checked_viewer_path(target)
    os.makedirs(os.path.dirname(target), exist_ok=True)
    # Re-check now that the directory exists: its resolved form is what the leaf joins.
    target = checked_viewer_path(target)
    directory = os.path.dirname(target)
    temporary = None
    try:
        candidate = os.path.join(directory, f".viewer-{secrets.token_hex(16)}.partial")
        # Exclusive creation refuses links/collisions; ordinary file mode preserves the
        # process umask so a host-side Tetravox user can still read the exported document.
        with open(candidate, "xb") as handle:
            temporary = candidate
            handle.write(content)
        target = checked_viewer_path(target)
        os.replace(temporary, target)
    finally:
        if temporary is not None:
            try:
                os.unlink(temporary)
            except FileNotFoundError:
                pass

localise_scene_paths

localise_scene_paths(scene: dict[str, Any], container_root: str, host_root: str | None) -> dict[str, Any]

Every dataset/sidecar URL in scene rewritten to a filesystem path.

Returns a new document; scene is not mutated (the same object is still the response body of GET /api/view/{kind}, where the URL form is correct). With no host_root the container path is used, which is right for a browser-mode download opened on a machine that mounts the project at the same place, and honest everywhere else -- Tetravox says which file it could not find.

Source code in tit/server/routes/viewers.py
def localise_scene_paths(
    scene: dict[str, Any], container_root: str, host_root: str | None
) -> dict[str, Any]:
    """Every dataset/sidecar URL in *scene* rewritten to a filesystem path.

    Returns a new document; *scene* is not mutated (the same object is still
    the response body of ``GET /api/view/{kind}``, where the URL form is
    correct).  With no *host_root* the container path is used, which is right
    for a browser-mode download opened on a machine that mounts the project at
    the same place, and honest everywhere else -- Tetravox says which file it
    could not find.
    """

    def localise(url: Any) -> Any:
        container = _container_path_from_raw_url(url)
        if container is None:
            return url
        if host_root is None:
            return container
        return _to_host(container, container_root, host_root)

    out = copy.deepcopy(scene)
    for dataset in out.get("datasets", []):
        if not isinstance(dataset, dict):
            continue
        for key in ("path", "absPath"):
            if key in dataset:
                dataset[key] = localise(dataset[key])
        for sidecar in (dataset.get("sidecars") or {}).values():
            if not isinstance(sidecar, dict):
                continue
            for key in ("path", "absPath"):
                if key in sidecar:
                    sidecar[key] = localise(sidecar[key])
    return out

native_scene

native_scene(scene: dict[str, Any]) -> dict[str, Any]

Resolve scene data into host paths, staging bundled resources in the project.

Source code in tit/server/routes/viewers.py
def native_scene(scene: dict[str, Any]) -> dict[str, Any]:
    """Resolve scene data into host paths, staging bundled resources in the project."""
    from pathlib import Path
    import hashlib
    from tit.paths import get_path_manager, is_within
    from tit.server.host_path import host_project_dir
    from tit.server.routes.files import _resolve_jailed

    root = get_path_manager().project_dir or ""
    host = host_project_dir(root)
    out = copy.deepcopy(scene)
    datasets = out.get("datasets", [])
    if not isinstance(datasets, list):
        raise HTTPException(status_code=422, detail="Scene datasets must be a list")

    def resolve(value: Any) -> str:
        if not isinstance(value, str) or not value:
            raise HTTPException(
                status_code=422, detail="Scene file paths must be nonempty strings"
            )
        path = _container_path_from_raw_url(value) or value
        # A scene previously saved for this host can be exported again.
        if host:
            normal_host = host.replace("\\", "/").rstrip("/")
            normal_path = path.replace("\\", "/")
            if normal_path.startswith(normal_host + "/"):
                path = root.rstrip("/") + normal_path[len(normal_host) :]
        resolved = _resolve_jailed(path, roots=viewspec.raw_jail_roots())
        if not is_within(root, str(resolved)):
            # References shipped inside the image are not mounted on the host. Copy only
            # allowed reference files beside exported scenes, with collision-safe names.
            key = hashlib.sha256(str(resolved).encode()).hexdigest()[:16]
            target = checked_viewer_path(
                os.path.join(viewer_scene_dir(), "assets", key, resolved.name)
            )
            atomic_viewer_write(target, resolved.read_bytes())
            resolved = Path(target)
        return _to_host(str(resolved), root, host) if host else str(resolved)

    for dataset in datasets:
        if not isinstance(dataset, dict):
            raise HTTPException(
                status_code=422, detail="Scene datasets must be objects"
            )
        sources = [dataset]
        sidecars = dataset.get("sidecars") or {}
        if not isinstance(sidecars, dict):
            raise HTTPException(
                status_code=422, detail="Scene sidecars must be objects"
            )
        sources.extend(sidecars.values())
        for source in sources:
            if not isinstance(source, dict):
                raise HTTPException(
                    status_code=422, detail="Scene file references must be objects"
                )
            for key in ("path", "absPath"):
                if key in source:
                    source[key] = resolve(source[key])
    return out

export_scene

export_scene(body: dict[str, Any] | None = Body(None)) -> dict[str, Any]

Preserve camera/layers and write host-addressed files for the native viewer.

Source code in tit/server/routes/viewers.py
@router.post(
    "/api/view/export",
    summary="Write a native Tetravox scene from an explicit ViewSpec",
)
def export_scene(body: dict[str, Any] | None = Body(None)) -> dict[str, Any]:
    """Preserve camera/layers and write host-addressed files for the native viewer."""
    from tit.paths import get_path_manager
    from tit.server.host_path import host_project_dir
    from tit.server.routes.viewer_library import _slug, _MAX_SCENE_BYTES

    payload = body or {}
    scene = payload.get("scene")
    if not isinstance(scene, dict) or not scene.get("layers"):
        raise HTTPException(
            status_code=422, detail="A ViewSpec with at least one layer is required"
        )
    if len(json.dumps(scene).encode()) > _MAX_SCENE_BYTES:
        raise HTTPException(
            status_code=413, detail="Scene document is implausibly large"
        )
    localised = native_scene(scene)
    name = _slug(payload.get("name", "preview")) + _SCENE_SUFFIX
    target = checked_viewer_path(os.path.join(viewer_scene_dir(), name))
    atomic_viewer_write(target, json.dumps(localised, indent=1).encode())
    root = get_path_manager().project_dir or ""
    host = host_project_dir(root)
    return {
        "scene_path": target,
        "path": target,
        "host_path": _to_host(target, root, host) if host else None,
        "scene": localised,
    }

view_open

view_open(body: dict[str, Any] | None = None, request: Request = None) -> dict[str, Any]

{kind, subject, ...} -> {name, path, host_path, scene, view, files}.

Launches nothing, and never could: the server has no display (D3). One resolution produces two addressings of the same scene --

  • view -- every dataset an /api/files/raw/... URL. This is what the Viewer sub-page reads to describe and preview the selection, fetching those bytes back through this origin.
  • scene -- the same document with every path re-rooted onto the host, also written to <project>/code/ti-toolbox/viewer/<kind>.tetravox.json so the file can be opened by a desktop Tetravox or kept as a record of what was viewed.

They come from one build_view call on purpose. Two calls could resolve differently -- a job finishing between them is enough -- and then the list the page shows, the file on disk and the scene on screen would disagree about what is in the scene, with nothing to say which was right.

Source code in tit/server/routes/viewers.py
@router.post(
    "/api/view/open",
    summary="Resolve a scene once: the embed ViewSpec, and the scene file on disk",
    response_model=ViewerOpen,
)
def view_open(
    body: dict[str, Any] | None = None,
    # Annotated bare `Request` (never `Request | None`): FastAPI reads the annotation to know this
    # is the request object and not a body field, and a union is not something it can special-case.
    # The `= None` is for the direct calls the tests make, which have no app to hand.
    request: Request = None,  # type: ignore[assignment]
) -> dict[str, Any]:
    """``{kind, subject, ...}`` -> ``{name, path, host_path, scene, view, files}``.

    Launches nothing, and never could: the server has no display (D3).  One
    resolution produces two addressings of the same scene --

    * ``view``  -- every dataset an ``/api/files/raw/...`` URL.  This is what
      the Viewer sub-page reads to describe and preview the selection, fetching
      those bytes back through this origin.
    * ``scene`` -- the same document with every path re-rooted onto the host,
      also written to ``<project>/code/ti-toolbox/viewer/<kind>.tetravox.json``
      so the file can be opened by a desktop Tetravox or kept as a record of
      what was viewed.

    They come from one ``build_view`` call on purpose.  Two calls could resolve
    differently -- a job finishing between them is enough -- and then the list
    the page shows, the file on disk and the scene on screen would disagree
    about what is in the scene, with nothing to say which was right.
    """
    from tit.paths import get_path_manager
    from tit.server.host_path import host_project_dir

    payload = body or {}
    kind = str(payload.get("kind") or "")
    if kind not in _SCENE_NAMES:
        raise HTTPException(
            status_code=422,
            detail=f"kind must be one of {', '.join(sorted(_SCENE_NAMES))}",
        )
    extras = payload.get("extras")
    overrides = payload.get("overrides")
    # VM2: the Viewer page is one editable "what will open" list. When the
    # client sends that list it is *authoritative* -- these datasets, this
    # order -- and the view type contributes only each kept file's default
    # layer settings. Absent, nothing changes for any caller.
    files = payload.get("files")
    if isinstance(files, list):
        _refuse_a_scene_that_spans_two_subjects(files)
    spec = viewspec.build_view(
        kind,
        subject=payload.get("subject"),
        simulation=payload.get("simulation"),
        space=payload.get("space"),
        field=payload.get("field"),
        analysis=payload.get("analysis"),
        atlas=payload.get("atlas"),
        roi=payload.get("roi"),
        path=payload.get("path"),
        extras=list(extras) if isinstance(extras, list) else None,
        overrides=overrides if isinstance(overrides, dict) else None,
        files=list(files) if isinstance(files, list) else None,
    )
    if spec is None:
        raise HTTPException(
            status_code=404, detail="Unknown subject/simulation/analysis"
        )
    scene = spec.get("scene")
    if not isinstance(scene, dict):
        raise HTTPException(
            status_code=404, detail="The server built no scene for this selection"
        )

    container_root = get_path_manager().project_dir or ""
    host_root = host_project_dir(container_root)
    localised = (
        native_scene(scene)
        if not payload.get("dry_run")
        else localise_scene_paths(scene, container_root, host_root)
    )

    directory = viewer_scene_dir()
    name = f"{_SCENE_NAMES[kind]}{_SCENE_SUFFIX}"
    target = checked_viewer_path(os.path.join(directory, name))
    dry_run = bool(payload.get("dry_run"))
    if not dry_run:
        atomic_viewer_write(target, json.dumps(localised, indent=1).encode("utf-8"))

    return {
        "name": name,
        "path": target,
        "scene_path": target,
        "host_path": (
            _to_host(target, container_root, host_root) if host_root else None
        ),
        "scene": localised,
        # The in-origin addressing, from the SAME resolution: `scene` is what
        # `build_view` produced (every dataset an /api/files/raw URL) and
        # `localised` is that document re-rooted onto the host. Returning both
        # is what lets the Viewer sub-page and the written file describe the
        # same set of datasets, in the same order, without a second call that
        # could resolve differently in between.
        "view": scene,
        "files": _scene_files(spec, localised),
        "dry_run": dry_run,
    }

viewer_candidates

viewer_candidates(subject: Annotated[SubjectId | None, Query()] = None, simulation: Annotated[EntityName | None, Query()] = None, space: str | None = Query(None)) -> dict[str, Any]

A read: it opens nothing and writes nothing.

Only files a scene can actually use are listed (volumes and meshes), each with its size, because the point of the picker is to choose without guessing -- and one of these files is routinely 64 MB.

Source code in tit/server/routes/viewers.py
@router.get(
    "/api/viewer/candidates",
    summary='Every file this subject/simulation offers the Viewer\'s "+ Add…"',
)
def viewer_candidates(
    subject: Annotated[SubjectId | None, Query()] = None,
    simulation: Annotated[EntityName | None, Query()] = None,
    space: str | None = Query(None),
) -> dict[str, Any]:
    """A read: it opens nothing and writes nothing.

    Only files a scene can actually use are listed (volumes and meshes), each
    with its size, because the point of the picker is to choose without
    guessing -- and one of these files is routinely 64 MB.
    """
    return {"candidates": viewspec.viewer_candidates(subject, simulation, space)}