@three-ws/mocap
v0.1.2
Published
Motion capture as an API. Turn webcam/video into face, pose, and hand animation clips, then save, share, and replay them on any three.ws avatar.
Maintainers
Readme
@three-ws/mocapis the official client for the three.ws mocap clip store - the library behind the in-browser mocap studio. Recording happens in the browser: MediaPipe Face Landmarker reads a webcam frame and emits 52 ARKit blendshapes plus a head-pose matrix per frame; the runtime smooths and packs them into a portable clip. This package is the durable half, it saves that clip, lists your library, makes clips public or for-sale, and hands any clip back so you can replay it on a different avatar. It wraps the auth'd/api/mocap/clipsendpoints. It pairs with@three-ws/avatarand@three-ws/walk, those render the avatar,@three-ws/mocapdrives its face.
Why
Facial mocap in the browser is solved math, but the boring half, persisting a recording, versioning the wire format, sharing it, replaying it on a different rig: is where projects stall. A blendshape buffer in memory is gone the moment the tab closes. This package is that half, done once:
- Capture once, replay anywhere. A clip is rig-agnostic ARKit blendshapes, not baked keyframes, so a face you recorded on one avatar plays on any avatar that exposes ARKit morph targets.
- Versioned wire format. Every clip carries a format string
(
three.ws.face-mocap.v1). The runtime asserts on it before replay, a future v2 clip can't silently load on a v1 player and mangle the animation. - Library, sharing, marketplace. Clips are private by default; flip one to
public, attach a$THREEprice, and it's listed. One field, no new plumbing. - Cursor-paginated, owner-scoped. List your own library or browse the public pool with the same call.
Install
npm install @three-ws/mocapZero runtime dependencies. Works in Node 18+ and the browser (uses fetch). To
record a clip in the browser, use the three.ws mocap runtime; to render the
avatar you replay it on, add
@three-ws/avatar.
Quick start
Save a recording the browser runtime handed you, then read it back:
import { saveClip, getClip } from '@three-ws/mocap';
// `recording` is the object FaceMocap.getRecording() returns:
// { format: 'three.ws.face-mocap.v1', duration, frames: [{ t, shapes, mat }] }
const { id, slug } = await saveClip(recording, {
name: 'Surprised reaction',
tags: ['emote', 'reaction'],
}, { token: process.env.THREEWS_TOKEN });
const clip = await getClip(id);
console.log(clip.frameCount, 'frames,', clip.duration, 'seconds');
// → drop clip.frames into FaceMocap.play(clip) on any ARKit avatarList your library, then browse the public pool:
import { listClips } from '@three-ws/mocap';
const mine = await listClips({ token }, { kind: 'face' });
const shared = await listClips({}, { includePublic: true, limit: 100 });API
The SDK is a thin client over /api/mocap/clips. Every write needs a session
cookie or a bearer token with the right scope; reads of public clips are open.
Writing with a session cookie also needs a CSRF token. Cookie-authenticated
writes are double-submit protected: mint a token with GET /api/csrf-token and
echo it in the X-CSRF-Token header on every POST/PATCH/DELETE, or the
call answers 403 csrf_missing. Tokens are single-use, so mint one per write.
Bearer tokens are exempt (a bearer is not auto-attached by a browser, so it is
its own proof of intent), which makes { token } the simpler path outside the
browser:
import { createMocap } from '@three-ws/mocap';
// Bearer: nothing else to do.
const mocap = createMocap({ token: process.env.THREEWS_TOKEN });
// Session cookie: wrap fetch and attach a fresh CSRF token per write.
const cookieClient = createMocap({
headers: { cookie },
fetch: async (url, init = {}) => {
if ((init.method || 'GET') === 'GET') return fetch(url, init);
const res = await fetch('https://three.ws/api/csrf-token', { headers: { cookie } });
const { data } = await res.json();
return fetch(url, { ...init, headers: { ...init.headers, 'x-csrf-token': data.token } });
},
});saveClip(recording, meta, auth) → Promise<Clip>
Persist a browser recording. Wraps POST /api/mocap/clips. The recording is
passed through as the clip field; meta supplies the library metadata.
recording: the object the capture runtime returns:
| Field | Type | Notes |
|---|---|---|
| format | string | One of the supported formats. Asserted server-side. |
| duration | number | Seconds, 0-3600. Stored as duration_ms. |
| frames | Frame[] | { t, shapes, mat? } per frame. Max 18,000 frames, 2 MB inline. |
A Frame is { t: number, shapes: Record<string, number>, mat?: number[16] | null }
tis seconds from clip start,shapesmaps ARKit blendshape names to scores,matis the optional 4×4 head-pose matrix.
meta
| Field | Type | Default | Notes |
|---|---|---|---|
| name | string | - | Required. 1-120 chars. |
| slug | string | auto from name | ^[a-z0-9][a-z0-9-]{0,79}$, unique per owner. |
| description | string | - | Up to 2000 chars. |
| tags | string[] | [] | Up to 20 tags, ≤40 chars each. |
| visibility | 'private' \| 'unlisted' \| 'public' | 'private' | |
| avatarId | uuid | - | Bind the clip to one of your avatars. Must be owned by you. (avatar_id is accepted too.) |
Returns the created Clip (201). kind (face / pose / hand / vmc) is
derived from format server-side.
getClip(idOrSlug, auth?) → Promise<Clip>
Fetch one clip with its full frames array, ready to replay. Wraps
GET /api/mocap/clips/:idOrSlug. Public and unlisted clips need no auth; a
private clip resolves only for its owner (otherwise 404, never a leak). Each
non-owner fetch bumps play_count.
A slug is unique per owner, not globally, so a slug lookup resolves to your own
clip when you are authenticated and otherwise to the oldest public clip carrying
it. Pass the id when you need to name one exact clip.
listClips(auth, opts?) → Promise<{ items, nextCursor }>
List clips without frames (metadata only: cheap, frames is null on every
row). Wraps GET /api/mocap/clips. Cursor-paginated, newest first. Returns
{ items, nextCursor, raw }, where nextCursor is null on the last page and
raw is the untouched wire envelope.
| Option | Type | Default | Notes |
|---|---|---|---|
| limit | number | 50 | Clamped to 1-100. |
| cursor | string | - | nextCursor from the previous page. |
| kind | 'face' \| 'pose' \| 'hand' \| 'vmc' | - | Filter by clip kind. |
| includePublic | boolean | false | Authed: union your clips with the public pool. |
With no auth, you get the public pool only. With auth, you get your own
library; add includePublic: true to fold in public clips too.
updateClip(idOrSlug, patch, auth) → Promise<Clip>
Edit metadata. Wraps PATCH /api/mocap/clips/:id (owner only). Patchable:
name, description, tags, visibility, avatarId, and price. Set
price: { amount, currency } to list the clip for sale, or price: null to make
it free again. Bearer tokens need the avatars:write scope.
deleteClip(idOrSlug, auth) → Promise<{ ok: true }>
Soft-delete a clip you own. Wraps DELETE /api/mocap/clips/:id. The row is
tombstoned (deleted_at), not destroyed. Bearer tokens need avatars:delete.
Clip shape (returned by every call; frames only on getClip). The SDK
camelCases the wire row and keeps the untouched original on raw:
| Field | Type | Notes |
|---|---|---|
| id / slug | string | Identity. |
| name / description | string | Library metadata. |
| kind | 'face' \| 'pose' \| 'hand' \| 'vmc' | Derived from format. |
| format | string | Wire-format version string. |
| durationMs / duration | number | Milliseconds / seconds. |
| frameCount | number | Number of frames. |
| frames | Frame[] | Present only on getClip. |
| tags | string[] | |
| visibility | string | private / unlisted / public. |
| avatarId | uuid \| null | Bound avatar, if any. |
| playCount | number | Non-owner fetches. |
| price | { amount, currency } \| null | null when free. |
| owner | 'self' \| 'other' \| null | Relative to the caller. Projected by listClips only; null on a single-clip read or write. |
| raw | unknown | The untouched wire row (duration_ms, frame_count, …). |
How it works
Capture is client-side and free; persistence is this API. The clip is the contract between them: a portable, versioned blendshape buffer:
webcam / video frame
│
▼
MediaPipe Face Landmarker ← in the browser, three.ws mocap runtime
│ 52 ARKit blendshapes + 4×4 head-pose matrix
▼
one-euro smoothing → record buffer
│
▼
getRecording() ──▶ { format, duration, frames:[{ t, shapes, mat }] }
│
saveClip() │ POST /api/mocap/clips ← @three-ws/mocap
▼
┌─────────────────────────────┐
│ mocap_clips (Postgres) │ owner-scoped, versioned, taggable, priceable
└──────────────┬──────────────┘
getClip() │ GET /api/mocap/clips/:id
▼
FaceMocap.play(clip) on ANY ARKit avatar ← @three-ws/avatar / @three-ws/walkBecause a clip is rig-agnostic ARKit blendshapes: not baked bone keyframes -
the same recording replays on any avatar that exposes ARKit morph targets. The
format string carries the version; the runtime refuses a clip whose format it
doesn't speak rather than mangling the animation.
Supported formats (the format string a recording must carry):
| format | kind | Source |
|---|---|---|
| three.ws.face-mocap.v1 | face | Webcam facial blendshapes + head pose. |
| three.ws.pose-mocap.v1 | pose | Body/skeletal pose capture. |
| three.ws.hand-mocap.v1 | hand | Hand/finger tracking. |
| three.ws.vmc.v1 | vmc | VMC-protocol motion stream. |
Frames are stored inline as JSONB up to 2 MB (≈ a multi-minute 30 Hz face capture). The runtime caps a single clip at 18,000 frames; longer captures should be split.
Errors & edge cases
The endpoints return structured JSON errors { error: { code, message } }. The
SDK surfaces them as a typed ThreeWsError carrying the code:
| code | HTTP | Meaning | Recovery |
|---|---|---|---|
| unauthorized | 401 | Write with no session/token. | Sign in or pass a bearer token. |
| csrf_missing / csrf_invalid | 403 | Cookie-authenticated write with no (or a spent) X-CSRF-Token. | Mint one per write from GET /api/csrf-token, or use a bearer token. |
| insufficient_scope | 403 | Token lacks avatars:write / avatars:delete. | Mint a token with the scope. |
| validation_error | 400 | meta/recording failed schema. | Fix the field named in message. |
| unsupported_format | 400 | format isn't a known wire format. | Use a supported format. |
| payload_too_large | 413 | Inline frames exceed 2 MB. | Record a shorter clip or split it. |
| duplicate_slug | 409 | Slug already used by you. | Omit slug (auto), or pick another. |
| not_found | 404 | Missing clip, or a private clip you don't own. | Check the id/ownership. |
| invalid_request | 400 | Bad id, or empty PATCH. | Supply a valid id / ≥1 field. |
| db_error | 500 | List query failed. | Retry. |
Every state is designed: a private clip you don't own returns 404, not its
contents: non-owners can't even confirm it exists. Mirror that in your UI.
Examples
Record in the browser, save to your library:
import { saveClip } from '@three-ws/mocap';
// faceMocap is the three.ws runtime recorder; recording is its output
const recording = faceMocap.getRecording();
const clip = await saveClip(recording, {
name: 'Wink + smile',
tags: ['emote'],
visibility: 'unlisted',
}, { token });
console.log(`https://three.ws/mocap/${clip.slug}`);Replay a public clip on a freshly forged avatar:
import { getClip } from '@three-ws/mocap';
import { forge } from '@three-ws/forge';
const avatar = await forge('a friendly robot, ARKit face');
const clip = await getClip('surprised-reaction');
// drive avatar's ARKit morph targets from clip.frames in your render loopList a tag-filtered gallery for a picker:
import { listClips } from '@three-ws/mocap';
let cursor;
const all = [];
do {
const page = await listClips({}, { includePublic: true, limit: 100, cursor });
all.push(...page.items);
cursor = page.nextCursor;
} while (cursor);Related
@three-ws/avatar, render the avatar a mocap clip drives.@three-ws/walk, a rigged avatar as a page companion you can face-animate.@three-ws/forge, generate the ARKit avatar to replay clips on.@three-ws/x402-fetch, settle payments for priced clips.
