@runwayml/avatars-react 0.10.0-beta.0 → 0.10.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -57,6 +57,8 @@ The styles use CSS custom properties for easy customization:
57
57
  See [`examples/`](./examples) for complete working examples:
58
58
  - [`nextjs`](./examples/nextjs) - Next.js App Router
59
59
  - [`nextjs-client-events`](./examples/nextjs-client-events) - Client event tools (trivia game)
60
+ - [`nextjs-rpc`](./examples/nextjs-rpc) - Backend RPC + client events (trivia with server-side questions)
61
+ - [`nextjs-rpc-weather`](./examples/nextjs-rpc-weather) - Backend RPC only (weather assistant)
60
62
  - [`nextjs-server-actions`](./examples/nextjs-server-actions) - Next.js with Server Actions
61
63
  - [`react-router`](./examples/react-router) - React Router v7 framework mode
62
64
  - [`express`](./examples/express) - Express + Vite
@@ -151,38 +153,41 @@ import { AvatarCall, AvatarVideo, ControlBar, UserVideo } from '@runwayml/avatar
151
153
 
152
154
  ### Render Props
153
155
 
154
- All components support render props for complete control:
156
+ All display components support render props for complete control. `AvatarVideo` receives a discriminated union with `status`:
155
157
 
156
158
  ```tsx
157
159
  <AvatarVideo>
158
- {({ hasVideo, isConnecting, trackRef }) => (
159
- <div>
160
- {isConnecting && <Spinner />}
161
- {hasVideo && <VideoTrack trackRef={trackRef} />}
162
- </div>
163
- )}
160
+ {(avatar) => {
161
+ switch (avatar.status) {
162
+ case 'connecting': return <Spinner />;
163
+ case 'waiting': return <Placeholder />;
164
+ case 'ready': return <VideoTrack trackRef={avatar.videoTrackRef} />;
165
+ }
166
+ }}
164
167
  </AvatarVideo>
165
168
  ```
166
169
 
167
170
  ### CSS Styling with Data Attributes
168
171
 
169
- Style connection states with CSS:
172
+ Style components with the namespaced `data-avatar-*` attributes:
170
173
 
171
174
  ```tsx
172
175
  <AvatarCall avatarId="music-superstar" connectUrl="/api/avatar/connect" className="my-avatar" />
173
176
  ```
174
177
 
175
178
  ```css
176
- .my-avatar[data-state="connecting"] {
179
+ /* Style avatar video by connection status */
180
+ [data-avatar-video][data-avatar-status="connecting"] {
177
181
  opacity: 0.5;
178
182
  }
179
183
 
180
- .my-avatar[data-state="error"] {
181
- border: 2px solid red;
184
+ [data-avatar-video][data-avatar-status="ready"] {
185
+ opacity: 1;
182
186
  }
183
187
 
184
- .my-avatar[data-state="connected"] {
185
- border: 2px solid green;
188
+ /* Style control buttons */
189
+ [data-avatar-control][data-avatar-enabled="false"] {
190
+ opacity: 0.5;
186
191
  }
187
192
  ```
188
193
 
