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
{ "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
curl -s -H "$H" localhost:7447/takes/20260912T040433-fd9q \ | jq '.streams[] | {path, timecode, framesWritten}'[ { "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.