Merge pull request #3 from leo-combaret/feat/import-avatar-definition

feat(studio): import .avatar.json definitions into the library
This commit is contained in:
Montlouis-Calixte Stéphane 2026-08-25 13:07:58 +02:00 committed by GitHub
commit 0097f340f5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 356 additions and 20 deletions

View File

@ -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 <MenuPrimitive.Root {...props} />
}
function MenuTrigger(props: MenuPrimitive.Trigger.Props) {
return <MenuPrimitive.Trigger {...props} />
}
function MenuContent({ className, ...props }: MenuPrimitive.Popup.Props) {
return (
<MenuPrimitive.Portal>
<MenuPrimitive.Positioner className="z-50" sideOffset={6}>
<MenuPrimitive.Popup
className={cn(
'min-w-56 rounded-lg border border-border bg-popover p-1 text-popover-foreground shadow-xl outline-none',
className
)}
{...props}
/>
</MenuPrimitive.Positioner>
</MenuPrimitive.Portal>
)
}
function MenuItem({ className, ...props }: MenuPrimitive.Item.Props) {
return (
<MenuPrimitive.Item
className={cn(
'flex cursor-default items-center gap-2 rounded-md px-2.5 py-2 text-sm outline-none select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[highlighted]:bg-muted',
className
)}
{...props}
/>
)
}
export { Menu, MenuContent, MenuItem, MenuTrigger }

View File

@ -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)
})
})

View File

@ -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<string, string>
): 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 }
}

View File

@ -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 }) {
</ContextMenu>
</motion.div>
))}
<Menu>
<MenuTrigger
render={
<Button
variant="outline"
className="avatar-add creation-card"
onClick={createNewAvatar}
aria-label={t('Nouvel avatar')}
aria-label={t('Ajouter un avatar')}
>
<Plus />
</Button>
}
/>
<MenuContent>
<MenuItem onClick={createNewAvatar}>
<Plus /> {t('Nouvel avatar')}
</MenuItem>
<MenuItem onClick={() => avatarImportRef.current?.click()}>
<Upload /> {t('Importer un .avatar.json')}
</MenuItem>
</MenuContent>
</Menu>
<input
ref={avatarImportRef}
className="project-import-input"
type="file"
accept="application/json,.json,.avatar.json"
aria-label={t('Importer un .avatar.json')}
onChange={event => {
prepareStudioProjectImport(event.currentTarget.files?.[0])
event.currentTarget.value = ''
}}
/>
</div>
{projectImportError && (
<p className="project-transfer-error" role="alert">
{projectImportError}
</p>
)}
</section>
</div>
)

View File

@ -108,9 +108,17 @@ export function StudioDialogs({ controller }: { controller: StudioController })
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t('Importer ce projet ?')}</AlertDialogTitle>
<AlertDialogTitle>
{pendingProjectImport?.kind === 'avatar'
? t('Importer cet avatar ?')
: t('Importer ce projet ?')}
</AlertDialogTitle>
<AlertDialogDescription>
{t(
{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}

View File

@ -1065,6 +1065,17 @@ export function StudioInspector({ controller }: { controller: StudioController }
<Copy />
{t('Dupliquer')}
</Button>
<Button
variant="outline"
disabled={!runtimeDefinitionResult.ok}
// downloadAvatarRuntimeDefinition is a no-op on an invalid
// definition, so surface why the button is dead.
title={runtimeExportErrors[0] ?? undefined}
onClick={downloadAvatarRuntimeDefinition}
>
<Download />
{t('Exporter')}
</Button>
</div>
<Button onClick={saveAvatarEditing}>{t('Enregistrer')}</Button>
</footer>

View File

@ -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<string | null>(null)
const projectImportRef = useRef<HTMLInputElement>(null)
const avatarImportRef = useRef<HTMLInputElement>(null)
const [statePlayerExpanded, setStatePlayerExpanded] = useState(false)
const [activeExpression, setActiveExpression] = useState<number | null>(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,

View File

@ -392,8 +392,13 @@ const english: Record<string, string> = {
'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.',

View File

@ -352,8 +352,13 @@ export const chinese: Record<string, string> = {
'将所有头像、表情和动画传输到另一个浏览器。',
'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.':
'当前本地项目将被此文件中的头像、表情、动画和播放状态替换。',