diff --git a/src/components/ui/menu.tsx b/src/components/ui/menu.tsx
new file mode 100644
index 0000000..bdc78e2
--- /dev/null
+++ b/src/components/ui/menu.tsx
@@ -0,0 +1,41 @@
+import { Menu as MenuPrimitive } from '@base-ui/react/menu'
+
+import { cn } from '../../lib/utils'
+
+function Menu(props: MenuPrimitive.Root.Props) {
+ return
+}
+
+function MenuTrigger(props: MenuPrimitive.Trigger.Props) {
+ return
+}
+
+function MenuContent({ className, ...props }: MenuPrimitive.Popup.Props) {
+ return (
+
+
+
+
+
+ )
+}
+
+function MenuItem({ className, ...props }: MenuPrimitive.Item.Props) {
+ return (
+
+ )
+}
+
+export { Menu, MenuContent, MenuItem, MenuTrigger }
diff --git a/src/features/avatar/__tests__/import-avatar-definition-test.ts b/src/features/avatar/__tests__/import-avatar-definition-test.ts
new file mode 100644
index 0000000..348964d
--- /dev/null
+++ b/src/features/avatar/__tests__/import-avatar-definition-test.ts
@@ -0,0 +1,43 @@
+import { describe, expect, it } from 'vitest'
+
+import strobi from '../../../../examples/react-vite-consumer/src/strobi.avatar.json'
+import { createAvatarDefinition } from '../avatarDefinition'
+import { isAvatarDefinitionSource, studioAvatarFromDefinition } from '../importAvatarDefinition'
+
+describe('studioAvatarFromDefinition', () => {
+ it('recognises a definition and rejects a studio project', () => {
+ expect(isAvatarDefinitionSource(strobi)).toBe(true)
+ expect(isAvatarDefinitionSource({ version: 2, library: { avatars: [] } })).toBe(false)
+ expect(isAvatarDefinitionSource(null)).toBe(false)
+ })
+
+ it('rejects a file that is not a valid definition', () => {
+ expect(() => studioAvatarFromDefinition({ schema: 'bible-strong/avatar-definition' })).toThrow()
+ })
+
+ it('round-trips back to the same definition', () => {
+ const { avatar, expressions, sequences } = studioAvatarFromDefinition(strobi)
+
+ expect(avatar.name).toBe(strobi.name)
+ expect(avatar.colors).toEqual(strobi.colors)
+ expect(avatar.body.primary).toEqual(strobi.body.primary)
+ expect(avatar.body.nodes).toHaveLength(strobi.body.nodes.length)
+ // `neutral` becomes the avatar's eye defaults rather than an expression.
+ expect(expressions).toHaveLength(strobi.expressionOrder.length - 1)
+ expect(expressions.map(e => e.semanticKey)).not.toContain('neutral')
+ expect(sequences).toHaveLength(strobi.animationOrder.length)
+
+ // Eye defaults are taken from `neutral`, the resting pose.
+ expect(avatar.eyes.spacing).toBe(strobi.expressions.neutral.eyes.spacing)
+
+ const result = createAvatarDefinition({ avatar, behavior: { expressions, sequences } })
+ expect(result.ok).toBe(true)
+ if (!result.ok) return
+
+ expect(result.value.body).toEqual(strobi.body)
+ expect(result.value.colors).toEqual(strobi.colors)
+ expect(result.value.expressionOrder).toEqual(strobi.expressionOrder)
+ expect(result.value.animationOrder).toEqual(strobi.animationOrder)
+ expect(result.value.expressions).toEqual(strobi.expressions)
+ })
+})
diff --git a/src/features/avatar/importAvatarDefinition.ts b/src/features/avatar/importAvatarDefinition.ts
new file mode 100644
index 0000000..257ff55
--- /dev/null
+++ b/src/features/avatar/importAvatarDefinition.ts
@@ -0,0 +1,161 @@
+import {
+ validateAvatarDefinition,
+ type AvatarAnimationDefinition,
+ type AvatarDefinition,
+ type AvatarExpressionDefinition,
+} from '@bible-strong/avatar-core'
+
+import type { AvatarSequence, SequenceStep } from '../animation/sequences'
+import type { StudioAvatar } from './avatars'
+import type { Expression } from './geometry'
+
+/**
+ * Reads a `.avatar.json` runtime definition back into studio state.
+ *
+ * This is the inverse of `createAvatarDefinition`: the definition keys expressions
+ * by semantic key with nested head/eyes objects, while the studio keeps a flat
+ * `Expression` record carrying its own id. Keep this in sync with `mapExpression`
+ * in ./avatarDefinition.ts — the two must round-trip.
+ */
+
+const AVATAR_DEFINITION_SCHEMA = 'bible-strong/avatar-definition'
+
+export const isAvatarDefinitionSource = (value: unknown): boolean =>
+ typeof value === 'object' &&
+ value !== null &&
+ (value as { schema?: unknown }).schema === AVATAR_DEFINITION_SCHEMA
+
+const slugify = (value: string) =>
+ value
+ .normalize('NFD')
+ .replace(/[̀-ͯ]/g, '')
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, '-')
+ .replace(/^-+|-+$/g, '') || 'avatar'
+
+const toExpression = (
+ slug: string,
+ semanticKey: string,
+ expression: AvatarExpressionDefinition
+): Expression => ({
+ id: `expression-${slug}-${semanticKey}`,
+ semanticKey,
+ headX: expression.head.x,
+ headY: expression.head.y,
+ headZ: expression.head.z,
+ widthLeft: expression.eyes.left.width,
+ widthRight: expression.eyes.right.width,
+ heightLeft: expression.eyes.left.height,
+ heightRight: expression.eyes.right.height,
+ spacing: expression.eyes.spacing,
+ positionXLeft: expression.eyes.left.x,
+ positionXRight: expression.eyes.right.x,
+ positionYLeft: expression.eyes.left.y,
+ positionYRight: expression.eyes.right.y,
+ leftAngle: expression.eyes.left.angle,
+ rightAngle: expression.eyes.right.angle,
+ perspective: expression.perspective,
+ eyeMotion: expression.motion.eyes,
+ bodyMotion: expression.motion.body,
+ ...(expression.colors?.body ? { bodyColor: expression.colors.body } : {}),
+ ...(expression.colors?.eyes ? { eyeColor: expression.colors.eyes } : {}),
+})
+
+const toSequence = (
+ slug: string,
+ semanticKey: string,
+ animation: AvatarAnimationDefinition,
+ expressionIdByKey: Map
+): AvatarSequence => {
+ const steps: SequenceStep[] = []
+ animation.steps.forEach((step, index) => {
+ const expressionId = expressionIdByKey.get(step.expression)
+ // A step pointing at an expression the definition never declared cannot be
+ // represented; dropping it keeps the sequence playable.
+ if (!expressionId) return
+ steps.push({
+ id: `step-${slug}-${semanticKey}-${index}`,
+ expressionId,
+ holdMs: step.holdMs,
+ transitionMs: step.transitionMs,
+ transition: step.transition,
+ })
+ })
+ return {
+ id: `sequence-${slug}-${semanticKey}`,
+ semanticKey,
+ name: animation.metadata?.label ?? semanticKey,
+ group: animation.metadata?.group ?? 'Importé',
+ description: animation.metadata?.description ?? '',
+ builtIn: false,
+ playbackMode: animation.playbackMode,
+ steps,
+ blink: animation.blink,
+ }
+}
+
+export type ImportedAvatarDefinition = {
+ avatar: StudioAvatar
+ expressions: Expression[]
+ sequences: AvatarSequence[]
+}
+
+/** Throws when the file is not a valid v1 avatar definition. */
+export const studioAvatarFromDefinition = (value: unknown): ImportedAvatarDefinition => {
+ const result = validateAvatarDefinition(value)
+ if (!result.ok) {
+ const first = result.errors[0]
+ throw new Error(first ? `${first.path}: ${first.message}` : 'Invalid avatar definition')
+ }
+ const definition: AvatarDefinition = result.value
+ const slug = slugify(definition.name ?? 'avatar')
+
+ // `neutral` is reserved: the studio does not keep it as an editable expression,
+ // it lives on the avatar as eye defaults and is re-emitted on export.
+ const keys = definition.expressionOrder.filter(
+ key => key !== 'neutral' && definition.expressions[key]
+ )
+ const expressions = keys.map(key => toExpression(slug, key, definition.expressions[key]!))
+ const expressionIdByKey = new Map(keys.map((key, i) => [key, expressions[i]!.id]))
+
+ const sequences = definition.animationOrder
+ .filter(key => definition.animations[key])
+ .map(key => toSequence(slug, key, definition.animations[key]!, expressionIdByKey))
+
+ // The studio stores one flat set of eye defaults per avatar; `neutral` is the
+ // resting pose, so it is the expression those defaults come from.
+ const neutral = definition.expressions.neutral
+ const avatar: StudioAvatar = {
+ id: `avatar-${slug}-${definition.body.nodes.length}-${expressions.length}`,
+ name: definition.name ?? slug,
+ body: {
+ primary: definition.body.primary,
+ // Definition nodes are anonymous; the studio addresses them by id in the editor.
+ nodes: definition.body.nodes.map((node, index) => ({
+ id: `shape-${slug}-${index}`,
+ name: `${node.surface.type} ${index + 1}`,
+ surface: node.surface,
+ position: node.position,
+ rotation: node.rotation,
+ })),
+ },
+ colors: definition.colors,
+ eyes: {
+ widthLeft: neutral.eyes.left.width,
+ widthRight: neutral.eyes.right.width,
+ heightLeft: neutral.eyes.left.height,
+ heightRight: neutral.eyes.right.height,
+ spacing: neutral.eyes.spacing,
+ positionXLeft: neutral.eyes.left.x,
+ positionXRight: neutral.eyes.right.x,
+ positionYLeft: neutral.eyes.left.y,
+ positionYRight: neutral.eyes.right.y,
+ leftAngle: neutral.eyes.left.angle,
+ rightAngle: neutral.eyes.right.angle,
+ },
+ renderStyle: { type: 'vector' },
+ behavior: { expressions, sequences },
+ }
+
+ return { avatar, expressions, sequences }
+}
diff --git a/src/features/studio/components/AvatarDrawer.tsx b/src/features/studio/components/AvatarDrawer.tsx
index f244ca9..db66c62 100644
--- a/src/features/studio/components/AvatarDrawer.tsx
+++ b/src/features/studio/components/AvatarDrawer.tsx
@@ -1,7 +1,8 @@
-import { Copy, Pencil, Plus, Trash2 } from 'lucide-react'
+import { Copy, Pencil, Plus, Trash2, Upload } from 'lucide-react'
import { motion } from 'motion/react'
import { Button } from '@/components/ui/button'
+import { Menu, MenuContent, MenuItem, MenuTrigger } from '@/components/ui/menu'
import {
ContextMenu,
ContextMenuContent,
@@ -24,12 +25,15 @@ export function AvatarPage({ controller }: { controller: StudioController }) {
avatarsRef,
cancelAvatarMove,
commitAvatarMove,
+ avatarImportRef,
createNewAvatar,
draggedAvatarId,
draggingAvatarId,
duplicateAvatar,
expressions,
+ prepareStudioProjectImport,
previewAvatarMove,
+ projectImportError,
reduceMotion,
setDeleteAvatarOpen,
setDraggingAvatarId,
@@ -133,15 +137,44 @@ export function AvatarPage({ controller }: { controller: StudioController }) {
))}
-
+
+ {
+ prepareStudioProjectImport(event.currentTarget.files?.[0])
+ event.currentTarget.value = ''
+ }}
+ />
+ {projectImportError && (
+
+ {projectImportError}
+
+ )}
)
diff --git a/src/features/studio/components/StudioDialogs.tsx b/src/features/studio/components/StudioDialogs.tsx
index caa6ff6..0c32ba9 100644
--- a/src/features/studio/components/StudioDialogs.tsx
+++ b/src/features/studio/components/StudioDialogs.tsx
@@ -108,11 +108,19 @@ export function StudioDialogs({ controller }: { controller: StudioController })
>
- {t('Importer ce projet ?')}
+
+ {pendingProjectImport?.kind === 'avatar'
+ ? t('Importer cet avatar ?')
+ : t('Importer ce projet ?')}
+
- {t(
- 'Le projet local actuel sera remplacé par les avatars, expressions, animations et état de lecture de ce fichier.'
- )}{' '}
+ {pendingProjectImport?.kind === 'avatar'
+ ? t(
+ 'Cet avatar sera ajouté à ta bibliothèque avec ses expressions et animations, puis sélectionné. Les autres avatars sont conservés.'
+ )
+ : t(
+ 'Le projet local actuel sera remplacé par les avatars, expressions, animations et état de lecture de ce fichier.'
+ )}{' '}
{pendingProjectImport?.fileName}
diff --git a/src/features/studio/components/StudioInspector.tsx b/src/features/studio/components/StudioInspector.tsx
index 7a8cee3..7ae7bf6 100644
--- a/src/features/studio/components/StudioInspector.tsx
+++ b/src/features/studio/components/StudioInspector.tsx
@@ -1065,6 +1065,17 @@ export function StudioInspector({ controller }: { controller: StudioController }
{t('Dupliquer')}
+
diff --git a/src/features/studio/useStudioController.ts b/src/features/studio/useStudioController.ts
index 044af14..292c712 100644
--- a/src/features/studio/useStudioController.ts
+++ b/src/features/studio/useStudioController.ts
@@ -64,6 +64,10 @@ import {
type AvatarEyeDefaults,
type StudioAvatar,
} from '@/features/avatar/avatars'
+import {
+ isAvatarDefinitionSource,
+ studioAvatarFromDefinition,
+} from '@/features/avatar/importAvatarDefinition'
import {
bodyPrimitiveTypes,
createBodyNode,
@@ -207,9 +211,11 @@ export function useStudioController() {
const [pendingProjectImport, setPendingProjectImport] = useState<{
document: StudioDocument
fileName: string
+ kind: 'project' | 'avatar'
} | null>(null)
const [projectImportError, setProjectImportError] = useState(null)
const projectImportRef = useRef(null)
+ const avatarImportRef = useRef(null)
const [statePlayerExpanded, setStatePlayerExpanded] = useState(false)
const [activeExpression, setActiveExpression] = useState(null)
const [editing, setEditing] = useState<{ index: number | null; draft: Expression } | null>(null)
@@ -1809,19 +1815,41 @@ export function useStudioController() {
setProjectImportError(null)
if (file.size > 10_000_000) {
setProjectImportError(
- t('Ce fichier ne contient pas un projet Avatar Studio valide et compatible.')
+ t('Ce fichier n’est ni un avatar .avatar.json ni un projet Avatar Studio valide.')
)
return
}
file
.text()
.then(source => {
+ // One picker, two formats: a `.avatar.json` definition adds a single avatar
+ // to the current library, a studio project replaces the whole document.
+ const parsed: unknown = JSON.parse(source)
+ if (isAvatarDefinitionSource(parsed)) {
+ const { avatar } = studioAvatarFromDefinition(parsed)
+ const document = currentStudioDocument()
+ setPendingProjectImport({
+ document: {
+ ...document,
+ library: {
+ activeAvatarId: avatar.id,
+ avatars: [
+ ...document.library.avatars.filter(existing => existing.id !== avatar.id),
+ avatar,
+ ],
+ },
+ },
+ fileName: file.name,
+ kind: 'avatar',
+ })
+ return
+ }
const imported = parseImportedStudioDocument(source, currentStudioDocument())
- setPendingProjectImport({ document: imported, fileName: file.name })
+ setPendingProjectImport({ document: imported, fileName: file.name, kind: 'project' })
})
.catch(() => {
setProjectImportError(
- t('Ce fichier ne contient pas un projet Avatar Studio valide et compatible.')
+ t('Ce fichier n’est ni un avatar .avatar.json ni un projet Avatar Studio valide.')
)
})
}
@@ -1995,6 +2023,7 @@ export function useStudioController() {
previewSelectedBodyNode,
previewStateMove,
projectImportError,
+ avatarImportRef,
projectImportRef,
reduceMotion,
renameActiveAvatar,
diff --git a/src/i18n/index.ts b/src/i18n/index.ts
index c3cac44..9cd76e8 100644
--- a/src/i18n/index.ts
+++ b/src/i18n/index.ts
@@ -392,8 +392,13 @@ const english: Record = {
'Transfer every avatar, expression and animation to another browser.',
'Télécharger le projet JSON': 'Download JSON project',
'Importer un projet JSON': 'Import JSON project',
- 'Ce fichier ne contient pas un projet Avatar Studio valide et compatible.':
- 'This file does not contain a valid, compatible Avatar Studio project.',
+ 'Ce fichier n’est ni un avatar .avatar.json ni un projet Avatar Studio valide.':
+ 'This file is neither a .avatar.json avatar nor a valid Avatar Studio project.',
+ 'Ajouter un avatar': 'Add an avatar',
+ 'Importer un .avatar.json': 'Import a .avatar.json',
+ 'Importer cet avatar ?': 'Import this avatar?',
+ 'Cet avatar sera ajouté à ta bibliothèque avec ses expressions et animations, puis sélectionné. Les autres avatars sont conservés.':
+ 'This avatar will be added to your library with its expressions and animations, then selected. Your other avatars are kept.',
'Importer ce projet ?': 'Import this project?',
'Le projet local actuel sera remplacé par les avatars, expressions, animations et état de lecture de ce fichier.':
'The current local project will be replaced with the avatars, expressions, animations and playback state from this file.',
diff --git a/src/i18n/zh.ts b/src/i18n/zh.ts
index c13d474..1f97bf2 100644
--- a/src/i18n/zh.ts
+++ b/src/i18n/zh.ts
@@ -352,8 +352,13 @@ export const chinese: Record = {
'将所有头像、表情和动画传输到另一个浏览器。',
'Télécharger le projet JSON': '下载 JSON 项目',
'Importer un projet JSON': '导入 JSON 项目',
- 'Ce fichier ne contient pas un projet Avatar Studio valide et compatible.':
- '此文件不包含有效且兼容的头像工作室项目。',
+ 'Ce fichier n’est ni un avatar .avatar.json ni un projet Avatar Studio valide.':
+ '此文件既不是 .avatar.json 头像,也不是有效的头像工作室项目。',
+ 'Ajouter un avatar': '添加头像',
+ 'Importer un .avatar.json': '导入 .avatar.json',
+ 'Importer cet avatar ?': '导入此头像?',
+ 'Cet avatar sera ajouté à ta bibliothèque avec ses expressions et animations, puis sélectionné. Les autres avatars sont conservés.':
+ '该头像及其表情和动画将被添加到你的库中并选中,其他头像会保留。',
'Importer ce projet ?': '导入此项目?',
'Le projet local actuel sera remplacé par les avatars, expressions, animations et état de lecture de ce fichier.':
'当前本地项目将被此文件中的头像、表情、动画和播放状态替换。',