From d6fcf63fe641f8e13dad9d9fd6146291a89b639a Mon Sep 17 00:00:00 2001 From: sandevistan Date: Thu, 20 Aug 2026 23:11:43 +0200 Subject: [PATCH] feat(studio): import .avatar.json definitions into the library The studio could only write runtime definitions: createAvatarDefinition is one-way, and the sole file picker fed everything to parseImportedStudioDocument, which rejects a definition for lacking version/library/expressions/sequences. There was no way to bring an exported avatar back in. Add the inverse mapping and wire it into the avatar library: - studioAvatarFromDefinition() rebuilds a StudioAvatar, its expressions and its sequences from a definition. `neutral` is deliberately not restored as an expression: it is a reserved semantic key that lives on the avatar as eye defaults and is re-emitted on export. - The import picker now sniffs the format. A definition adds one avatar and keeps the rest of the library; a studio project still replaces the document. - Avatars tab: the "+" card opens a menu offering a new avatar or an import. - Avatar editor: an Exporter button beside Dupliquer downloads the avatar as .avatar.json, disabled with the validation error as a tooltip when the definition would not validate. - Menu primitive built on the existing @base-ui/react dependency, matching context-menu.tsx. No new packages. The round-trip test imports the repo's strobi.avatar.json fixture and exports it again, asserting identical body, colors, expressions and ordering, so the two directions cannot drift. Co-Authored-By: Claude Opus 5 (1M context) --- src/components/ui/menu.tsx | 41 +++++ .../import-avatar-definition-test.ts | 43 +++++ src/features/avatar/importAvatarDefinition.ts | 161 ++++++++++++++++++ .../studio/components/AvatarDrawer.tsx | 51 +++++- .../studio/components/StudioDialogs.tsx | 16 +- .../studio/components/StudioInspector.tsx | 11 ++ src/features/studio/useStudioController.ts | 35 +++- src/i18n/index.ts | 9 +- src/i18n/zh.ts | 9 +- 9 files changed, 356 insertions(+), 20 deletions(-) create mode 100644 src/components/ui/menu.tsx create mode 100644 src/features/avatar/__tests__/import-avatar-definition-test.ts create mode 100644 src/features/avatar/importAvatarDefinition.ts 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 }) { ))} - + + + + + } + /> + + + {t('Nouvel avatar')} + + avatarImportRef.current?.click()}> + {t('Importer un .avatar.json')} + + + + { + 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.': '当前本地项目将被此文件中的头像、表情、动画和播放状态替换。',