The review loop: versions, a player, notes on frames
The October 2026 product audit (docs/audits/product-audit-2026-10.md, finding 16) found no
review loop. Each render overwrote the last. Watch opened the raw MP4 in a browser tab. There
was no compare, no approval and no note on a frame. "Was v3 better than v4 at 0:12?" could not
be answered.
This is what was built, in both editions, and how it works.
Every render is a version
Every render Studio starts goes to a folder of its own: renders/<slug>/v1/, v2/, and so on
(core/src/studio/render-versions.ts). Previews and full renders of an episode share one
numbering. A render on the person's runner is numbered by the service when it is first handed
out (core/src/host/runners.ts). Its files that came out byte for byte as one of the three
versions before it had them (the stems and the mix when only the picture changed, or
everything when nothing did) are copied from that version on the service rather than sent
up again. A render that fails or is cancelled leaves no folder, so its number is used by the
next one. A render that finished keeps its folder, whatever it delivered, even with no video
to watch (an Export… of frames or stems).
A version's number is never given again once it finished. Notes, the approval and review links name a version by its number, and a new render under an old one would take them over. So the next number is past three things:
the versions on disk;
the highest version that finished, kept outside
renders/(which Free space clears) in.reelwright/render-versions.json;the highest version a note names. A show's notes travel with it in
comments/; its.reelwright/does not.Inputs. As a render compiles, what the show holds is kept beside it in
inputs.json. Each document's and script's bytes are stored by their hash, in the same objects store the show's named versions use (.reelwright/changes/objects). A file that did not change between renders is stored once.Changes.
renders.changes {episode, version, against?}compares two versions' inputs. It reports three things:- the episode's script, line by line;
- what the director made of it: the episode's scenes, with their shots and cues by id, and the episode's other fields;
- every other document, field by field, as
versions.diffdoes.
Other episodes and their scripts are left out. A render from before versions, or from
reelwright renderitself, has no inputs:recordedis false and nothing is compared.Approved. One version of an episode can be marked approved, its current cut:
renders.approve, kept inrenders/<slug>/review.jsonwith who marked it and when. Marking another version moves the mark. A reviewer cannot approve: approving writes.Listing.
renders.listgives each render itsversionand, for the approved one,approved. The CLI's ownrenders/<slug>/and thepreview/folders of before are still listed, with no number.Out of date. A render keeps the source hash of what it was drawn from in its
render.json(sourceHash,core/src/compile/compile.tssourceHashOf): the episode, its show, style, cast, sets, graphics, props, effects and clips, the gestures it plays (not the rest of the library's), and each line's take and each sound by their files' hashes. The Reelwright that drew it, the size it was drawn at and what tools read from the files (probes, lip sync) are not in it, so another machine or release finds the same hash.renders.listcompiles the episode as the Show page does (episodes.status, cached until a file or take changes) and sayscurrent(protocol 1.12): false when the episode gives another hash now. Studio then says "out of date" on the episode's card, in the review's version picker and on the picture, and the Show page marks the episode Out of date with a step to render a new preview. Another episode's script, the bible or a character not in it change nothing. A dub's render, one from before 0.12, and an episode that does not compile now say nothing either way.The demo's previews. The demo ships with its directed episodes' previews (
examples/demo-previews/,core/src/show/demo-previews.ts): rendered as a person's preview is, by the render queue on lavapipe (tools/demo-previews.ts). A copy of the demo takes each as its episode's v1, its inputs the show as copied, so a first visit plays the cold open at once. It is out of date as soon as the episode is edited, like any render. A shippedrender.jsonnames its files$RENDER/<name>and$SHOW/<path>, made the copy's own as it is put in; only the video and its captions ship. Everyrender.jsonalso keeps what drew it (engine.fingerprint: the engine's draw code and its whole library). A person's render is never out of date for that alone, since nothing they made changed; the demo's previews keep what drew each of them of only what the episode uses (the engine's draw code, and the rigs, parts and gestures it uses) and are rendered again before a release that draws them otherwise (tools/demo-previews.ts --check), so they show what that release draws. A gesture, rig or part the library gains that an episode does not use changes neither that nor its source hash, which reads only the gestures it plays. Each ships with the lip sync of its episode's lines too (lipsync/, the records its render made,previews.json'slipSync), which a copy takes into its own lip sync cache: the Show page's first still compiles the episode with lip sync, and Rhubarb on the cold open's six lines was 13 seconds of every new account's first visit. A record is keyed by its take, its words and the Rhubarb that made it, so another Rhubarb never reads one;--checkfails when a line has none by the Rhubarb a release carries (tools/demo-previews.ts --lip-syncmakes them again without rendering). And the pictures the Show page asks first (the cold open's still and the poster's line-up) are kept beside them as preview cache entries (examples/demo-pictures/,core/src/host/demo-pictures.ts), which every copy takes: a desktop's first open of the demo spent 45 of its 49-60 s on a busy box drawing those two. Their keys cover what drew them and everything they show, so--checksays when they would be drawn otherwise now (--picturesdraws them again), and an edited copy draws its own.
A player Godot can play
Godot cannot decode an MP4, so the player reads a review copy of the render instead
(core/src/studio/review.ts, renders.review {dir}). It is made the first time it is asked
for, a few seconds for a preview, and kept in the render's review/ folder:
frames.jpg: every frame of the MP4 as a JPEG, one after another, scaled to fit 960×540. ffmpeg decodes with-fps_mode passthrough, so no frame is dropped or doubled.index.json: where each frame starts inframes.jpg(frameCount + 1 offsets), and each frame's peak level, left and right, in dBFS to a tenth, for the meters; andposter, the first frame with its picture drawn, past black and a fade up out of it, where the review opens when no frame is asked for (posterFrame, from each frame's mean grey, measured in the same ffmpeg run). A copy made before it was measured is made again.audio.wav: the sound as 16-bit stereo PCM.
A player reads frame n with a byte range. On the desktop it reads from the disk. On the web
service, and for a show on a runner, it reads through /api/file, which answers HTTP Range
requests. A new MP4 in the same folder (a CLI render over renders/<slug>/) makes the copy
again. Two asks at once share one making. The copy uses only ffmpeg parts the packaged build
already holds: the mov demuxer, the h264 and aac decoders, scale, mjpeg, image2,
pcm_s16le and wav.
Notes on frames and ranges
Notes are M13's comment threads (comments/<slug>.thread.json, core/src/studio/comments.ts).
They are threaded, @mentionable, resolvable, and tell the people they concern. A note on a
render is a timeline thread whose anchor (CommentThread 1.1) also names:
version: the render it was written on;to: the last frame of a range, fromframe;drawing: marks drawn on the frame (arrows, circles, lines, pen strokes). Each is in the frame's own coordinates, 0,0 to 1,1, so it lands in the same place at any size.
The daemon checks a note against the render on disk:
- the version exists;
- the frames are inside it;
- the range runs forward;
- every mark has whole points.
Export
comments.export {episode, version?, format, status?} writes the notes to
renders/<slug>/notes/ (core/src/studio/review-notes.ts):
- csv is a ZIP. It holds
notes.csv, one row per comment with its note's number, version, timecodes in and out (HH:MM:SS:FF), frames, status, author, time, text, mentions and marks. Beside it is a thumbnail of each note's frame, taken from its version's review copy. A cell a spreadsheet would run as a formula is kept as text. - pdf is Letter pages with one block per note. Each block has the note's frame, with the marks drawn over it as vector strokes, beside the thread. The JPEG is placed as it is (DCTDecode), by the core's own PDF writer. The same notes give the same bytes.
Studio
The review view, in both editions, opens from an episode's render (Watch) or from a route,
<show>/review/<episode>/v<N>[/<frame>]. It holds:
- The player.
- frame-accurate scrubbing, and a timecode;
- J, K and L shuttle, and the arrows step a frame;
- a range with In and Out, played in a loop;
- speeds from a quarter to double;
- level meters from the index's peaks;
- fullscreen.
- Versions. Pick a version; compare two side by side or as a wipe, in sync; mark one approved; see what changed since the one before.
- Notes.
- The notes beside the player; a click seeks to one.
- Write one at the playhead or over the range, drawing on the frame.
- Reply, @mention, resolve.
- Send one to the showrunner ("Fix this"), with the episode and frame as its context.
- Share for review. On the service, invite someone as a reviewer (M13: they comment and cannot edit), with a link to this version.
- Export notes as a CSV with thumbnails, or as a PDF.
Watch opens the render it was pressed on, by its folder, so a render from the command line (which has no version) is the one that opens. Notes go on a version: on a render with none the note box says so and nothing is posted. The picture plays as soon as the review copy's index is read; the sound joins when it has come down, which in a browser can be a while for a long episode. A frame that could not be read is said over the older frame still shown, and asked for again, playing or paused, until it comes.
Hidden (another section, another show), the view pauses and leaves fullscreen. When Studio leaves the show, the view lets go of everything: the episode, its renders, their frames and sound, its notes and what changed. In a browser, the browser's own Escape ends fullscreen without a key reaching Studio; the view notices the window leave it and leaves too. Another episode takes the notes and the changes at once, so a note is never filed on the one before while the new one loads. A version or compare that cannot be shown leaves the header (the pickers, Approve) on what the screen still shows.
The keys are the player's. They come before anything focused takes them, so Space after a click on Approve plays rather than taking the mark off, and the arrows step rather than move the focus. They also come before Studio's own Ctrl+L, which opens the chat elsewhere. A text field keeps its keys. Held down, a key acts once, but the steps repeat. With Ctrl, Alt or Cmd, only Ctrl+L is the player's, so Ctrl+C and the like do nothing here.
| Key | Does |
|---|---|
| Space | Play or pause |
| J, K, L | Back, stop, forward; J or L again goes faster (2×, 4×, 8×) |
| Left, Right | A frame back or on; with Shift, a second |
| Home, End | The first or the last frame |
| I, O, X | In, Out, clear them |
| Ctrl+L | Play In to Out round and round |
| C | Compare: side by side, a wipe, none |
| D | Draw: an arrow, a circle, a line, the pen, none |
| N | Write a note; Ctrl+Enter posts it |
| F, Escape | Fullscreen, and back |
Sound plays forward at a quarter speed up to double, pitched with the speed. Faster than that, or backward, it stops. The meters show the frame's peaks from the index, not the sound card, so they read the same in a browser, on the desktop and while paused.
Sharing a version
Share… invites an address as a reviewer (POST /api/shows/<key>/invitations with place,
review/<episode>/v<n>). The invitation keeps that place. Accepting it sends the person to
Studio at it, /studio/#/<their key for the show>/review/<episode>/v<n>, whether they accept
on the invitation's page or by signing in with it. Copy link gives the same address with the
show's key as everyone knows it, <owner>~<show>, for someone in the show already.
Both editions
The view, its keys and its files are the same in both editions. Only where the bytes come from differs:
- the desktop reads the disk;
- the web reads
/api/filewith Range; - a show on a runner reads through the service from that computer.
On the web an export downloads, since a tab opened after the export's wait would be a pop-up.
What the view does is printed (RwReviewView: …), so a check driving the web export from
outside sees it, as History's does.
Limits
- Versions are never pruned. Each one holds its whole render;
show.clean {renders}clears them all. A preview of the cold open is a few MB, and a full render with ProRes deliverables is far more. - A long render's review copy is heavy. It takes about 50 KB a frame, and is made on the first Watch: seconds for a preview, minutes for a 22-minute full render.
- The sound is read whole. In a browser that is about 11 MB a minute, which suits previews and short episodes. The picture does not wait for it.
- A runner's inputs are taken early. A render handed to the person's runner keeps what the show held when it was handed out, before the runner voices any missing line.
- Comparing different sizes. A preview and a full render compare at the same frame, each fitted to its half or, for a wipe, to the frame.
How it is proved
Core tests:
renders.test.ts: versions, inputs, changes and approval on two real renders; numbers never given again; a render of frames alone kept;review.test.ts: the review copy's frames, levels and keeping;review-notes.test.ts: notes checked against the render, and both exports read back;host-runners.test.ts: a runner's render kept as v1, and its number remembered;host-invitations.test.ts: the place an invitation lands on.
gdUnit:
rw_review_player_test.gd: the source and the clock;rw_review_view_test.gd: the view on a recorded review: hidden and closed, another episode, what cannot be shown, the keys by a focused button or held, a render with no version, the sound after the picture, late answers, a frame not read;rw_studio_review_test.gd: Studio's route, Watch, Fix this, Ctrl+L in Studio, and the review let go when the show is left.
Each was mutation-checked.
Live:
tools/review-watch.shon the desktop, against the real daemon and engine;tools/web/review-check.tson the web, against the real service, with a reviewer.