Contents

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

  1. 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.
  2. 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.
  3. 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.
  4. 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.com and the native one (1.1.0) opens connections to play.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.
  5. 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.
  6. 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). CameraServer has 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 a CameraTexture; Studio reads one back as an Image. macOS asks for the camera with the export preset's privacy/camera_usage_description and needs codesign/entitlements/camera: neither is set in engine/export_presets.cfg today (the microphone's two are).
  • Web Studio. The web template Studio exports with (web_nothreads_release.zip) has the CameraServer class but no feed: its godot.js asks getUserMedia for {audio: true} only, and the wasm holds no camera driver. So the page reads the camera itself, in its own JavaScript, as rw_web.gd already does other browser things through JavaScriptBridge. The page is served from the person's own Reelwright service over HTTPS, which getUserMedia needs, 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 ws takes binary already). Measured with a 1280×720 picture in a headless Godot and the core's ws 8.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 a VideoFrame (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 (new schemas/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 keeps capture when 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 as headTiltDeg is;
  • 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:

  1. 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).
  2. 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.
  3. 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-src is its own origin and its socket's (core/src/host/studio-csp.ts), so it could not send anywhere else if it tried.
  4. 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.
  5. 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.json 1.0, EpisodeIR 1.65 LineCue.capture, Timeline 1.39 Actor.captures, compiling it, set_capture, takes.add with a capture (protocol 1.14), takes.choose and takes.delete carrying 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.ts runs LiteRT.js 2.5.3 (pinned) on the face models, loaded by runtime.ts in the core and by worker.ts in web Studio's page, in models/capture/face/ (node tools/capture/models.ts --check makes them again from the pinned Face Landmarker, the landmark model's 221 open sizes fixed, byte for byte); face.ts finds and follows the face as MediaPipe's graph does; solve.ts turns each frame into the head's turn, the eyes and nine face fields; make.ts takes the rest off, smooths and keeps keys; store.ts keeps audio/takes/<sha256>.capture.json; video.ts reads 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.39 Actor.captures, capture.schema.json 1.0, set_capture; capture.missing when a line asks for a performance its take has none of. Choosing a take brings its performance, or plays as directed.
  • The engine: RwActorSampler into RwCaptureState, RwPoseSolver and RwPlanSolver giving 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.open opens a performance; desktop Studio's frames come as binary messages on the same socket (session.ts frameOf: a 32-byte header, then RGB or the camera's own YCbCr), web Studio's page sends what it solved (capture.frames); capture.status says whether the newest frame showed a face; takes.add with capture keeps the take and its performance in one change, the count-in's last second the person's rest; capture.close drops one. A take's capture lists 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's CameraServer feed, 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 with privacy/camera_usage_description and has com.apple.security.device.camera.
  • Web (RwBoothWebCamera, shell.html's reelwrightCamera): getUserMedia at 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 of tools/web/capture-booth-check.ts names 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_capture undone with Ctrl+Z. Off is LineCue.capture.off: the line plays as directed, the strengths stay, shown but not changed, and on again plays it at them (set_capture off: false); remove still 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.json 1.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, require and 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.