RheoclesREE-oh-kleez

Takes and the manifest

The manifest is the take. Everything downstream reads manifest.json; nothing guesses from a folder listing.

A take is a folder under the output root with one file per stream and a manifest.json that describes them. The manifest is written when the take is created, rewritten on every state change, served live by GET /takes/{id} while recording, and pushed as a take event each time it changes. It is the only thing a client needs to read.

Two rules

Take ids are the handle; paths are the answer. A client holds 20260912T040433-fd9q and asks Rheocles what that means. It never assembles a path from a naming convention.

Every path is relative. The take folder (destination) is relative to the output root, which GET / reports as the one absolute path in the API; each stream’s path is relative to the take folder. A client that stores them as given survives the root moving, the volume being renamed, and the take being copied to another machine; a client that stored absolutes did not.

Written atomically

The manifest is rewritten as a temp file in the take folder and then renamed over the old one, on every state change: create, start, stop, and any failure — and, from step 6, each join, leave and marker. At any instant on disk it is either the previous complete version or the next. There is no moment at which a reader can see half a manifest.

That is what makes a crash survivable rather than recoverable: a process that dies mid-take leaves fragmented MOVs that play to their last fragment and a manifest that says recording, with every stream’s started. Nothing has to be reconstructed.

The states

state meaning
created paths reserved, manifest written, nothing recording
recording the cue has happened; writers are live
complete stopped cleanly; every file finalised
incomplete stopped by a failure, or superseded before it started; reason says which; every file is playable as far as it got

A manifest that still says recording when nothing is running is a crash, and that is what it should say. A take created and never started is superseded by the next create and reads incomplete with reason superseded before start.

The shape

takes/2026-09-11/210433-episode-12/manifest.json
{
"id": "20260912T040433-fd9q",
"name": "Episode 12",
"state": "complete",
"created": "2026-09-12T04:04:33.235Z",
"started": "2026-09-12T04:04:41.004Z",
"stopped": "2026-09-12T04:17:04.501Z",
"outputRoot": "/Users/len/Movies/Rheocles",
"destination": "takes/2026-09-11/210433-episode-12",
"version": "0.1.0",
"machine": { "hostname": "studio.local", "machineId": "CD3B7EE5-…" },
"streams": [
{
"id": "display:F65F9C53-…", "kind": "display",
"name": "Prompter XL", "model": "vendor 9353 model 6433",
"path": "prompter-xl.mov", "codec": "hevc",
"format": { "video": { "width": 1920, "height": 1080, "maxFrameRate": 60 } },
"started": "2026-09-12T04:04:41.004Z",
"stopped": "2026-09-12T04:17:04.501Z",
"timecode": "21:04:41:00",
"framesWritten": 0,
"events": [ { "t": 0, "type": "join" }, { "t": 743.497, "type": "leave" } ]
},
{
"id": "microphone:…", "kind": "microphone",
"name": "Scarlett 2i2 USB", "model": "Scarlett 2i2 USB:1235:8210",
"path": "scarlett-2i2-usb.wav", "codec": "pcm_s24le",
"format": { "audio": { "sampleRate": 48000, "channels": 2 } },
"started": "2026-09-12T04:04:41.004Z",
"stopped": "2026-09-12T04:17:04.501Z",
"timecode": "21:04:41:00",
"framesWritten": 0,
"events": [ { "t": 0, "type": "join" }, { "t": 743.497, "type": "leave" } ]
}
],
"markers": [],
"settings": { "codec": "hevc", "expectedDuration": 3600 }
}

The shape is pinned by docs/openapi.yaml (Manifest, ManifestStream) and rendered field by field on the reference. Where this page and the reference disagree, the reference is right.

The take

field is
id the handle: the UTC create time to the second and a short random suffix
name what you called it, if you did
state one of the four above
reason only when incomplete: what happened, listing every stream’s error
created started stopped host times, UTC ISO 8601 with milliseconds; started is the cue
outputRoot absolute, as it was at create — so a moved root does not orphan the take
destination the take folder, relative to the output root
version the Rheocles that wrote it
machine hostname and machineId (the kernel host UUID), for two-box setups
settings codec and expectedDuration in effect

Each stream

field is
id kind name model the three identity fields plus kind; all recorded because ids change across reconnects
path relative to the take folder
codec hevc, prores, or pcm_s24le
format video or audio, as recorded — the device’s active format at the cue
started stopped this stream’s own host times — a late joiner’s started is later than the take’s
timecode the time-of-day timecode of the first written frame, as stamped into the file
framesWritten frames written; 0 until step 5
drift frames × frame duration versus host elapsed, in seconds — absent until measured, never zero by default
events[] { t, type }: join and leave, t in seconds from the cue to the millisecond
error only when something went wrong for this stream — not armed at the cue, a write failure

markers[]

{ t, label }, t in seconds from the cue. Rheocles stamps t; the client owns label, and Rheocles never interprets it. See Markers (step 6, planned).

Reading it

Terminal window
curl -s -H "$H" localhost:7447/takes/20260912T040433-fd9q \
| jq '.streams[] | {path, timecode, framesWritten}'
GET /takes — recent takes, newest first
[ { "id": "20260912T040433-fd9q", "name": "Episode 12", "state": "complete",
"created": "2026-09-12T04:04:33.235Z",
"destination": "takes/2026-09-11/210433-episode-12", "streams": 5 } ]

GET /takes/{id} serves the manifest live while recording and from disk afterwards. GET /takes lists the active take, then this process’s finished ones, then whatever the output root holds, up to fifty. The file is the source; the API is a reader.

The same manifest arrives as a take event on /events and the WebSocket on every state change, so a client that is listening never has to poll for state.