caliper.export.v1¶
Service id caliper.export.v1 — the terminal sink of the publishing track
(PUBLISHING.md §3, Rung E). Export re-renders a submitted draw list
offscreen at any requested resolution and writes a pixel-exact PNG next to a
JSON provenance sidecar: 4K figures and numbered frame sequences from a live
session, deterministic and stamped. It composes pieces that already ship and are
already byte-exact tested — it adds no new render code. This page embeds the
header verbatim; the docs build fails if the embedded file moves.
The boundary is deliberate and stated once in PUBLISHING.md: Caliper exports
pixel-exact view images + a sidecar, and does not compose figures. Axes,
captions, typography, and subfigure layout belong to the paper toolchain
(matplotlib / TikZ) that consumes these PNGs. The instrument's claim — the
pixels are the tensors — survives export bit-for-bit; a typography engine would
add scope with zero claim.
Platform status (honest, stated once)
macOS / Metal: run-proven. The E1 battery is green live on Metal (a
known FLAT quad exported and decoded back to the CPU reference pixels;
double-export byte-identity; refusal purity; sidecar golden; a finalized
sequence). The E2 exemplars produced real artifacts from a live session — a
3840×2160 TwinScope figure + sidecar (backend=metal) and a 300-frame
twin_scope sequence assembled into a 10 s clip via the documented ffmpeg
line. Windows / Vulkan: run-proven. The full battery ran live on
the Vulkan box (RTX 500 Ada, Windows 11) — 14 cases, 0 skipped: the same
decoded-quad byte-exactness, double-export byte-identity, and refusal
purity (sentinel byte-identical after a refused export), plus the
NTFS-specific claims verified on the box, not from docs:
std::filesystem::rename atomically replaces a pre-existing target
(MoveFileExW(MOVEFILE_REPLACE_EXISTING) semantics confirmed); a target
held open without FILE_SHARE_DELETE makes the rename fail cleanly
(refusal, temp removed, original byte-identical); and a sidecar-write
failure rolls the PNG back rather than orphaning it. The E2 exemplars
reproduced on Vulkan: a 3840×2160 figure + sidecar
(backend=vulkan platform=windows) and a finalized 300-frame sequence
from each of twin_scope and mesh_scope, plus a deliberate mid-record kill
whose sequence.json finalized with the honest partial count. When the geometry
primitives cap is absent (headless, or a host without the renderer path) the
export cap bit is unset and every entry point is inert — no file is ever
written. This is the degradation ladder, not a bug.
#pragma once
/* caliper.export.v1 — the terminal sink: pixel-exact view PNGs + a provenance
* sidecar (PUBLISHING.md §3, Rung E). Export re-renders a submitted draw list
* OFFSCREEN at any requested resolution through pieces that already ship and
* are already byte-exact tested — geometry.v1_3's create_view_ex + the existing
* draw_primitives host path + the promoted debug_readback_rgba8 — then encodes
* the tightly-packed RGBA8 to PNG (stb_image_write, vendored) and writes a JSON
* sidecar next to it. No new render code, no retained draw state; the pixels
* that leave are the tensors, bit-for-bit.
*
* Immediate-mode, mirrors the geometry.v1_3 draw signature (v1_3 records,
* stride-widened exactly like geometry.v1_3): the caller hands the SAME arrays
* it draws with. Video assembly stays OUTSIDE the ABI — the wiki documents the
* one ffmpeg line over the numbered frames a sequence writes.
*
* Refusal purity extends to the FILESYSTEM: a refused export (missing gate, bad
* w/h, unwritable path, readback failure, a draw the geometry gate battery
* rejects) returns 0 and leaves the disk EXACTLY as it was — no file created,
* no pre-existing file truncated (the PNG is written to a temp name and
* atomically renamed; the sidecar follows only after the PNG lands).
*
* FRAME-THREAD ONLY: every entry point composes the frame-thread-owned
* renderer (offscreen view create → the geometry draw path → readback), so it
* carries the same rule as caliper.geometry.v1 — call from the caliper_core_
* frame() thread only. Calling from any other thread races the renderer; the
* sequence mutex serializes bookkeeping, it does NOT make export any-thread.
*
* Determinism: same draws + camera + clear + (w,h) on the SAME backend →
* BYTE-IDENTICAL PNG across calls (the readback is deterministic, the byte-exact
* matrix is built on it, stb PNG encode is deterministic). Cross-backend is NOT
* byte-identical (Lambert ±2 LSB carries over) — the sidecar's `backend` field
* makes that honest.
*
* IMMUTABLE once published; additive growth lands as export.v1_1. */
#include <stdint.h>
#include <caliper/services/geometry_v1_3.h> /* CaliperGeomCamera, CaliperGeomDrawV1_3 */
#define CALIPER_EXPORT_V1 "caliper.export.v1"
/* caps() bit 0: view_png / sequences are live. Set IFF the geometry primitives
* cap (CALIPER_GEOM_CAP_PRIMITIVES) is live on this host — export is a veneer
* over that path, so it degrades in lockstep with it (absent renderer / headless
* → 0, every entry point inert, no file ever written). */
#define CALIPER_EXPORT_CAP_VIEW_PNG (1u << 0)
/* Largest accepted dimension (inclusive). w or h of 0 or > this is refused. */
#define CALIPER_EXPORT_MAX_DIM 16384u
#ifdef __cplusplus
extern "C" {
#endif
typedef struct CaliperExportV1 {
uint32_t struct_size;
uint32_t (*caps)(void);
/* Render `draws` (v1_3 records, stride-widened exactly like geometry.v1_3)
* into a fresh offscreen (w,h) target and write:
* <path> — PNG, tightly-packed RGBA8, top-down (row 0 = top).
* <path>.json — the provenance sidecar (PUBLISHING.md §3), written
* atomically AFTER the PNG succeeds.
* Returns 1 on success; 0 on refusal. On ANY refusal — no gate/renderer,
* cam==0, w/h 0 or > CALIPER_EXPORT_MAX_DIM, unwritable path, readback
* failure, or a draw the geometry gate battery rejects — NO file is created
* and NO pre-existing file is truncated. Same gate battery, same reason
* strings, same atomicity as draw_primitives (it IS draw_primitives).
* state_json is nullable and copied VERBATIM into the sidecar's "state". */
uint32_t (*view_png)(const char* path, uint32_t w, uint32_t h,
const CaliperGeomCamera* cam,
const CaliperGeomDrawV1_3* draws,
uint32_t draw_count, uint32_t draw_stride,
uint32_t clear_rgba,
const char* state_json);
/* Frame sequences: same per-frame contract as view_png, frames written as
* <dir>/frame_%06u.png, plus ONE sidecar for the whole sequence written at
* end (<dir>/sequence.json, carrying frame_count). begin_sequence returns a
* non-zero handle (0 = refusal); frame() renders one numbered frame (1 ok /
* 0 refusal); end_sequence() finalizes + drops the handle. ONE sequence live
* at a time (v0); begin while one is active is refused. */
uint64_t (*begin_sequence)(const char* dir, uint32_t w, uint32_t h,
const char* state_json);
uint32_t (*frame)(uint64_t seq, const CaliperGeomCamera* cam,
const CaliperGeomDrawV1_3* draws, uint32_t draw_count,
uint32_t draw_stride, uint32_t clear_rgba);
void (*end_sequence)(uint64_t seq);
void* reserved0;
} CaliperExportV1;
#ifdef __cplusplus
}
/* --- ABI freeze: sizes + offsets pinned (only C types cross the boundary) --- */
static_assert(offsetof(CaliperExportV1, struct_size) == 0);
static_assert(offsetof(CaliperExportV1, caps) == sizeof(void*));
static_assert(offsetof(CaliperExportV1, view_png) ==
offsetof(CaliperExportV1, caps) + sizeof(void*));
static_assert(offsetof(CaliperExportV1, begin_sequence) ==
offsetof(CaliperExportV1, view_png) + sizeof(void*));
static_assert(offsetof(CaliperExportV1, frame) ==
offsetof(CaliperExportV1, begin_sequence) + sizeof(void*));
static_assert(offsetof(CaliperExportV1, end_sequence) ==
offsetof(CaliperExportV1, frame) + sizeof(void*));
static_assert(offsetof(CaliperExportV1, reserved0) ==
offsetof(CaliperExportV1, end_sequence) + sizeof(void*));
static_assert(sizeof(CaliperExportV1) ==
offsetof(CaliperExportV1, reserved0) + sizeof(void*),
"CaliperExportV1 vtable layout is frozen");
#endif
The composition (why this is small)¶
Export is a veneer over the geometry draw path, not a second renderer. Each call:
create_view_ex(w, h, CALIPER_GEOM_VIEW_DEPTH)— a fresh offscreen target at the requested resolution (the geometry pipeline is resolution-independent, so a 4K figure from a live session costs only pixels);- the existing
draw_primitivespath over the submitted v1_3 draws — every gate, every reason string, every byte-exact behavior reused verbatim (it isdraw_primitives); debug_readback_rgba8— the tightly-packed RGBA8 readback implemented on both backends since the v1_3 pass, promoted from test-only to the export path;- PNG encode (
stb_image_write, vendored and pinned) + the sidecar JSON; - destroy the temp view.
No retained draw state. The draw arrays are the same immediate-mode arrays
the applet draws with (v1_3 records, stride-widened exactly like
caliper.geometry.v1_3 — the caliper::Export sugar mirrors
caliper::Geometry's v1.1/v1.2/v1.3 overload set and passes sizeof for you).
Because export re-renders through the geometry cap, it degrades in lockstep
with it: caps() bit 0 (CALIPER_EXPORT_CAP_VIEW_PNG) is set iff
CALIPER_GEOM_CAP_PRIMITIVES is live on this host.
Frame-thread only¶
Every entry point composes the frame-thread-owned renderer, so export
carries the same rule as caliper.geometry.v1: call it from
the caliper_core_frame() thread only. Calling from any other thread races the
renderer's texture/geometry maps. The sequence bookkeeping (one sequence live at
a time in v0) is guarded by a mutex, but that mutex serializes bookkeeping
only — it does not make export safe against a renderer running on another
thread. The E2 exemplars capture inline on the frame thread for exactly this
reason: they snapshot the worker-published slot under the usual mutex, then
export from the same arrays they draw, on the same thread they draw on. (A truly
background export would need a serialization mutex around the renderer's shared
maps — a host change beyond this rung.)
The sidecar — a figure without it is a screenshot¶
Every export writes <path>.json next to the PNG (for a sequence, one
sequence.json for the whole run). It carries the provenance that makes a PNG
research-grade rather than a screenshot:
{
"caliper": { "version": "...", "git_commit": "...", "backend": "metal", "platform": "macos" },
"timestamp_utc": "2026-07-12T17:13:37Z",
"width": 3840, "height": 2160, "clear_rgba": 4278519050,
"camera": { "view": [ ...16... ], "proj": [ ...16... ] },
"draw_count": 3,
"colormaps": [1],
"state": { "step": 1200, "seed": 7 }
}
git_commit is compiled in at configure time (the same mechanism that plumbs
the version string). state is the caller's own JSON, copied verbatim (step,
seed, hparams — whatever the applet chooses) or null. A sequence sidecar adds
frame_count. This is a house invariant: a figure without its sidecar is a
screenshot; Caliper exports figures.
Refusal purity extends to the filesystem¶
A refused export returns 0 and leaves the disk exactly as it was — no file
created, no pre-existing file truncated. The PNG is written to a temp name and
atomically renamed; the sidecar follows only after the PNG lands, and if the
sidecar write fails the PNG is rolled back rather than orphaned (a PNG without
its sidecar would violate the invariant above). Export refuses for the same
reasons draw_primitives does — missing gate/renderer, cam == NULL, w/h of
0 or > CALIPER_EXPORT_MAX_DIM (16384), an unwritable path, a readback
failure, or any draw the geometry gate battery rejects — with the same reason
strings. (The atomic-rename-over-existing-file guarantee is verified on POSIX
and on NTFS — including the held-open-handle refusal and the
sidecar-failure PNG rollback; see the platform status above.)
Determinism contract, and its scope¶
Same draws + camera + clear + (w, h) on the same backend → a
byte-identical PNG across calls. The readback is deterministic (the entire
byte-exact test matrix is built on it) and stb_image_write is deterministic, so
this is pinned by a test that exports twice and memcmps the files. Cross-
backend is not byte-identical — the Lambert ±2-LSB tolerance that the
geometry contract carries survives into the pixels — and the sidecar's backend
field makes that honest rather than silent. Determinism is the byte-exact
discipline extended one step to disk, scoped exactly where the instrument can
back it.
Video assembly stays outside the ABI¶
Export writes numbered frames (<dir>/frame_%06u.png) and one sequence sidecar;
it does not encode video (no in-process codecs). Assemble the frames with one
documented ffmpeg line:
ffmpeg -framerate 30 -i frame_%06d.png -pix_fmt yuv420p out.mp4
This is the exact line the E2 twin_scope run used to turn a 300-frame sequence into its 10 s clip.
What is not exportable — the ImPlot / metrics split¶
ImPlot chart panes (pulse_scope, metrics dashboards) are ImGui chrome, not
exportable views — they are not tensors re-rendered offscreen, so export does
not touch them. Their paper path is the one that already ships: the numbers go to
SQL via caliper.metrics.v1_1 and into matplotlib
downstream. Export is for the 3-D views whose pixels are the tensors; charts
are for the metrics surface. (PUBLISHING.md §3, the honest split.)
Worked shape — export a figure and record a clip¶
Modelled on applets/mesh_scope and applets/twin_scope (the E2 exemplars).
The applet already draws its 3-D view every frame from worker-published,
mutex-snapshotted slot tensors; export reuses those same arrays, on the frame
thread:
caliper::Export xport(host); // falsy when the host vends no export path
if (xport.has_view_png()) { // caps bit 0, tied to geometry primitives
// A 4K still: the SAME draws[] the applet just rendered, at a bigger view.
xport.view_png("figure.png", 3840, 2160, &cam,
draws, draw_count, /*clear=*/0xff05050au,
/*state_json=*/R"({"step":1200,"seed":7})"); // + figure.png.json
// A clip: begin → one frame() per captured frame → end (one sequence.json).
uint64_t seq = xport.begin_sequence("clip_dir", 1280, 720, state_json);
for (int i = 0; i < 300; ++i)
xport.frame(seq, &cam_i, draws_i, draw_count_i, clear); // frame_000000.png …
xport.end_sequence(seq); // + clip_dir/sequence.json
}
Then assemble the clip with the ffmpeg line above. Because capture re-renders and reads back each frame inline, a long high-resolution record paces the UI while it runs — the exemplars show a live "Recording N/300" counter; the produced clip is correct real-time regardless.
See also: caliper.geometry.v1 for the draw path and camera
vocabulary export composes, and caliper.metrics.v1_1 for the
chart/metrics paper path export deliberately does not cover.