Acting a line on camera: performance capture from a webcam
Status: the first half of the first branch is built, 2026-10-13 (capture-face-1: the module,
the data, the engine and reelwright capture, below under "Built"); the booth's camera follows
(capture-face-2). The product plan's last item (docs/audits/product-audit-2026-10d.md,
"After the three"). Read with m6-recording.md (the booth),
voice-from-booth.md (a take said again in the character's voice), idle-layer.md and the
pose keys and mouth lane (EpisodeIR 1.62 and 1.63, core/src/compile/poses.ts and mouths.ts).
A person already acts a line in the booth: the take gives the line its timing and its mouth (Rhubarb reads the take), and voice-from-booth can say it again in the character's voice. Everything else the character does on that line is still the director's: one emotion for the line, gestures put on its words, and the idle layer's breath and weight. Performance capture lets the camera watch the person act it too. The character then turns its head as they did, looks where they looked, pulls the faces they pulled and moves its arms as they did. What comes out is keys on the line, the person's own change, undone with Ctrl+Z, kept in the show and played from there. Rendering never opens the camera again.
In short
- One capture module for both editions. MediaPipe's face, pose and hand models (Apache 2.0) run in LiteRT.js, Google's WebAssembly runtime for those model files (Apache 2.0). The same TypeScript and the same WebAssembly run in the core on the desktop and in a Worker of the page on the web, so the same frames make the same keys on both. Frames never leave the computer.
- The camera: desktop Studio reads it with Godot's own camera feed (Linux, Windows and macOS
in 4.7) and hands each frame to the local core; web Studio reads it with
getUserMedia. It is open only from Record to Stop. - What is kept: keys of the rig's own channels (head, eyes, face, arms) beside the take they were acted with, as the recording itself is kept. The line names them in EpisodeIR 1.65, and the Timeline 1.39 carries them to the engine. No picture, no video and no face points are kept.
- Not MediaPipe's own libraries. Both of their current releases report to Google while they
run, measured: the web one (1.1.0) posts to
odml.pa.googleapis.comand the native one (1.1.0) opens connections toplay.googleapis.com, with no switch to stop either. The web one also cannot run outside a browser, and the native one is native code a release cannot build here for every platform. LiteRT.js made no connection at all, in Node or in Chromium. - What it costs, measured on this box: following a face takes about 19 ms of CPU a frame in WebAssembly (native MediaPipe: 11; MediaPipe's web library: 26), a body 34 ms. Head, eyes and face at 30 frames a second come to 0.6 of a core of this loaded box, and moving the frames from Studio to the core on the desktop 0.4 more; only while recording, nothing at any other time.
- First branch (L): head, eyes and face from the booth, on desktop and web. Arms, editing the keys and the rest follow, each S or M.
1. Where capture runs
What each platform gives
- Desktop Studio (Godot 4.7.2).
CameraServerhas a feed on all three desktop platforms:CameraLinux(V4L2) is in the Linux binary this box runs,CameraWindows(Media Foundation) in the Windows release template, and macOS has had AVFoundation's for years. A feed's frames come to aCameraTexture; Studio reads one back as anImage. macOS asks for the camera with the export preset'sprivacy/camera_usage_descriptionand needscodesign/entitlements/camera: neither is set inengine/export_presets.cfgtoday (the microphone's two are). - Web Studio. The web template Studio exports with (
web_nothreads_release.zip) has theCameraServerclass but no feed: itsgodot.jsasksgetUserMediafor{audio: true}only, and the wasm holds no camera driver. So the page reads the camera itself, in its own JavaScript, asrw_web.gdalready does other browser things throughJavaScriptBridge. The page is served from the person's own Reelwright service over HTTPS, whichgetUserMedianeeds, and already with cross-origin isolation (/studio/sends COOP and COEP), so a Worker can use shared memory and threads.
Where a model can run
| Way | Desktop | Web | Verdict |
|---|---|---|---|
MediaPipe Tasks for the web (@mediapipe/tasks-vision) |
No: it needs a browser. Every vision task hands its picture to the graph as a WebGL texture (_addBoundTextureAsImageToStream), even on the CPU, and makes its canvas with document.createElement; in Node it stops at document is not defined |
Yes, but from 1.0.0 (2026-07-28) it reports to Google (section 2) | Not used |
Native MediaPipe (libmediapipe, the C library the Python package 1.1.0 wraps) |
Only as native code beside the core or in a GDExtension, built and signed for each platform; and 1.1.0 reports to Google too (section 2) | No | Not used: the core is one script injected into Node for each platform, built from any host (tools/release/build-core.ts); a native part needs a build on each, and only Linux is built here |
LiteRT.js (@litertjs/core 2.5.3) running MediaPipe's own .tflite models |
Yes, in the core's Node (measured) | Yes, in a Worker of the page (measured) | Used |
| ONNX Runtime Web (MIT) with the models converted | Yes | Yes | Kept in reserve, not measured: the models would need converting (tf2onnx), with MediaPipe's own operations to check |
A MediaPipe .task file is a zip of .tflite models (the face one holds face_detector,
face_landmarks_detector and face_blendshapes). LiteRT.js loads them as they are: no
conversion. What MediaPipe's graph does around the models is ours to write in TypeScript: find
the face (or body) in the picture, crop it and turn it upright, map the points back, follow it
from frame to frame without finding it again, and pick the 146 points the blendshapes model
reads. MediaPipe's graphs (Apache 2.0) say what each step does, and each is checked against
MediaPipe's own results on the same frames, which this design measured as the reference (the
native rows below): a test, not a hope.
One module, two homes. core/src/capture/ holds the module: frames in, the rig's channels
out, then keys. On the desktop the core runs it, on Studio's frames. On the web the page runs the
same code, bundled for a Worker, on the camera's frames; only the channels (numbers) go to the
service, which makes the keys there with the same code the desktop core uses. The model files and
the WebAssembly ship with Studio: in the desktop release's resources, and beside the web page
(/studio/capture/), fetched the first time someone turns the camera on. Nothing is fetched from
Google at run time.
What it costs: CPU a frame
Milliseconds of CPU (user and system, every thread) a frame of 640×360, on the sample clips (method under "Measurements"). A range is the three clips'. MediaPipe's libraries run a whole task (finding, following, the model, their own picture handling); LiteRT.js runs one model call, so its rows add up the calls a frame needs.
| Work a frame | Native MediaPipe 1.1.0 | MediaPipe Tasks 0.10.35, Chromium | LiteRT.js, Chromium | LiteRT.js, Node |
|---|---|---|---|---|
| Face, following it: its points and blendshapes | 10.6–11.4 | 26.2–27.3 | 19.2 (17.0 + 2.2) | 18.6 (15.4 + 3.2) |
| finding the face again, when it was lost | inside the above | inside the above | 5.6 | 4.7 |
| Body (pose lite), following it | 14.6–15.1 | 34.5–35.6 | 33.6 | 33.6 |
| finding the body again | inside | inside | 23.0 | 23.6 |
| Body, pose full / heavy | 20.6–21.6 / 63.9–64.8 | 51.5–55.3 / not run | not run | not run |
| Hands, following them | 20.5–25.1 (both) | 60.9–75.1 (both) | 35.4 a hand | 34.5 a hand |
| finding hands again | inside | inside | 50.2 | 50.7 |
| Holistic (face, body and hands in one) | 47.4–58.6 | 78.4 | not run | not run |
Around the models, the module turns each frame's crop upright and samples it to the model's size in TypeScript: 1.8 ms a frame in Node (a rotated 256×256 bilinear crop of a 640×360 frame).
- LiteRT.js costs what MediaPipe's web library does, or less, for the same models: 19 ms for the face against 26, 34 for the body against 35. Native MediaPipe is about twice as fast as either; that is WebAssembly's price, paid once on both editions.
- Node and Chromium cost the same for each model (within 2 ms), and answer with the same bytes (section 3, "Deterministic").
- The picture's size does not matter to the cost: the face at 320×180, 640×360 and 1280×720 cost 11.3, 11.0 and 11.4 ms natively, since the models read fixed-size crops. It matters a little to the result (section 3), and 640×360 is enough.
- Holistic is not worth it: it costs more than the face and the body run apart.
- The budget while recording (this box, loaded): the face every frame at 30 fps is 0.6 of a core; with the body every second frame, 1.1. On one thread the face takes about 20 ms of a 33 ms frame. The camera is read only while recording, so the rest of the time this costs nothing.
The frames
- Desktop. Studio scales each frame to 640×360 and sends it to the core as a binary WebSocket
message on the session's own socket (the core's
wstakes binary already). Measured with a 1280×720 picture in a headless Godot and the core'sws8.22 in Node: 9.0 ms of Studio's CPU a frame (copying the picture, scaling it, sending 691,200 bytes) and 3.5 ms of the core's to take it. Asking the camera for 640×360 in the first place (a feed lists the formats it can give) saves most of Studio's part. - Web. The page asks
getUserMedia({video: {width: 640, height: 360, frameRate: 30}})and passes each frame to the Worker as aVideoFrame(transferred, not copied), which reads it into RGBA there. - As fast as the computer can. The module takes the newest frame when it is free and skips the rest; each frame carries its time. The face is read every frame it gets; the body every second one (it moves slower, and its keys are sparser). Anything from 12 frames a second up makes the same keys within their tolerance: a show draws characters on twos at 24 fps.
- In time with the take. Each frame is stamped with the take's own clock (the samples the booth has recorded when the frame arrives, over the rate). A camera's frames arrive late by a steady amount, a few frames, which the booth takes off as one allowance; the owner's check on real webcams sets it. The mouth cannot set it: on the clips, the jaw the camera saw agreed too loosely with Rhubarb's mouths to line the two up by (section 3).
2. The models
MediaPipe's (Google's), as its .task files ship them (float16), each under the Apache License
2.0 by its model card, which allows shipping them in Studio with the licence beside them:
| Model | Gives | Size | Accuracy, by its model card | Used for |
|---|---|---|---|---|
| Face Landmarker v1: BlazeFace (short range), Face Mesh V2, Blendshape V2 | 478 points with the irises, 52 blendshapes, the head's transformation | 3.76 MB (0.23 + 2.55 + 0.96) | points: mean error 2.62% of the distance between the eyes, following a face (human annotators differ by 2.56%); blendshapes: mean absolute deviation 0.199 | head, eyes, face |
| Pose Landmarker lite (BlazePose GHUM 3D) | 33 points on the picture, and in metres around the hips | 5.78 MB | 87.0% of points within a fifth of the torso's size (full: 91.8%, 9.40 MB; heavy: 94.2%, 30.66 MB; annotators 97.5%) | arms |
| Hand Landmarker | 21 points a hand, and which hand | 7.82 MB | mean error 10.1% of the palm's size (annotators 6.0%) | hand shapes |
| Holistic Landmarker | all three at once | 13.68 MB | as its parts | not used |
All are made for one person facing a camera, "selfie" style (their cards' words), which is the booth. The cards rule out what this does not do: recognising who someone is. Their limits are the booth's to say: a face turned more than 80° away, or less than half of it in the picture, is lost, and dim light makes the points jitter.
Found, on the clips: the face in every frame of S1 and S2 and 222 of S3's 225 (three lost mid-laugh, head down); the body in every frame of all three; in the medium shot S2, both hands in 299 of 360 frames.
Not chosen: MoveNet (Apache 2.0) has 17 body points and no face; RTMPose and RTMW (Apache 2.0) have 133 whole-body points but no blendshapes and no head turn, and would mean another runtime; YOLO pose models are AGPL 3.0, which Studio's MIT licence cannot take in; OpenPose's licence forbids commercial use; Apple's Vision exists on Apple's systems only.
Offline: what each one sends
Each ran on the clips with every connection watched: in Chromium every request was logged and
anything not served by the probe refused; native and Node runs went under strace -e connect.
| Library | What it sent |
|---|---|
| MediaPipe Tasks Vision 1.1.0 (web), face, 45 frames | POST https://odml.pa.googleapis.com/v1/log, refused by the probe. Every task makes a logger since 1.0.0 (2026-07-28) that posts every 60 s and when the task closes, with a key it reads from its own WebAssembly; no option turns it off. Its README: "MediaPipe Tasks APIs send metrics about the performance and utilization of the APIs in your app to Google… You are responsible for obtaining informed consent from your app users" |
| MediaPipe Tasks Vision 0.10.35 (2026-04-27), the same | nothing: its six requests were the probe's own files. Offline, but the last release before the logger, frozen there, and web only |
| Native MediaPipe 1.1.0 (its Python package) | looked up play.googleapis.com and opened TLS connections to Google (172.217.112.4 to 172.217.119.4, port 443): 24 in a 7.5 s run of face, body and hands, 8 in 1 s of the face alone. No switch for it in the library |
| LiteRT.js 2.5.3, Chromium, seven models | nothing: 13 requests, all the probe's own files |
| LiteRT.js 2.5.3, Node | not one connect() |
LiteRT.js has no address of its own to send to: the only URLs in its package are in comments, and it fetches only the files it is given. So the module is offline by construction, and the models and WebAssembly ship with Studio, so it needs nothing from anywhere at run time.
3. From a performance to the show's data
Solving: what the camera saw, in the rig's own terms
Every character's rig template has a head and a neck (engine/addons/reelwright/library/*.rig.json),
the face is drawn from the same twelve fields everywhere (RwFaceParams), and the 3D figure poses
on the 2D biped's skeleton (biped_3d.v1 names biped_toon.v1). So the module solves into those
channels, never into a model's points:
| Channel | From | Rig units |
|---|---|---|
| Head turn, nod, tilt | The face's transformation matrix (Face Landmarker), as yaw, pitch and roll, less the body's own turn from the shoulders once the body is read (capture-arms; until then the body is taken to face the camera) |
degrees on neck and head (center axes: yaw, pitch), the tilt as headTiltDeg |
| Eyes | eyeLookIn/Out/Up/Down, left and right |
gaze degrees, within the solver's ±35° by ±22° |
| Face | the 52 blendshapes, into the face's twelve fields: mouthSmile less mouthFrown is mouthCurve, their difference mouthAsymmetry, jawOpen is mouthOpen, browInnerUp, browOuterUp and browDown make browRaise, browAngle and browAsymmetry, eyeWide and eyeSquint make eyeOpen and squint, the smile and the squint together make cheekRaise |
RwFaceParams offsets from the character's neutral face |
| Arms | BlazePose's world points (metres) for shoulders, elbows and wrists, in the body's own frame (across the shoulders, up the spine): the upper arm from the body, the forearm from the upper arm (how far the elbow bends, and which way) as its bone hangs from it | each limb bone's [frontal, sagittal] degrees, as the rig's limb axes name them, held to its limits |
| Hands (later) | Hand Landmarker's 21 points | the gesture library's hand shapes: relaxed, point, open, fist, grip, thumb |
Never captured: the legs, the hips and walking (an actor in a booth sits or stands; the director's staging stays), blinks (the character's own, as now), pupils and blush.
Fifteen of the 52 blendshapes hardly move. On both faces, these stayed under 0.05 in 95% of
frames: cheekSquint and noseSneer (left and right) at 0.000, cheekPuff, jawForward,
jawRight, mouthLeft, mouthFrown (left 0.017, right 0.023), mouthShrugUpper and Lower,
eyeWideRight, mouthStretchLeft and mouthDimpleLeft. So cheekRaise comes from the smile and
the squint, not from cheekSquint, and a frown shows weakly through mouthFrown: the solver
reads a sad mouth from the lowered brows, mouthPress and mouthLowerDown as well, and the first
branch's tests hold a frown and a surprise to their fields, not only the smile these clips are
full of.
A body turned towards the camera from a seat is the hard case for the arms: the three pose models put the right upper arm's sideways angle in different places on S2 (5th to 95th percentile: lite 4° to 29°, full −3° to 26°, heavy −20° to 3°), because how far an arm reaches towards the camera is what one camera sees worst. Arms read best moving across the picture. That is why they come second, with their own tests on more footage, and why each part has its own strength.
The mouth stays Rhubarb's. On the clips, the camera's open jaw agreed only loosely with the
mouths Rhubarb read from the same audio: a correlation of 0.28 on S1 (0.36 two frames apart, the
best), 0.04 on S3 (a laugh and a grimace, little speech), and the two agreed on open or closed in
65% and 61% of frames. Rhubarb reads the take for a cartoon's mouth shapes, closed on M, B and P,
and the mouth lane already lets a person draw over them. What the camera adds to the mouth is what
the voice cannot say: a smile, a grimace, a mouth held open between words, through mouthCurve,
mouthAsymmetry and mouthOpen where no shape is spoken (the solver's _mouth already adds an
expression's open mouth there).
A neutral face for each person. People's resting faces are not zero: a natural frown, brows that sit high. The count-in (three beats of 0.8 s) is when a person waits for their cue, so its last 1.2 s is taken as their neutral, and every face field is measured from it. The same goes for the head: straight ahead is how they hold it during the count-in, not the camera's axis.
Smoothing and keys
The models' points jitter from frame to frame. Each channel is smoothed with a One Euro filter
(steady when still, quick when moving), then cut down to keys with Ramer–Douglas–Peucker: the
fewest keys whose straight lines between them stay within a tolerance of every frame. The
tolerances are the size of what a viewer sees on the rig: the speaking nods are 3° and turns 1.8°
(SPEAK_NOD_DEG, SPEAK_TURN_DEG), so the head keeps 1.5°; the face 0.06 in each field; the
eyes 3°; the arms 8°. Keys are made on all of a part's channels together (a head key keeps yaw,
pitch and roll), so a person sees one tick per moment, not one per number.
Measured on the clips' 30 frames a second (jitter is what a five-frame average takes off each frame, so on a moving face it includes some real movement):
| S1, close-up, 17 s | S3, laugh and grimace, 7.5 s | S2, arms, 12 s (pose lite) | |
|---|---|---|---|
| What moved (5th to 95th percentile) | head yaw 8.5° to 28.6°, pitch −15.2° to 7.7°, tilt 1.2° to 11.8°; mouthCurve 0 to 0.77 |
yaw −14.5° to 22.7°, tilt −17.0° to 0.8°; mouthCurve 0 to 0.88 |
upper arms 18° to 36° forward, elbows bent 60° to 108° |
| Jitter, raw → smoothed | head 0.15–0.24° → 0.06–0.13°; eyes 1.0–1.2°; mouthCurve 0.032 → 0.018 |
head 0.32–0.96° → 0.13–0.36° | upper arms 0.6–0.7° → 0.2–0.3°; elbows 0.9–1.3° → 0.5–0.8° |
| Head keys a second at 1° / 1.5° / 2° / 3° | 3.4 / 2.7 / 2.2 / 1.7 | 4.5 / 3.7 / 3.2 / 2.8 | |
| Head rebuilt from its keys at 2°, error (RMS) | 0.56° | 0.49° | |
| Eye keys a second at 3° | 3.7 | 2.7 | |
| Face keys a second at 0.04 / 0.06 / 0.1 | 10.1 / 7.6 / 5.5 | 7.6 / 6.1 / 4.7 | |
| Arm key poses a second (both arms together) at 5° / 8° / 12° | 3.8 / 2.6 / 1.7 |
- Smoothing halves the jitter and keeps it far under what the tolerances allow, so keys follow movement, not noise.
- The head is a few keys a second: 2.7 at 1.5° while talking, 3.7 through a laugh. A ten-second line is about 30 head keys, each a moment a person can see and later move.
- The face is the busiest: 6 to 8 keys a second at 0.06, a fifth of the frames, because a talking face never holds still. Editing (a later branch) shows them as one row of ticks, with a coarser tolerance a person can choose.
- Arms are pose to pose: 2.6 key poses a second at 8° with both hands talking constantly; a cartoon's arms hold between poses, and the engine draws them on twos anyway.
- Size: a ten-second line keeps about 30 head keys, 35 eye keys, 70 face keys and 26 arm poses, about 11 KB of JSON beside its take, a small part of the take's own FLAC.
The picture's size, compared to 640×360 on S1: at 1280×720 the head moved 0.06–0.14° and the face fields 0.013 or less on average; at 320×180 the head moved up to 0.5° and the brows 0.03–0.05. 640×360 is enough, and cheaper to move about.
Where it is kept
A capture is source material, like the recording: the camera's moment is gone, so nothing can make it again. It is kept beside the take it was acted with, in the show:
audio/takes/<sha256>.capture.json(newschemas/v1/capture.schema.json): the take it belongs to, who acted it (as the take records who read it), the models and the module's version, the neutral it was measured from, and the keys of each part in seconds into the take. A take said again in the character's voice keeps the timing to the sample (voice-from-booth), so it plays the capture of the take it was made from.- The line says whether it plays, in the EpisodeIR (1.65):
LineCue.capture, how strongly each part plays, from 0 to 1:{"head": 1, "eyes": 1, "face": 1, "arms": 0}. Without it the line plays no capture, whatever its take holds. So nothing plays from a file the episode does not name, and each change is the person's: recording a take with the camera on chooses the take and its capture in one change ("Use Ada Lovell's take of sc01/l01, with her performance"), and Ctrl+Z takes both back. Choosing another take of the line keepscapturewhen that take has one too, and drops it otherwise, in the same change. - Each take keeps its own performance. Take 2 and take 3 each have theirs; choosing one brings its acting with it, so takes are compared whole.
- Editing keys (a later branch) makes a new version of the capture from the one acted, as a trim makes a new version of a recording from the recording: the acted one is never lost.
Kept in seconds into the take, not by the line's words as the mouth lane is, because the capture belongs to that recording: its timing is the take's. If the line's words change, the take still plays as recorded, and so does its capture.
Schema. EpisodeIR 1.65: LineCue.capture (the parts' strengths). Timeline 1.39:
Actor.captures, each played capture resolved to frames: its first and last frame, the parts'
strengths, and each part's keys by frame ({frame, v}), so the engine reads nothing but the
Timeline. capture.schema.json 1.0 for the file beside the take. A new edit, set_capture
(strengths, or none to stop it playing), and takes.add taking a capture with its take (session
protocol 1.14). The lead reserves the numbers.
In the engine
RwActorSampler reads Actor.captures into the actor's state at each frame (RwCaptureState:
the head's three angles, the eyes' two, the face's twelve fields, each arm bone's two, and each
part's weight, which fades in and out over 4 frames at the capture's ends), interpolating between
keys. RwPoseSolver then gives each part over by its weight:
- head: the speaking accents and a look's head turn give way to the captured turn, shared
between neck and head as a look's is (
LOOK_NECK_SHARE), the tilt asheadTiltDegis; - eyes: the look's angles and the idle layer's darts give way to the captured gaze;
- face: the line's emotion's face blends into the captured fields; the line's emotion still sets the idle layer's energy and the voice;
- arms: the bones take the captured angles as a keyed pose's bones take its pose (slerped by weight), the director's gestures on that line are left out by the compiler, and the idle layer yields there as it does to a pose;
- the rest goes on as directed: breath, legs, walks, props in hand (a hand holding a prop keeps its grip), blinks, and Rhubarb's mouth shapes.
Nothing is remembered between frames, so a render, Studio's player and the web player draw the
same frames, as the idle layer does. Characters of any body plan take the head, eyes and face;
arms go to the biped's skeleton (biped_toon.v1 and biped_3d.v1) first.
Deterministic by construction. The camera is read once, while recording. What plays is the keys in the show; compiling and rendering read only them. And the same frames make the same keys on both editions: one module and one WebAssembly build, pinned. Measured: the face landmark model and the blendshapes model, given the same face from a sample clip, answer with the same bytes in the core's Node and in Chromium (all 1,434 numbers of the face's points, all 52 blendshapes).
4. Privacy
The camera sees a person's face and home. What Reelwright does with it, in order:
- Off unless asked for. "Act it on camera too" is a switch in the booth, off until the person turns it on; Studio remembers it per show. The first time, before the operating system or the browser asks, Studio says what happens in a few lines and what it keeps (below).
- Open only while recording. The camera opens when Record is pressed (the count-in gives it time to start) and closes at Stop, at Esc and when the booth closes. Browsing lines, playing takes and the picture never open it. The operating system's camera light is on exactly then.
- Nothing leaves the computer. On the desktop the frames go from Studio to the core on the
same computer, over the local socket, and are dropped as soon as they are read. On the web they
stay in the page: the Worker reads them, and only the channels (angles and amounts, about 30
numbers a frame) go to the person's own workspace on their service, as their takes do. The
models and the WebAssembly come with Studio; nothing is fetched from anywhere else while
capturing. And web Studio's page has a Content-Security-Policy whose
connect-srcis its own origin and its socket's (core/src/host/studio-csp.ts), so it could not send anywhere else if it tried. - What is kept: the keys of each part and who acted it, beside the take. No picture, no video, and no face points: the module drops the 478 points after solving each frame, and what is kept are angles and amounts of the rig's channels, which cannot draw a face back. (The model cards say the same of the models: their points and blendshapes "do not provide facial recognition or identification and do not store any unique face representation".) Deleting the take deletes its capture; turning a part off or removing the capture keeps the file until the take goes, so Ctrl+Z works.
- Said plainly. In the booth, beside the switch: "Act it on camera too: Studio watches how you move your head, eyes, face and arms while you record, and keeps only that, never the picture. The camera is on only while you record, and nothing leaves this computer." On the web: "…nothing leaves this browser but how you moved, which is kept with your show." While recording, the status line: "Recording · camera on". In the take's row: "With your performance: head, eyes, face". In Settings: what the switch does, and that the camera is never used anywhere else.
5. Branches
Sizes are estimates of an agent's work: S about a day, M two or three days, L about a week.
1. capture-face: head, eyes and face from the booth, on desktop and web (L). The smallest
thing a person can see end to end: they switch on "Act it on camera too", record a line, and the
take's Picture plays the character turning its head, glancing and pulling the faces they did.
It holds:
- core:
core/src/capture/with LiteRT.js and the Face Landmarker's three models (finding the face, following it, its points, its blendshapes), checked frame by frame against MediaPipe's own results on recorded clips; the solving, the neutral from the count-in, smoothing, keys, and the camera's delay taken off;capture.schema.json1.0, EpisodeIR 1.65LineCue.capture, Timeline 1.39Actor.captures, compiling it,set_capture,takes.addwith a capture (protocol 1.14),takes.chooseandtakes.deletecarrying it; the WebAssembly and models in the desktop release's resources and beside the web page; - engine: the sampler's
RwCaptureState, the solver giving head, eyes and face over; - Studio: the booth's switch and its first-time words, the camera (desktop: the camera feed to the core; web: the page's Worker), open only from Record to Stop, "Your face is in the picture" or what to do when it is not, the take's "With your performance" and its Picture; the line's inspector with each part's strength and "Stop playing the performance"; Spanish; macOS's camera entitlement and usage words;
- checks: core tests on recorded frames (the same keys from the same frames, every time), the
sampler and solver tests, the booth against a fake camera, and a web check with Chromium's own
fake camera fed a clip (
--use-file-for-fake-video-capture).
If the lead wants it smaller, it cuts cleanly in two M branches at the module's edge: first the
core, schema and engine, proved by reelwright capture --video <file> <episode> <line> and a
render; then the booth's camera on both editions.
Then, each on its own:
| Branch | What a person can then do | Size |
|---|---|---|
capture-arms |
Their arm movements come through too: Pose Landmarker (lite) every second frame, the body's turn taken out of the head's, each arm bone's two angles; the director's gestures on that line give way, and a hand holding a prop keeps its grip | M |
capture-keys |
See and change the keys: the line's Performance row on the timeline shows each part's keys; select, drag in time, delete, nudge a value in the inspector; each edit a new version of the capture, "Back to as acted" | M |
capture-live |
Watch the character follow them live in the booth's picture while they record (the channels streamed to the live player as they come) | S |
capture-hands |
Their hand shapes come through: Hand Landmarker into the gesture library's shapes (point, open, fist, grip, thumb) | S |
capture-react |
Act without a line: a listener's reaction while someone else speaks, or a beat with no words, captured against the scene's time instead of a take | M |
capture-bodies |
Arms for other body plans: a bird's wings, a fish's fins, a robot's arms, each mapped from the person's arms | S |
capture-mouth |
Use the camera's mouth where they want it, drawn into the mouth lane as spans they can change, for what Rhubarb cannot hear (a held grimace, a silent "oh") | S |
Built
capture-face-1, the core, the data and the engine, as section 3 says, proved from a clip:
- The module (
core/src/capture/):litert.tsruns LiteRT.js 2.5.3 (pinned) on the face models, loaded byruntime.tsin the core and byworker.tsin web Studio's page, inmodels/capture/face/(node tools/capture/models.ts --checkmakes them again from the pinned Face Landmarker, the landmark model's 221 open sizes fixed, byte for byte);face.tsfinds and follows the face as MediaPipe's graph does;solve.tsturns each frame into the head's turn, the eyes and nine face fields;make.tstakes the rest off, smooths and keeps keys;store.tskeepsaudio/takes/<sha256>.capture.json;video.tsreads a clip. On the NASA fixture (core/test/fixtures/capture/, four seconds, 640×360), the face is found in all 120 frames, its points within 1.05 px of MediaPipe's own on average and its blendshapes within 0.008, and the head's turn agrees with MediaPipe's transformation (correlation 0.996, 0.5° apart). The same clip writes the same file, byte for byte, in a checkout and in the release core. reelwright capture <show> --episode <id> --line <scene>/<line> --clip <file>: the clip's sound becomes the line's take, its performance the keys beside it, chosen in one change (Takes.addActed).- The data: EpisodeIR 1.65
LineCue.capture, Timeline 1.39Actor.captures,capture.schema.json1.0,set_capture;capture.missingwhen a line asks for a performance its take has none of. Choosing a take brings its performance, or plays as directed. - The engine:
RwActorSamplerintoRwCaptureState,RwPoseSolverandRwPlanSolvergiving the head, the eyes and the face over by each part's weight.
capture-face-2, the booth's camera on both editions, as section 4 says:
- Session protocol 1.14:
capture.openopens a performance; desktop Studio's frames come as binary messages on the same socket (session.tsframeOf: a 32-byte header, then RGB or the camera's own YCbCr), web Studio's page sends what it solved (capture.frames);capture.statussays whether the newest frame showed a face;takes.addwithcapturekeeps the take and its performance in one change, the count-in's last second the person's rest;capture.closedrops one. A take'scapturelists the parts its performance moves. The core reads the newest frame whenever it is free, so a slow computer keeps fewer frames and never falls behind; the same frames make the same file, byte for byte, sent either way. - Desktop (
RwBoothCamera): Godot'sCameraServerfeed, the format nearest 640 wide, each new frame read back from its texture and sent at 640 wide. A camera's frame is taken to arrive 0.1 s after what it shows (LATE_SEC), until the owner's check with real webcams measures it. macOS asks withprivacy/camera_usage_descriptionand hascom.apple.security.device.camera. - Web (
RwBoothWebCamera,shell.html'sreelwrightCamera):getUserMediaat 640×360, each frame to a Worker (capture/worker.js,tools/web/capture-bundle.ts) that loads the WebAssembly and models from<studio>/capture/the first time; a frame's time is the browser's own capture time where it gives one. The page's Content-Security-Policy (core/src/host/studio-csp.ts, on every file under/studio/, so the Worker's script has it too) lets it reach only its own origin and its socket's; the connection log oftools/web/capture-booth-check.tsnames only the service.
capture-face-3, what was left of the face:
- The line's inspector shows a line's performance when it plays one: "Plays as acted on
camera", and the head's, the eyes' and the face's strengths in percent, each a
set_captureundone with Ctrl+Z. Off isLineCue.capture.off: the line plays as directed, the strengths stay, shown but not changed, and on again plays it at them (set_captureoff: false);removestill forgets it. - A trimmed take keeps its performance: each version of a recording made in the booth
(
takes.edit) gets the recording's capture cut as its sound is (trimCapture,cutKeys): the keys inside, measured from the new start, and where the trim falls between two keys, a key of their line's value there. A version only made louder, normalised or cleaned has the capture as acted, key for key. Deleting a recording deletes its versions' captures. - The camera's delay (
RwBoothCamera.LATE_SEC, 0.1 s) could not be measured from the fixture. Its picture and sound are in step, yet the mouth the module reads (mouthOpen) and the sound's loudness agree best 14 frames apart, at a correlation of 0.36; and with the picture moved 3 and 6 frames late, that best moved by 3 and by 1. The mouth is too loose a clock for a delay of a few frames, and a stand-in camera has no lens or cable delay of its own. Web Studio uses the browser's capture time where it gives one; the owner's check with a real webcam sets the rest (a clap: the hands meeting in the picture, the sound of it in the take).
Measurements
The samples. This box has no camera, so the frames came from three clips of NASA's #AskNASA series, people talking to a camera in a studio, as a person in a booth does. NASA's videos are not under copyright in the United States (NASA's media guidelines); they were used to measure only, and no frame of anyone is in this repository.
| Clip | Segment | What is in it | |
|---|---|---|---|
| S1 | NHQ_2020_0106_AskNASA, "Is the Sun a ball of fire?" | 234.5 s, 17 s | a close-up: talking, head turning, hands near the face |
| S2 | the same | 22.5 s, 12 s | a medium shot, seated: both hands gesturing |
| S3 | NHQ_2019_1007_AskNASA EP105 Lindsay Aitchison | 122.2 s, 7.5 s | a close-up: a laugh, a grimace, pursed lips |
Each was downloaded as NASA's 1920×1080, 30 fps file and scaled to 640×360 (a webcam's size) by ffmpeg, outside every timing.
CPU, not the clock. This box runs other projects' work at a load near 50 on 24 cores, so its
wall times are nobody's. Each figure is the CPU (user and system, every thread) spent per frame
inside the model calls: for native MediaPipe, the Python process's own; for Chromium, every
Chromium process's, read from /proc before and after the loop (after 30 frames of warming up,
while V8 compiles the WebAssembly again, optimised); for Node, the process's own. Every run
went through the shared pool (heavy), and runs that are compared ran in the same job, minutes
apart. On a laptop with nothing else to do the same work takes less: what carries over is how the
ways compare.
The probes (throwaway, in /mnt/Projects/.rw-tmp-perf-capture/probes, not committed):
native.py (MediaPipe 1.1.0's Python package, which is the native library), browser.mjs
(MediaPipe Tasks Vision and LiteRT.js in headless Chromium 1248 on SwiftShader, every request
logged and anything not served by the probe refused), node-litert.mjs (LiteRT.js in Node 24),
same-numbers.mjs (one input through LiteRT.js in Node and in Chromium, the outputs' bytes
compared), ws-recv.mjs with godot-send/ (frames from Godot to Node over a WebSocket),
fake-cam.mjs (Chromium's fake camera fed a clip: getUserMedia gives the clip's frames at
640×360 and 30 fps, so the booth's web check can act a line with no camera) and analyze.py
(solving, smoothing, keys, and the mouth against Rhubarb 1.14.0 on the same audio).
Open questions for the lead
- Numbers to reserve: EpisodeIR 1.65 and Timeline 1.39 (the next free), session protocol 1.14,
and
capture.schema.json1.0, a new kind. - A real face in the test data. The module's tests need frames with a face in them, and
MediaPipe finds no drawn one. Either a few seconds of a public-domain clip (one of the NASA
clips above, 320×180, a few hundred kilobytes) go into
testdata/with where they came from, or the owner records three seconds of themselves for it, which leaves no doubt about consent. The second is cleaner. - Size. The desktop release grows by about 13 MB (LiteRT.js's WebAssembly, 9.4 MB, and the
face models, 3.8 MB; the pose model adds 5.8 MB with
capture-arms). Web Studio fetches about 6 MB compressed the first time someone turns the camera on, and nothing before. - LiteRT.js in Node is off its beaten path. It is made for browsers; in Node it ran after four
lines of setup (it loads its WebAssembly as a browser script, so Node is given
self,importScripts,requireand its folder), and the face model needed its batch size fixed to 1 in a copy (LiteRT.js 2.5.3 refuses a model whose batch is left open). Pin the version, keep the setup in one file, and let a core test run it on every gate; ONNX Runtime Web is the fallback. - macOS. The camera entitlement and its usage words go in
engine/export_presets.cfg, but only a signed build on a Mac proves the system asks. That is the owner's step, with the release. - Not measured here: a real webcam. This box has none: the frames came from clips. A real camera's delay, low light and a laptop's own CPU are the owner's check: act one line on a Mac and on a Linux laptop, and read the take's Picture.