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 { motion } from 'motion/react'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Menu, MenuContent, MenuItem, MenuTrigger } from '@/components/ui/menu'
import { import {
ContextMenu, ContextMenu,
ContextMenuContent, ContextMenuContent,
@ -24,12 +25,15 @@ export function AvatarPage({ controller }: { controller: StudioController }) {
avatarsRef, avatarsRef,
cancelAvatarMove, cancelAvatarMove,
commitAvatarMove, commitAvatarMove,
avatarImportRef,
createNewAvatar, createNewAvatar,
draggedAvatarId, draggedAvatarId,
draggingAvatarId, draggingAvatarId,
duplicateAvatar, duplicateAvatar,
expressions, expressions,
prepareStudioProjectImport,
previewAvatarMove, previewAvatarMove,
projectImportError,
reduceMotion, reduceMotion,
setDeleteAvatarOpen, setDeleteAvatarOpen,
setDraggingAvatarId, setDraggingAvatarId,
@ -133,15 +137,44 @@ export function AvatarPage({ controller }: { controller: StudioController }) {
</ContextMenu> </ContextMenu>
</motion.div> </motion.div>
))} ))}
<Button <Menu>
variant="outline" <MenuTrigger
className="avatar-add creation-card" render={
onClick={createNewAvatar} <Button
aria-label={t('Nouvel avatar')} variant="outline"
> className="avatar-add creation-card"
<Plus /> aria-label={t('Ajouter un avatar')}
</Button> >
<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> </div>
{projectImportError && (
<p className="project-transfer-error" role="alert">
{projectImportError}
</p>
)}
</section> </section>
</div> </div>
) )

View File

@ -108,11 +108,19 @@ export function StudioDialogs({ controller }: { controller: StudioController })
> >
<AlertDialogContent> <AlertDialogContent>
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle>{t('Importer ce projet ?')}</AlertDialogTitle> <AlertDialogTitle>
{pendingProjectImport?.kind === 'avatar'
? t('Importer cet avatar ?')
: t('Importer ce projet ?')}
</AlertDialogTitle>
<AlertDialogDescription> <AlertDialogDescription>
{t( {pendingProjectImport?.kind === 'avatar'
'Le projet local actuel sera remplacé par les avatars, expressions, animations et état de lecture de ce fichier.' ? 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} {pendingProjectImport?.fileName}
</AlertDialogDescription> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>

View File

@ -1065,6 +1065,17 @@ export function StudioInspector({ controller }: { controller: StudioController }
<Copy /> <Copy />
{t('Dupliquer')} {t('Dupliquer')}
</Button> </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> </div>
<Button onClick={saveAvatarEditing}>{t('Enregistrer')}</Button> <Button onClick={saveAvatarEditing}>{t('Enregistrer')}</Button>
</footer> </footer>

View File

@ -64,6 +64,10 @@ import {
type AvatarEyeDefaults, type AvatarEyeDefaults,
type StudioAvatar, type StudioAvatar,
} from '@/features/avatar/avatars' } from '@/features/avatar/avatars'
import {
isAvatarDefinitionSource,
studioAvatarFromDefinition,
} from '@/features/avatar/importAvatarDefinition'
import { import {
bodyPrimitiveTypes, bodyPrimitiveTypes,
createBodyNode, createBodyNode,
@ -207,9 +211,11 @@ export function useStudioController() {
const [pendingProjectImport, setPendingProjectImport] = useState<{ const [pendingProjectImport, setPendingProjectImport] = useState<{
document: StudioDocument document: StudioDocument
fileName: string fileName: string
kind: 'project' | 'avatar'
} | null>(null) } | null>(null)
const [projectImportError, setProjectImportError] = useState<string | null>(null) const [projectImportError, setProjectImportError] = useState<string | null>(null)
const projectImportRef = useRef<HTMLInputElement>(null) const projectImportRef = useRef<HTMLInputElement>(null)
const avatarImportRef = useRef<HTMLInputElement>(null)
const [statePlayerExpanded, setStatePlayerExpanded] = useState(false) const [statePlayerExpanded, setStatePlayerExpanded] = useState(false)
const [activeExpression, setActiveExpression] = useState<number | null>(null) const [activeExpression, setActiveExpression] = useState<number | null>(null)
const [editing, setEditing] = useState<{ index: number | null; draft: Expression } | null>(null) const [editing, setEditing] = useState<{ index: number | null; draft: Expression } | null>(null)
@ -1809,19 +1815,41 @@ export function useStudioController() {
setProjectImportError(null) setProjectImportError(null)
if (file.size > 10_000_000) { if (file.size > 10_000_000) {
setProjectImportError( 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 return
} }
file file
.text() .text()
.then(source => { .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()) const imported = parseImportedStudioDocument(source, currentStudioDocument())
setPendingProjectImport({ document: imported, fileName: file.name }) setPendingProjectImport({ document: imported, fileName: file.name, kind: 'project' })
}) })
.catch(() => { .catch(() => {
setProjectImportError( 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, previewSelectedBodyNode,
previewStateMove, previewStateMove,
projectImportError, projectImportError,
avatarImportRef,
projectImportRef, projectImportRef,
reduceMotion, reduceMotion,
renameActiveAvatar, renameActiveAvatar,

View File

@ -392,8 +392,13 @@ const english: Record<string, string> = {
'Transfer every avatar, expression and animation to another browser.', 'Transfer every avatar, expression and animation to another browser.',
'Télécharger le projet JSON': 'Download JSON project', 'Télécharger le projet JSON': 'Download JSON project',
'Importer un projet JSON': 'Import JSON project', 'Importer un projet JSON': 'Import JSON project',
'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.':
'This file does not contain a valid, compatible Avatar Studio project.', '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?', 'Importer ce projet ?': 'Import this project?',
'Le projet local actuel sera remplacé par les avatars, expressions, animations et état de lecture de ce fichier.': '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.', '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 项目', 'Télécharger le projet JSON': '下载 JSON 项目',
'Importer un 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 ?': '导入此项目?', 'Importer ce projet ?': '导入此项目?',
'Le projet local actuel sera remplacé par les avatars, expressions, animations et état de lecture de ce fichier.': 'Le projet local actuel sera remplacé par les avatars, expressions, animations et état de lecture de ce fichier.':
'当前本地项目将被此文件中的头像、表情、动画和播放状态替换。', '当前本地项目将被此文件中的头像、表情、动画和播放状态替换。',