@three-ws/mocap

@three-ws/mocap

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.

npm install @three-ws/mocap
View on GitHubStar the repo

Motion capture as an API, turn a webcam or video into face, pose, and hand animation clips you can replay on any avatar.

npm downloads license node

Install · Quick start · API · How it works · three.ws


@three-ws/mocap is 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/clips endpoints. It pairs with @three-ws/avatar and @three-ws/walk, those render the avatar, @three-ws/mocap drives 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:

#Install

npm install @three-ws/mocap

Zero 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 avatar

List 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 }

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/walk

Because 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 loop

List 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);

Built by three.ws · The only coin is $THREE