@@ -346,6 +351,8 @@ function MediaControls() {
346
351
 
347
352
  ## Client Events
348
353
 
354
+ > **Compatibility:** Client events (tool calling) are supported on avatars that use a **preset voice**. Custom voice avatars do not currently support client events.
355
+
349
356
  Avatars can trigger UI events via tool calls sent over the data channel. Define tools, pass them when creating a session, and subscribe on the client:
350
357
 
351
358
  ```ts
package/dist/index.cjs CHANGED
@@ -170,53 +170,6 @@ function parseClientEvent(payload) {
170
170
  return null;
171
171
  }
172
172
  }
173
- async function hasMediaDevice(kind, timeoutMs = 1e3) {
174
- try {
175
- const timeoutPromise = new Promise(
176
- (resolve) => setTimeout(() => resolve(false), timeoutMs)
177
- );
178
- const checkPromise = navigator.mediaDevices.enumerateDevices().then((devices) => devices.some((device) => device.kind === kind));
179
- return await Promise.race([checkPromise, timeoutPromise]);
180
- } catch {
181
- return false;
182
- }
183
- }
184
- function useDeviceAvailability(requestAudio, requestVideo) {
185
- const [state, setState] = react.useState({
186
- audio: requestAudio,
187
- // Optimistically assume devices exist
188
- video: requestVideo
189
- });
190
- react.useEffect(() => {
191
- let cancelled = false;
192
- async function checkDevices() {
193
- const [hasAudio, hasVideo] = await Promise.all([
194
- requestAudio ? hasMediaDevice("audioinput") : Promise.resolve(false),
195
- requestVideo ? hasMediaDevice("videoinput") : Promise.resolve(false)
196
- ]);
197
- if (!cancelled) {
198
- setState({
199
- audio: requestAudio && hasAudio,
200
- video: requestVideo && hasVideo
201
- });
202
- }
203
- }
204
- checkDevices();
205
- return () => {
206
- cancelled = true;
207
- };
208
- }, [requestAudio, requestVideo]);
209
- return state;
210
- }
211
- var MEDIA_DEVICE_ERROR_NAMES = /* @__PURE__ */ new Set([
212
- "NotAllowedError",
213
- "NotFoundError",
214
- "NotReadableError",
215
- "OverconstrainedError"
216
- ]);
217
- function isMediaDeviceError(error) {
218
- return MEDIA_DEVICE_ERROR_NAMES.has(error.name);
219
- }
220
173
  var DEFAULT_ROOM_OPTIONS = {
221
174
  adaptiveStream: false,
222
175
  dynacast: false
@@ -238,6 +191,7 @@ function mapConnectionState(connectionState) {
238
191
  var AvatarSessionContext = react.createContext(
239
192
  null
240
193
  );
194
+ var MediaDeviceErrorContext = react.createContext(null);
241
195
  function AvatarSession({
242
196
  credentials,
243
197
  children,
@@ -250,12 +204,9 @@ function AvatarSession({
250
204
  __unstable_roomOptions
251
205
  }) {
252
206
  const errorRef = react.useRef(null);
253
- const deviceAvailability = useDeviceAvailability(requestAudio, requestVideo);
254
207
  const handleError = (error) => {
255
208
  onError?.(error);
256
- if (!isMediaDeviceError(error)) {
257
- errorRef.current = error;
258
- }
209
+ errorRef.current = error;
259
210
  };
260
211
  const roomOptions = {
261
212
  ...DEFAULT_ROOM_OPTIONS,
@@ -267,8 +218,8 @@ function AvatarSession({
267
218
  serverUrl: credentials.serverUrl,
268
219
  token: credentials.token,
269
220
  connect: true,
270
- audio: deviceAvailability.audio,
271
- video: deviceAvailability.video,
221
+ audio: false,
222
+ video: false,
272
223
  onDisconnected: () => onEnd?.(),
273
224
  onError: handleError,
274
225
  options: roomOptions,
@@ -280,6 +231,8 @@ function AvatarSession({
280
231
  AvatarSessionContextInner,
281
232
  {
282
233
  sessionId: credentials.sessionId,
234
+ requestAudio,
235
+ requestVideo,
283
236
  onEnd,
284
237
  onClientEvent,
285
238
  errorRef,
@@ -294,6 +247,8 @@ function AvatarSession({
294
247
  }
295
248
  function AvatarSessionContextInner({
296
249
  sessionId,
250
+ requestAudio,
251
+ requestVideo,
297
252
  onEnd,
298
253
  onClientEvent,
299
254
  errorRef,
@@ -329,6 +284,64 @@ function AvatarSessionContextInner({
329
284
  });
330
285
  };
331
286
  }, [connectionState, initialScreenStream, room]);
287
+ const [micError, setMicError] = react.useState(null);
288
+ const [cameraError, setCameraError] = react.useState(null);
289
+ const mediaEnabledRef = react.useRef(false);
290
+ react.useEffect(() => {
291
+ if (connectionState !== livekitClient.ConnectionState.Connected) return;
292
+ if (mediaEnabledRef.current) return;
293
+ mediaEnabledRef.current = true;
294
+ async function enableMedia() {
295
+ if (requestAudio) {
296
+ try {
297
+ await room.localParticipant.setMicrophoneEnabled(true);
298
+ } catch (err) {
299
+ if (err instanceof Error) setMicError(err);
300
+ }
301
+ }
302
+ if (requestVideo) {
303
+ try {
304
+ await room.localParticipant.setCameraEnabled(true);
305
+ } catch (err) {
306
+ if (err instanceof Error) setCameraError(err);
307
+ }
308
+ }
309
+ }
310
+ enableMedia();
311
+ }, [connectionState, room, requestAudio, requestVideo]);
312
+ react.useEffect(() => {
313
+ function handleMediaDevicesError(error, kind) {
314
+ if (kind === "audioinput") {
315
+ setMicError(error);
316
+ } else if (kind === "videoinput") {
317
+ setCameraError(error);
318
+ }
319
+ }
320
+ room.on(livekitClient.RoomEvent.MediaDevicesError, handleMediaDevicesError);
321
+ return () => {
322
+ room.off(livekitClient.RoomEvent.MediaDevicesError, handleMediaDevicesError);
323
+ };
324
+ }, [room]);
325
+ const retryMic = react.useCallback(async () => {
326
+ try {
327
+ await room.localParticipant.setMicrophoneEnabled(true);
328
+ setMicError(null);
329
+ } catch (err) {
330
+ if (err instanceof Error) setMicError(err);
331
+ }
332
+ }, [room]);
333
+ const retryCamera = react.useCallback(async () => {
334
+ try {
335
+ await room.localParticipant.setCameraEnabled(true);
336
+ setCameraError(null);
337
+ } catch (err) {
338
+ if (err instanceof Error) setCameraError(err);
339
+ }
340
+ }, [room]);
341
+ const mediaDeviceErrors = react.useMemo(
342
+ () => ({ micError, cameraError, retryMic, retryCamera }),
343
+ [micError, cameraError, retryMic, retryCamera]
344
+ );
332
345
  react.useEffect(() => {
333
346
  function handleDataReceived(payload) {
334
347
  const event = parseClientEvent(payload);
@@ -357,7 +370,7 @@ function AvatarSessionContextInner({
357
370
  error: errorRef.current,
358
371
  end
359
372
  };
360
- return /* @__PURE__ */ jsxRuntime.jsx(AvatarSessionContext.Provider, { value: contextValue, children });
373
+ return /* @__PURE__ */ jsxRuntime.jsx(AvatarSessionContext.Provider, { value: contextValue, children: /* @__PURE__ */ jsxRuntime.jsx(MediaDeviceErrorContext.Provider, { value: mediaDeviceErrors, children }) });
361
374
  }
362
375
  function useAvatarSessionContext() {
363
376
  const context = react.useContext(AvatarSessionContext);
@@ -368,6 +381,9 @@ function useAvatarSessionContext() {
368
381
  }
369
382
  return context;
370
383
  }
384
+ function useMediaDeviceErrorContext() {
385
+ return react.useContext(MediaDeviceErrorContext);
386
+ }
371
387
  function useAvatar() {
372
388
  const remoteParticipants = componentsReact.useRemoteParticipants();
373
389
  const avatarParticipant = remoteParticipants[0] ?? null;
@@ -433,8 +449,16 @@ function AvatarVideo({ children, ...props }) {
433
449
  }
434
450
  );
435
451
  }
452
+ var NOOP_ASYNC = async () => {
453
+ };
436
454
  function useLocalMedia() {
437
455
  const { localParticipant } = componentsReact.useLocalParticipant();
456
+ const {
457
+ micError = null,
458
+ cameraError = null,
459
+ retryMic = NOOP_ASYNC,
460
+ retryCamera = NOOP_ASYNC
461
+ } = useMediaDeviceErrorContext() ?? {};
438
462
  const audioDevices = componentsReact.useMediaDevices({ kind: "audioinput" });
439
463
  const videoDevices = componentsReact.useMediaDevices({ kind: "videoinput" });
440
464
  const hasMic = audioDevices?.length > 0;
@@ -480,7 +504,11 @@ function useLocalMedia() {
480
504
  toggleMic,
481
505
  toggleCamera,
482
506
  toggleScreenShare,
483
- localVideoTrackRef
507
+ localVideoTrackRef,
508
+ micError,
509
+ cameraError,
510
+ retryMic,
511
+ retryCamera
484
512
  };
485
513
  }
486
514
  function ControlBar({
@@ -498,7 +526,11 @@ function ControlBar({
498
526
  isScreenShareEnabled,
499
527
  toggleMic,
500
528
  toggleCamera,
501
- toggleScreenShare
529
+ toggleScreenShare,
530
+ micError,
531
+ cameraError,
532
+ retryMic,
533
+ retryCamera
502
534
  } = useLocalMedia();
503
535
  const isActive = session.state === "active";
504
536
  const state = {
@@ -509,7 +541,11 @@ function ControlBar({
509
541
  toggleCamera,
510
542
  toggleScreenShare,
511
543
  endCall: session.end,
512
- isActive
544
+ isActive,
545
+ micError,
546
+ cameraError,
547
+ retryMic,
548
+ retryCamera
513
549
  };
514
550
  if (children) {
515
551
  return /* @__PURE__ */ jsxRuntime.jsx(jsxRuntime.Fragment, { children: children(state) });