Recording a take
A take is created, cued, and stopped. Creating reserves the paths in memory; the folder and manifest are written at start, before a single frame; starting is the cue.
A take is three calls. The first does everything that can be done before recording — snapshots the armed set, resolves the destination, pre-flights the disk, reserves every file name — so that by the time the cue comes there is nothing left to decide. It writes nothing to disk: the folder and the manifest are written at the cue, immediately before the first writer opens (still before frame one), so a take prepared but never started never touches disk at all.
Create, start, stop
| call | does | state after |
|---|---|---|
POST /takes |
snapshot the armed set, reserve paths, pre-flight the disk (in memory — nothing on disk yet) | created |
POST /takes/{id}/start |
the cue: every stream’s writer starts on frames that are already flowing | recording |
POST /takes/{id}/stop |
finalise every writer and the manifest | complete |
→ { "name": "Episode 12", "expectedDuration": 3600 }← { "take": { "id": "20260912T040433-fd9q", "name": "Episode 12", "state": "created", "created": "2026-09-12T04:04:33.235Z", "outputRoot": "/Users/len/Movies/Rheocles", "destination": "takes/2026-09-11/210433-episode-12", "streams": [ { "id": "display:F65F9C53-…", "kind": "display", "name": "Prompter XL", "path": "prompter-xl.mov", "codec": "hevc", "framesWritten": 0, "events": [] }, { "id": "microphone:…", "kind": "microphone", "name": "Scarlett 2i2 USB", "path": "scarlett-2i2-usb.wav", "codec": "pcm_s24le", "framesWritten": 0, "events": [] } ], "markers": [], "settings": { "codec": "hevc", "expectedDuration": 3600 } }, "warnings": [] }The answer is the manifest, with state: created. Nothing is recording
after create. This is the call Pteroprompter makes to learn where the
footage will live before frame one, so the cue log can name files that do
not exist yet. A created take can sit as long as you like; start is a
separate, deliberate call.
POST /record is create followed by start in one call, with the same body
and the same answer. It is what the popover’s Record button does. Use it
when you do not need the paths ahead of time.
Where the files go
Every path is relative: the take folder to the output root, each file to the take folder. Join them if you need an absolute path; store them as given.
| default | override | |
|---|---|---|
| take folder | takes/<yyyy-MM-dd>/<HHmmss>[-<name slug>], local date and time |
destination, relative to the output root |
| each file | the stream’s name, slugged — prompter-xl.mov, scarlett-2i2-usb.wav; windows are window-<bundle id>.mov; -2, -3 on a collision |
files, a map of stream id → file name |
The same destination twice is a 409 conflict, unless overwrite: true. A destination is taken if the folder exists and is not empty.
Rheocles never silently suffixes a path; a client that named a path meant
it, and a file that quietly became -2 is how a take goes missing.
Disk pre-flight
Create refuses if there is not room. The armed set’s bitrates ×
expectedDuration (seconds; default 1800) gives an estimate — rough until
the codec tiers are measured — and:
- free space short of the estimate →
507 insufficient_storage, nothing created; - free space under twice the estimate → created, with a line in
warnings.
If a write fails mid-take — disk full, volume unmounted — the take stops
cleanly, every file is finalised as far as it got, and the manifest is
marked incomplete with the reason. Nothing is deleted.
One at a time
While a take is recording, POST /takes and POST /record answer
409 take_active, naming it. Stop it first.
A take that was created but never started recorded nothing, and it lived in
memory only — nothing on disk. Superseded by the next create (or still
created when the daemon stops), it simply disappears: a final take event
marks it incomplete, reason superseded before start (or
daemon stopped before start), with removed: true so a client drops it, and
no folder is ever left behind. A paired recorder that re-prepares its take on
every arm change leaves no trace. Concurrent takes with disjoint stream sets — a
background screen recording spanning several camera takes — are conceivable and
deliberately
deferred.
POST /takes with no armed streams is a 400.
What lands on disk
Rheocles/ ← output root, one field on GET /└── takes/2026-09-11/210433-episode-12/ ← destination ├── manifest.json ├── prompter-xl.mov HEVC, native resolution and rate, timecode track ├── elgato-4k-x.mov ├── scarlett-2i2-usb.wav Broadcast Wave, 48 kHz 24-bit, bext time reference ├── system-audio.wav └── combined.mov only with combine: the one video plus every audio stream, muxed, not re-encoded| kind | container | codec |
carries its time as |
|---|---|---|---|
| camera, display, window | QuickTime .mov, a fragment every second |
hevc or prores (ProRes 422), one setting for the whole take — codec on create, else the daemon’s settings.codec |
a tmcd track, local time of day, one sample per frame |
| microphone, system audio | Broadcast Wave .wav, sizes patched every second |
pcm_s24le, 48 kHz |
bext TimeReference, samples since local midnight |
Video files carry no audio track; audio is always its own file. Streams
record at the format the device is delivering when armed — active on the
stream — and per-stream codec, resolution and rate are deliberately not
exposed.
The HEVC tier
HEVC is the default because at the tier Rheocles uses it is visually
indistinguishable from ProRes 422 on the hardest content it records. That is
a measurement, not a claim; the method and every number are in
docs/CAPTURE.md.
The tier is 0.15 bits per pixel per frame, so it is independent of frame rate and resolution. What that comes to, and what the encoder actually produced at that tier on the measured source:
| nominal at 0.15 bpp | measured at 0.15 bpp | |
|---|---|---|
| 1080p, 30 fps | 9.3 Mbps | 8.3 Mbps · SSIM 0.9956 · XPSNR 47.0 dB |
| 1080p, 60 fps | 18.7 Mbps | |
| 4K, 30 fps | 37.3 Mbps | 30.0 Mbps · SSIM 0.9977 · XPSNR 52.0 dB |
| 4K, 60 fps | 74.6 Mbps |
The method. Two real captures, recorded by Rheocles itself as ProRes 422
from a BenQ PD3220U at its native 3840×2160: a terminal scrolling colour-coded
hex over a photographic wallpaper (continuous motion plus fine high-contrast
text — harder than camera footage), and a static desktop with a browser full
of JSON. Each was re-encoded with hevc_videotoolbox — the same VideoToolbox
encoder AVAssetWriter uses — across a sweep from 0.04 to 0.30 bpp, and
compared to the ProRes reference by XPSNR (perceptually weighted PSNR; ~45 dB
is transparent) and SSIM (> 0.995 indistinguishable, > 0.997 excellent). The
1080p rows are the same captures downscaled, not independently shot footage.
What it found. 4K reaches transparency early — SSIM 0.997 / XPSNR 50 by 0.12 bpp on the motion source — and the curves flatten above 0.15, where another 7 Mbps buys +0.0004 SSIM. 1080p wants slightly more per pixel and knees at about 0.15. A 100 % crop of the motion source at 0.10 bpp is indistinguishable from ProRes by eye. So 0.15 bpp is kept as the single default with headroom; 4K at 0.12 bpp (about 20 % smaller, still transparent) is a refinement the data would justify if file size ever matters.
The disk pre-flight estimates from the same figures: HEVC at 0.15 bpp, ProRes 422 at about 2.4 bpp (1080p30 ≈ 147 Mbps).
What the counters mean
framesWritten is frames for video and samples for audio, actually written.
framesDropped (video, absent when zero) counts frames the encoder was not
ready for under load — the timeline stays correct, the frame is simply not
there. drift is on Timecode and sync.
A screen delivers a frame only when its content changes, so a prompter holding a page would otherwise produce a file with a hole in it. A ~1 fps keepalive re-feeds the last frame, so a static screen still spans the take and still flushes fragments.
A stream that delivered no frames at all while armed — a camera with the
lid closed — writes no file, and the take finishes incomplete with that
stream’s error reading no frames arrived. Its framesSeen in
GET /streams sat at 0 the whole time it was armed, which is the signal to
watch before the cue.
Crash safety
Every MOV is written with a one-second movie fragment interval, so a process
that dies mid-take leaves a file that plays up to the last fragment; every
WAV has its RIFF sizes patched every second, so it reads. The manifest is
rewritten atomically — temp file, then rename — on every state change, so it
is never half-written; and because a full disk is the one failure that could
stop the final manifest landing, every take folder holds a 64 KB reserve
that is freed for exactly that write. This is proven, not hoped: kill -9 mid-take
recovers every MOV, leaves every WAV readable, and the manifest says
recording because that is the truth. A dead process leaves playable files
and a truthful manifest; that is a design constraint, not a recovery
feature.
Broadcast Wave stops at 4 GB. At 48 kHz, 24-bit, that is 144 kB a second per channel: about 8.3 hours mono, about 4.1 hours stereo. It is the format’s limit and Rheocles does not work around it.
One file, when there is one camera
One video and one microphone is the Loom shape, and people who record that
shape want one file. Set combine: true on POST /takes or POST /record
(or leave it off and let settings.combine decide — it defaults to
false) and when the take stops, after every per-stream file is final, the
daemon writes combined.mov beside them.
What it is. A passthrough mux. The video track is copied as-is, with
its tmcd track, so the single file carries the same time of day as the
per-stream one; every audio stream becomes its own track in the same file,
each one enabled, so QuickTime plays them together and an NLE still has
them apart. There is no re-encode — the bits are the ones the encoder
already wrote — and there is no mixdown: two microphones are two tracks,
not one. It is AVMutableComposition and a passthrough export, not ffmpeg.
When it appears. The combine option is allowed when the armed set holds
at most one video stream. Two displays, or a camera and a screen, is a
400 combine_requires_single_video — a passthrough mux cannot put two
pictures in one frame, and Rheocles does not compose. Zero video streams
is allowed: an audio-only take combines to an audio-only .mov with a
track per microphone. If settings.combine is on but the request never
asked for combine and two or more video streams are armed, the take is
created without a combined file and warnings says so — combine is on by
default but 2 video streams are armed; recording without a combined file
— because the front ends hide the checkbox in that state and a plain
Record should still record.
What it is not. It is not an edit and not a mixdown. It does not pick a level, does not duck one track under another, does not crop, scale or composite, and does not touch the per-stream files, which remain the take. It is one more file, for the person who wants one file.
After the fact. POST /takes/{id}/combine runs the same mux on any
take that is not the live one and qualifies — complete, or incomplete
(a take that lost a stream is still worth a single file of what it kept).
It answers at once with combined.state: "pending" and fires a take
event when the mux finishes; a second call while one is pending returns the
pending manifest rather than starting a second mux. 409 while the take is
recording; 400 nothing_to_combine if the take has no files on disk. In
the apps this is the Combine now button, on a finished take that has no
combined file and at most one video stream.
When it fails. The manifest’s combined block reads failed with a
reason, and nothing else changes: a failed combine never marks the
take incomplete. The per-stream files are the take; the combined file is
a convenience. A combine that a dying daemon left pending is rewritten
failed, reason daemon died, on the next launch, and the half-written
file is removed — run POST /takes/{id}/combine again if you still want
it.
No pause
There is no pause. Stop, and start a new take. A paused file would need either a gap in its timecode or a lie in it, and neither is worth a button.