The render queue: one at a time, time left, resume, failures in words
The second product audit (docs/audits/product-audit-2026-10b.md, finding 13) found rendering
one at a time with no queue. A second render was refused ("is rendering: wait for it, or cancel
it first"). There was no time left, and a cancelled render kept nothing. A failure could end at
a colon, or call a full disk a bug in Reelwright.
This is what was built, in both editions, and how it works.
The queue
renders.start no longer refuses a second render. It adds it to the show's render queue and
answers with its id and ahead, how many renders run or wait before it. Previews, full
renders, exports (deliverables) and dubs (language) all wait in the same queue. The first
waiting render runs, one at a time (core/src/studio/renders.ts).
- Order.
renders.move {renderId, to}puts a render at another place (0 is the top). - Pause.
renders.pause {renderId}holds one render: the queue goes on past it. A running render stops and keeps the frames it drew and checked.renders.pause {}pauses the whole queue. The running render stops the same way and waits at the top until the queue resumes. - Resume.
renders.resume {renderId}lets a paused render wait its turn again. On a failed or cancelled render it tries again, from the frames it kept.renders.resume {}runs the queue again. - Cancel.
renders.cancelstops a running render or takes a waiting one out of the way. It stays listed as cancelled, so it can be tried again. - Clear.
renders.clear {renderId}takes one render off the list.renders.clear {}takes every render that ended (done, failed, cancelled). A render's log goes with it.
renders.queue answers with the whole queue. Every change says it to everyone in the show as
a render.queue notification. The running render carries its last render.event as
progress.
It is kept with the show
The queue is kept in .reelwright/render-queue.json. A daemon started again finds it and goes
on (SessionDaemon.start calls runRenderQueue). A render that was running when its daemon
stopped, closed by Studio or killed, goes on first, from the frames it had drawn and checked.
Its version folder from the attempt that never finished is freed first, so the numbering has no
gap.
One daemon runs a show's queue at a time. It holds .reelwright/render-queue.lock, which names
its process. Another daemon on the same show (a second Studio window) can read the queue but not
change it: it says which process runs it. A lock whose process is gone is taken over. Only the
session daemon runs the queue; reelwright mcp and chat open the show without it.
On the web service, each show's daemon runs its queue the same way, and its renders also wait
their turn in one line for the service's CPUs, across every show (docs/design/render-capacity.md):
a render that has compiled asks for its turn, says queued with its place (ahead,
estimateSec) while it waits, and draws when the renders before it have. The host counts a render
waiting in a queue as the asker's, so the renders a person may have at once
(concurrentRenders) hold for those waiting too (core/src/host/jobs.ts). A render sent to the
person's runner waits in the service's own list for that runner, one render of a show at a time
in the order asked (core/src/host/runners.ts). The service adds those renders to the
renders.queue answers and render.queue notifications it passes on, with the runner they go
to. Such a render can be cancelled; it is not paused or moved.
Time left
While a render draws, each render.event says:
frameofframes, andchecked: frames drawn and checked, kept should it stop;resumed: frames an earlier attempt drew and checked, not drawn again;fps: frames drawn per second in this attempt, from the frames drawn since it began drawing;etaSec: the frames left at that speed;elapsedSec: how long this attempt has run.
The speed leaves out the resumed frames, which cost nothing. When a render ends, tookSec says
how long it rendered, every attempt together. Studio shows "Drawing frames: 412 of 2115 · 3.2
frames a second · about 9 min left", and "Done in 4 min 12 s".
Resume: frames in batches, each checked and kept
A render that stopped (cancelled, paused, its engine killed, the daemon or the machine
restarted) goes on from the frames it drew and checked, not from zero
(core/src/render/batches.ts).
Studio's renders draw their frames in batches, one engine job after another. Each batch is
checked as it ends, as every frame always was: its result.json lists every frame with its
SHA-256, and the frame files must match. A checked batch is kept in a folder of its own,
.reelwright/frames/<slug>/<size>-<capture>/<first>-<end>-<attempt>/, with batch.json beside
it naming its job and what drew it: the engine's fingerprint (EngineRunner.fingerprint) and
what the engine said it draws with (its renderer, driver and adapter).
The first batch is 24 frames. Each next one is sized to take about a minute at the speed of the one before, so a stopped render loses a minute's drawing at most. No batch at the end is shorter than half the first.
A render's batches are drawn by one Godot, started for its first batch and closed as the render
ends, done, stopped or failed (GodotEngine.warm, the engine's serve: each job file's path a
line on its stdin, and a line of JSON back on its stdout as each job ends). Started again for
every batch, Godot cost the service about 9 s a batch on its two CPUs (the live machine's Kitchen
Science, 2026-10-14). The cold open's preview on two pinned CPUs, as the service draws it, from
its built image's layout (tools/host/build.ts, laid out as tools/host/shipped-check.ts does):
1,575 s of drawing alone in 27 batches before, 1,230 s in 23 after, 22% less, with every frame
and the MP4 byte for byte the same.
Each batch is drawn as a job of its own still is, into its own folder with its own result.json,
checked and kept the same way, with its own time limits. A batch that is stopped, runs out of
time, or whose engine ends takes that engine with it, and the next batch starts another. On the
service a render waits for its turn between batches (render-capacity.md): its engine waits on
its stdin meanwhile and uses no CPU. A farm's chunks and a runner's segments run as before, each
an engine of its own.
To go on, a render looks in its frames folder for batches inside its range. It keeps a batch only when all of these hold:
- the job made now for those frames, in that folder, is the job that drew it (its id is the hash of the job: the Timeline, every document and the folder);
- the engine's fingerprint and what it draws with are unchanged;
- every frame checks out against its hash again.
It draws only the frames no kept batch holds. A frame is a function of its job alone (a farm's
chunks rely on the same), so the kept frames are the frames that would be drawn. A render that
went on is byte-identical to one drawn in one go: core/test/render-queue.test.ts compares
every frame's hash and the MP4's, and the live check does it on the real engine.
Each attempt draws into folders named afresh. A daemon killed mid-batch can leave its engine drawing until that batch ends, and it never writes into a folder the next attempt draws in.
reelwright render draws as before, in --chunks at once, unless asked otherwise. A farm and
a runner's segments do not use batches.
The frames are a cache. reelwright clean, and Free space in Studio's Settings, clear each
.reelwright/frames/<name>/ folder, but not an episode's (or its dub's) while a render of it in
the queue has not finished: it goes on from them (core/src/cli/clean.ts).
Failures in words
A failure never ends at a colon, and never calls what the person can fix a bug in Reelwright
(core/src/render/failure.ts). Each render.event that failed has failure:
code |
Said | actions |
|---|---|---|
disk_full |
"The disk that holds /shows/lantern is full: this render needs about 1.4 GB more there, and 12 MB is free. Free some space there, then try again: it goes on from frame 384, and the frames before it are kept." | free_space, retry, open_log |
tool_missing |
"ffmpeg is missing: it encodes the video and mixes the sound. Install ffmpeg (…), then try again." Godot, ffprobe, Rhubarb and uv likewise. | retry, open_log |
engine_stopped |
"The renderer stopped at frame 412 (0:17), in shot 7 of 31 (sh070, INT. ROOFTOP - NIGHT): the system ended it, most likely because the computer ran out of memory. Try again: it goes on from frame 384, …" A crash, a signal or an exit code says so in words. | retry, open_log |
engine_hung |
"…: it drew no new frame for 260 seconds (27 of its 28 frames drawn) and was stopped." A batch's time (two minutes and five seconds a frame) counts from the last frame it drew, so a renderer drawing slowly on a busy machine goes on; ten times that is its cap: "…: it was still drawing after 2600 seconds (…) and was stopped." | retry, open_log |
does_not_compile |
"The episode does not compile, so nothing was voiced or drawn: … Fix it in the script or on the timeline, then try again." | open_script, retry, open_log |
frames_bad |
"Some frames did not check out (missing frames 10). Try again." | retry, open_log |
failed |
"The render stopped: … Try again." | retry, open_log |
resumeAt is the first frame not drawn and checked, where trying again goes on from. frame
and shot say where the engine stopped; path, needBytes and freeBytes where the disk is
full and by how much. The engine's last lines go to the render's log, never into the message:
what it said on stderr, and every line on stdout but a result it printed (a JSON object, its
summary or its version). On a machine with no display the engine runs under xvfb-run, which
passes its stderr on as stdout, so a script error there is heard all the same, whether the
engine drew one job or a render's batches (resultOf in core/src/render/engine.ts).
How each is found:
- Before anything is voiced or drawn (
checkFirst), a render checks that ffmpeg, ffprobe and the engine are there, and that the episode compiles, timed from estimates where lines are still to be voiced. A broken episode no longer voices every line before it fails. - Room. Once it knows the frames it keeps, a render estimates what it will write: its
frames still to draw (at the size of frames already drawn, else 0.4 bytes a pixel), the video,
the mix and stems, and what it delivers. With less room free than that, it stops before it
draws, saying how much it needs. A disk that fills while it renders says the same, from what
is left: the system's
ENOSPC,EDQUOTorEFBIG, ffmpeg's "No space left on device", or an engine that failed while next to no room was left. - The engine. A job that ends without its result is an
EngineStopped: the frame it was drawing (the first of its range it had not written), its shot and the scene's heading, and why it ended (SIGKILLor 137 the system, most likely out of memory;SIGSEGV, 134 or 139 a crash; a timeout).
Each render has a log, .reelwright/render-logs/<render id>.log, with everything it said in
every attempt: Studio shows it (Open log), read where it is, on the desktop or through the web
service's /api/file.
The same words reach the command line and the daemon: reelwright render prints a render's
failure in them, any command that meets a full disk or a missing program says so and exits 1
rather than 70, and the daemon answers a full disk with disk.full rather than "internal
error". A write that fails takes its temporary file with it (writeFileAtomic).
In Studio
The Episodes page shows the queue over the season (RwRenderQueue), the same on the desktop
and the web, hidden while it is empty. Each render says what it makes ("Export: ProRes 422 HQ,
stems, SRT captions", "Preview in Japanese") and how it stands: drawing with its speed and time
left, its place in line, paused at its kept frames, done and how long it took, or failed and
why. Its buttons move it up or down, pause, resume, cancel, try again, open its log, free space
(the show's Settings, at Storage), open the script and watch what it made. The header pauses or
resumes the whole queue and clears the renders that ended.
Render preview, Render, Export… and Render a dub… stay enabled while a render runs: what is asked for waits its turn. An episode's card says the render running, its place in line, or why its last render failed with what to do about it.
A show switched away from (RwDaemonPool) counts as working while renders wait in its queue, so
its daemon is not put away between one render and the next.