video-atlas-encode (Transform Node)
The Video Atlas Encode node reads the H.264 access units that Capture streams inside its protobuf payloads (StreamComponent, for example eye.left), composites every admitted stream into one atlas, and publishes an MSE fragment ladder plus a manifest that the VideoPlayback component plays in sync with the session clock.
The output is for the HEAT dashboard only. It is not a downloadable video file (see the runner page for why).
Where it goes in the DAG
- Parent: the capture input node that receives the protobuf uploads. The node decodes the video itself. It must not be chained after
hydrate-protobuf,tabular-queryor any node that has already turned the capture into CSV or JSON, because the video is not in those artefacts. - Children: an Arbex script that publishes the manifest as a
valuechannel (standalone, or wrapped into ajson-mergefor an existing dashboard), then the dimension. Children must read latest only (inputMode: "latest"). - Configuration must set
retention: { skipArchive: true }. Archive collapses any non-Input node to a single output, which would delete every fragment but one.
How it works
One run, end to end
The publish order is deliberate: the manifest is written last, so a reader always sees a manifest whose fragments already exist.
Window purity and the lead-in
Each window is rendered from exactly the access units in its gather range, with decoders created fresh for that window. The lead-in exists so a keyframe precedes the window start. Nothing carries over from the previous window, which is what makes a window’s bytes a pure function of its own input and lets a retroactive upload rebuild one window without touching its neighbours.
A late upload
- Window addressed. The session is cut into fixed windows (
windowMs, default 8 seconds) from a pinned epoch. Each run scans only the capture blobs above its watermark, records which windows each blob touches, and rebuilds exactly those windows, wherever they fall in the session. Arrival order does not matter: a late upload that belongs to minute 3 rebuilds the minute 3 windows in place. - Pure windows. A window’s bytes are a function of the access units inside its gather range (the window plus a short lead-in). Each window gets fresh decoders, so it never depends on which windows were rendered before it. An unchanged window is detected without downloading anything and is skipped.
- Contiguous timeline. Every window between the first and last carries media. A seat with no data in a window is black for that window; a window with no data at all is a cached blank segment and flagged
blankin the manifest. The player therefore never hits a hole in the buffer. - Journal output. Fragments are written to deterministic keys and replaced in place. The manifest is the node’s current output and is rewritten each run. Superseded init segments and manifests are marked for retention. A small state artefact carries the window index between runs.
- Geometry generations. When the set of admitted streams, the encoder settings or the presentation epoch change, the manifest’s
geometryGenerationincrements and the player resets its buffer. Every window is then re-encoded once, in place, with no orphaned rows.
Configuration schema
| Property | Type | Default | Description |
|---|---|---|---|
dataSourceName | string | runner default store | Object store for fragments, manifest and state. |
admittedStreams | string[] | ["eye.left"] | Canonical StreamComponent names to composite. Left eye only by default: the right eye is nearly the same view and would double decode cost. |
maxStreams | integer | 8 | Cap on tiles per atlas. The only knob that touches the dominant cost, which is linear in stream count. |
minBytesPerFragment | integer | 200 | Admission threshold on median fragment size. A powered but unworn headset emits about 85-byte null frames and would otherwise claim a tile and shrink every other seat. |
windowMs | integer | 8000 | Fragment duration. Finer than the processing lag buys nothing and doubles every per-window cost. |
fps | integer | 15 | Atlas frame rate, downsampled from the roughly 75 fps source. Must divide 90000 (15, 25, 30, 45, 60 are safe) so the presentation timestamps stay on a drift-free grid. |
crf | integer | 26 | Capped-CRF quality target. Not constant bitrate: static views compress to almost nothing. Lower is better quality and larger fragments. |
maxrateKbps | integer | 2000 | VBV ceiling in kbps; bounds the worst case without paying it on quiet windows. |
bufsizeKbps | integer | 4000 | VBV buffer size in kbps. |
encoderName | string | libx264 | PyAV encoder id. Changing it changes the emitted parameter sets and forces a new geometry generation. |
encoderThreads | integer | 4 | Pinned, never auto. x264 is deterministic only for a fixed thread count, so an auto value would make the bytes depend on the host CPU count and silently break the unchanged-window gate on a mixed cluster. |
leadInMs | integer | 1000 | How far back a window reaches for decoder lead-in. It must contain a preceding keyframe; measured keyframe interval is 0.3 to 0.6 seconds, so 1 second gives about twice the margin. Must not exceed windowMs. |
windowsPerChunk | integer | 8 | Windows rebuilt per fetch batch. Neighbouring windows share blobs, so a contiguous chunk fetches each blob once. Raising it trades memory for fewer fetches. |
maxWindowsPerRun | integer | 0 | Cap on windows rebuilt per run; 0 is unbounded. Unbounded on purpose: HEAT re-runs a node on new parent outputs, not on self-reported backlog, so a bounded run on a finished session would strand the remainder. |
maxPreEpochMs | integer | 86400000 | Clock-anomaly guard. Data more than this far before the pinned epoch is dropped and counted rather than implying millions of black windows. |
retention | object | Must set skipArchive: true. | |
reorderMs | integer | Deprecated and ignored; kept so older templates still validate. |
Tuning
- Frame rate.
fpsonly affects encode and fragment size; decode cost is fixed by the source. Keep it a divisor of 90000. Going from 15 to 30 roughly doubles encode time and fragment bytes for a smoother picture. - Quality versus size. Move
crffirst (each step of about 6 halves or doubles size), thenmaxrateKbpsto bound bursts. Both change the encoder fingerprint, so a change re-encodes the whole session once. - Which seats. Widen
admittedStreams(for example asceneordisplay.<n>stream) or lowerminBytesPerFragmentwhen a seat is missing. Every extra stream is a linear decode cost. - Throughput on a live session. Four decode-bound cores per task keep four origins ahead of real time. Give the runner a CPU request near its limit; a low request under a high limit throttles under node contention.
- Check
diagnostics.countersin the manifest for lead-in misses, dropped pre-epoch data and blobs read versus skipped when a window looks wrong.
Memory
- Decoding is tick-synchronous: per output tick the node decodes only the frames that tick needs from each stream, scales them into their tiles, and releases them. Peak memory is bounded by the atlas (1920 by 1088 cap) plus one fetch batch of blobs, not by sources times seats. Measured on the full 24th August export: about 3.8 GB before this design, 220 to 250 MB after.
windowsPerChunkis the memory knob on the fetch side;maxStreamson the decode side.- The runner declares
memoryLimit: 4096Mi.
Playback path
The player follows the realm clock, prefetches windows ahead of it, and evicts far-away windows, so the browser holds a bounded number of fragments regardless of session length.
Geometry generations
Output
- Per window: one fragmented-MP4 media segment at a deterministic key, registered as an output with
windowInputHash,startMs,endMsandblankon its configuration. - One init segment per geometry generation.
- A state artefact (
atlas-state) with the window index and stream statistics. - The manifest (current output):
formatVersion,epochMs,indexEpochMs,windowMs,fps,codecMime,avccMd5,geometryGeneration,geometry.regions[](one tile per seat with source and destination rects),windows[](index,startMs,endMs,outputId,inputHash,blank, per-stream residency) anddiagnostics. The field-level contract is on the VideoPlayback page.
Example configuration
{
"dataSourceName": "HEAT Managed Object Store",
"admittedStreams": ["eye.left"],
"maxStreams": 8,
"minBytesPerFragment": 200,
"windowMs": 8000,
"leadInMs": 1000,
"fps": 15,
"crf": 26,
"maxrateKbps": 2000,
"bufsizeKbps": 4000,
"encoderName": "libx264",
"encoderThreads": 4,
"windowsPerChunk": 8,
"maxWindowsPerRun": 0,
"retention": { "skipArchive": true }
}