Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 27 additions & 3 deletions .github/workflows/pr.yml
Original file line number Diff line number Diff line change
@@ -1,9 +1,33 @@
# Reusable workflow for PRs; to eject, you can replace this file with
# https://github.com/ryansonshine/ryansonshine/blob/main/.github/workflows/pr.yml
name: 🔖 Pull Request

# Ejected from the ryansonshine reusable workflow: it ran a Node 16 matrix, and
# vite 5 requires Node >= 18 (`crypto.getRandomValues is not a function` on 16),
# so that job could never pass. This mirrors the local `ci.yml` steps across the
# Node versions the package actually supports.
on: [pull_request]

jobs:
build:
uses: ryansonshine/ryansonshine/.github/workflows/pr.yml@main
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
node-version: [18.x, 20.x]
steps:
- name: ⬇️ Checkout code
uses: actions/checkout@v4

- name: 🟦 Setup Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: npm

- name: 📦 Install dependencies
run: npm ci

- name: 🔨 Build
run: npm run build --if-present

- name: 🧪 Test
run: npm test
43 changes: 43 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,49 @@ function App() {
npm run example
```

## 📱 React Native (Expo)

Avatoon runs on React Native too. The same `<Avatoon>` component renders through
`@react-three/fiber/native` (via `expo-gl`) and plays audio through `expo-av` —
selected automatically by the package's `react-native` export condition, so you
import from `'avatoon'` exactly as on web.

```bash
npx expo install expo-gl expo-av expo-asset expo-file-system \
@react-three/fiber @react-three/drei three
npm install avatoon
```

```tsx
import { useRef } from 'react';
import { View, Pressable, Text } from 'react-native';
import { Avatoon, type AvatoonHandle } from 'avatoon';

export default function App() {
const avatar = useRef<AvatoonHandle>(null);
return (
<View style={{ flex: 1 }}>
<Avatoon ref={avatar} glbUrl="https://.../model.glb" visemeJson={visemeJson} />
<Pressable onPress={() => avatar.current?.toggle()}>
<Text>Talk</Text>
</Pressable>
</View>
);
}
```

**Platform differences**

| | Web | React Native |
|---|---|---|
| Renderer | `@react-three/fiber` (DOM `<canvas>`) | `@react-three/fiber/native` (`expo-gl`) |
| Audio | `HTMLAudioElement` | `expo-av` (base64 WAV written to a temp file) |
| Play control | `showPlayVoiceButton` or `ref` | `ref` only (the built-in button is DOM) |

A full runnable example lives in [`examples/native`](./examples/native). Note:
HDR `environmentPreset` lighting is more limited under `@react-three/drei/native`,
and R3F's native entry is most battle-tested on the v8 line (React 18 / Expo 51).

## 🧩 API

### `Avatoon(props)`
Expand Down
54 changes: 54 additions & 0 deletions examples/native/App.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { useRef, useState } from 'react';
import { View, Pressable, Text, StyleSheet } from 'react-native';
import { Avatoon, type AvatoonHandle } from 'avatoon';

/**
* Minimal Expo example for avatoon on React Native.
*
* The 3D scene renders through `@react-three/fiber/native` (expo-gl) and audio
* plays through `expo-av` — both selected automatically via the package's
* `react-native` export condition. Playback is driven imperatively through the
* `ref`, since the built-in DOM play button is web-only.
*/
export default function App() {
const avatar = useRef<AvatoonHandle>(null);
const [playing, setPlaying] = useState(false);

const toggle = () => {
avatar.current?.toggle();
setPlaying(p => !p);
};

return (
<View style={styles.container}>
<Avatoon
ref={avatar}
glbUrl="https://models.readyplayer.me/YOUR_MODEL.glb"
// visemeJson carries the viseme keyframes + base64 WAV; fetch it from
// your TTS pipeline (Azure / Polly / Rhubarb — see the converters).
visemeJson={undefined}
environmentPreset="sunset"
/>

<Pressable style={styles.button} onPress={toggle}>
<Text style={styles.buttonText}>
{playing ? '⏹ Stop talking' : '▶ Talk with me'}
</Text>
</Pressable>
</View>
);
}

const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: '#f0f0f0' },
button: {
position: 'absolute',
bottom: 40,
alignSelf: 'center',
paddingVertical: 12,
paddingHorizontal: 24,
borderRadius: 25,
backgroundColor: '#3b82f6',
},
buttonText: { color: '#fff', fontWeight: '700', fontSize: 16 },
});
61 changes: 61 additions & 0 deletions examples/native/GoalsExample.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { useState } from 'react';
import { View, Pressable, Text, StyleSheet } from 'react-native';
import { Avatoon, type AvatoonGoal } from 'avatoon';

/**
* React Native example showing goal-based gestures (Normal / Muscle / Sleep).
*
* Switching `goal` re-poses the avatar via the same morph/bone logic as web —
* no audio required. Handy for idle states, reactions, or emotes.
*/
const GOALS: AvatoonGoal[] = ['Normal', 'Muscle', 'Sleep'];

export default function GoalsExample() {
const [goal, setGoal] = useState<AvatoonGoal>('Normal');

return (
<View style={styles.container}>
<Avatoon
glbUrl="https://models.readyplayer.me/YOUR_MODEL.glb"
goal={goal}
environmentPreset="city"
/>

<View style={styles.row}>
{GOALS.map(g => (
<Pressable
key={g}
onPress={() => setGoal(g)}
style={[styles.chip, goal === g && styles.chipActive]}
>
<Text style={[styles.chipText, goal === g && styles.chipTextActive]}>
{g}
</Text>
</Pressable>
))}
</View>
</View>
);
}

const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: '#f0f0f0' },
row: {
position: 'absolute',
bottom: 40,
left: 0,
right: 0,
flexDirection: 'row',
justifyContent: 'center',
gap: 10,
},
chip: {
paddingVertical: 10,
paddingHorizontal: 18,
borderRadius: 20,
backgroundColor: '#e2e8f0',
},
chipActive: { backgroundColor: '#3b82f6' },
chipText: { color: '#334155', fontWeight: '600' },
chipTextActive: { color: '#fff' },
});
51 changes: 51 additions & 0 deletions examples/native/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# avatoon — React Native (Expo) example

Minimal Expo app rendering `<Avatoon>` on React Native. The 3D scene runs on
`@react-three/fiber/native` (via `expo-gl`) and audio plays through `expo-av`.
avatoon picks these automatically through its `react-native` package export
condition — you import from `'avatoon'` exactly as on web.

## Setup

```bash
npx create-expo-app avatoon-native --template blank-typescript
cd avatoon-native

# avatoon + the native peers it needs
npx expo install expo-gl expo-av expo-asset expo-file-system \
@react-three/fiber @react-three/drei three
npm install avatoon
```

Then drop one of the example screens into your project and run:

```bash
npx expo start
```

## Examples

- [`App.tsx`](./App.tsx) — audio-synced lip-sync driven imperatively via a `ref`.
- [`GoalsExample.tsx`](./GoalsExample.tsx) — goal-based gestures (Normal / Muscle
/ Sleep) with no audio, switched from RN buttons.

## How it differs from web

| | Web | React Native |
|---|---|---|
| Renderer | `@react-three/fiber` (DOM `<canvas>`) | `@react-three/fiber/native` (`expo-gl`) |
| Audio | `HTMLAudioElement` | `expo-av` (base64 WAV → temp file via `expo-file-system`) |
| Play control | `showPlayVoiceButton` or `ref` | `ref` only (the built-in button is DOM) |

## Notes & caveats

- **Playback is imperative on native.** The DOM play button (`showPlayVoiceButton`)
is web-only; drive playback with the `ref` (`play()` / `stop()` / `toggle()`)
and your own RN `Pressable`, as in `App.tsx`.
- **`environmentPreset` / HDR lighting** relies on `@react-three/drei/native`,
whose `Environment` support is more limited than on web. Prefer simple
lighting (`ambientLight` is added for you) if you hit issues.
- **Models** load over the network via `useGLTF`; make sure the `.glb` URL is
reachable from the device/simulator.
- R3F's native entry is most battle-tested on the v8 line (React 18 / Expo 51),
which is what this example pins.
29 changes: 29 additions & 0 deletions examples/native/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
{
"name": "avatoon-native-example",
"version": "1.0.0",
"private": true,
"description": "Minimal Expo example for avatoon on React Native",
"main": "node_modules/expo/AppEntry.js",
"scripts": {
"start": "expo start",
"android": "expo start --android",
"ios": "expo start --ios"
},
"dependencies": {
"avatoon": "^1.9.0",
"expo": "~51.0.0",
"expo-asset": "~10.0.0",
"expo-av": "~14.0.0",
"expo-file-system": "~17.0.0",
"expo-gl": "~14.0.0",
"@react-three/drei": "^9.0.0",
"@react-three/fiber": "^8.17.0",
"react": "18.2.0",
"react-native": "0.74.0",
"three": "^0.166.0"
},
"devDependencies": {
"@types/react": "~18.2.0",
"typescript": "~5.3.0"
}
}
4 changes: 4 additions & 0 deletions jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ module.exports = {
'\\.(css|less|scss|sass)$': 'identity-obj-proxy',
'^three/examples/jsm/utils/SkeletonUtils$':
'<rootDir>/test/__mocks__/skeletonUtils.ts',
// Optional native peers aren't installed; map them to manual mocks so the
// React Native audio clock can be unit-tested in jsdom.
'^expo-av$': '<rootDir>/test/__mocks__/expo-av.ts',
'^expo-file-system$': '<rootDir>/test/__mocks__/expo-file-system.ts',
},
setupFilesAfterEnv: ['./jest.setup.ts'],
};
30 changes: 28 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
"exports": {
".": {
"types": "./lib/index.d.ts",
"react-native": "./lib/native/index.es.js",
"require": "./lib/Avatoon.umd.js",
"import": "./lib/Avatoon.es.js"
}
Expand All @@ -17,7 +18,8 @@
"LICENSE"
],
"scripts": {
"build": "tsc --project tsconfig.build.json && vite build",
"build": "tsc --project tsconfig.build.json && vite build && npm run build:native",
"build:native": "vite build --config vite.native.config.mjs",
"dev": "tsc --project tsconfig.build.json && vite build --watch",
"clean": "rm -rf ./lib/",
"lint": "eslint ./src/ --fix",
Expand Down Expand Up @@ -53,16 +55,40 @@
"peerDependencies": {
"@react-three/drei": ">=9.0.0",
"@react-three/fiber": ">=8.0.0",
"expo": "*",
"expo-asset": "*",
"expo-av": "*",
"expo-file-system": "*",
"expo-gl": "*",
"react": ">=18",
"react-dom": ">=18",
"react-native": "*",
"three": ">=0.153.0"
},
"peerDependenciesMeta": {
"react": {
"optional": false
},
"react-dom": {
"optional": false
"optional": true
},
"react-native": {
"optional": true
},
"expo": {
"optional": true
},
"expo-asset": {
"optional": true
},
"expo-av": {
"optional": true
},
"expo-file-system": {
"optional": true
},
"expo-gl": {
"optional": true
}
},
"devDependencies": {
Expand Down
Loading
Loading