bible-strong-avatar-lab/src/features/studio/components/StudioInspector.tsx
sandevistan d6fcf63fe6
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) <noreply@anthropic.com>
2026-08-20 23:11:43 +02:00

2172 lines
90 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import {
ArrowLeft,
ArrowRight,
Camera,
Check,
ChevronDown,
ChevronUp,
Copy,
Download,
FileCode2,
Move3D,
Pencil,
Play,
Plus,
RotateCcw,
Scan,
Shuffle,
Smile,
Trash2,
TriangleAlert,
Upload,
} from 'lucide-react'
import { AnimatePresence, animate, motion, useMotionValue, useTransform } from 'motion/react'
import { type CSSProperties, useEffect, useLayoutEffect, useRef, useState } from 'react'
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from '@/components/ui/accordion'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger,
} from '@/components/ui/context-menu'
import { Field, FieldTitle } from '@/components/ui/field'
import { Drawer } from '@/components/ui/drawer'
import { Input } from '@/components/ui/input'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Separator } from '@/components/ui/separator'
import { Switch } from '@/components/ui/switch'
import {
ControlSection,
ExportSection,
InspectorCard,
PanelTitle,
StatePlayer,
} from '@/app/components/common'
import { ColorField, LinkButton, NumericField } from '@/app/components/controls'
import {
COPY_FEEDBACK_DURATION_MS,
formatSeconds,
type Side,
type SnapshotFormat,
} from '@/app/studio-utils'
import { SequenceWorkspace } from '@/features/animation/components/SequenceWorkspace'
import { findExpressionIndex, groupSequences } from '@/features/animation/sequences'
import { defaultAvatarEyes } from '@/features/avatar/avatars'
import {
ExpressionCard,
ExpressionPreview,
ExpressionWorkspace,
} from '@/features/avatar/components/ExpressionWorkspace'
import { defaultExpression } from '@/features/avatar/presets'
import { randomSnapshotPalette } from '@/features/export/snapshotPalette'
import { type SnapshotBackground } from '@/features/export/snapshotExporter'
import { AvatarPage } from '@/features/studio/components/AvatarDrawer'
import { BodyConstructionAccordion } from '@/features/studio/components/BodyConstructionAccordion'
import { HighlightedRuntimeCode } from '@/features/studio/components/HighlightedRuntimeCode'
import {
buildRuntimeGuideText,
RuntimeGuideDialog,
} from '@/features/studio/components/RuntimeGuideDialog'
import { RuntimePreviewDialog } from '@/features/studio/components/RuntimePreviewDialog'
import { StudioIdentity } from '@/features/studio/components/StudioIdentity'
import type { StudioController } from '@/features/studio/useStudioController'
const reactQuickStartInstall = 'npm install @bible-strong/avatar-react react react-dom'
const webQuickStartInstall = 'npm install @bible-strong/avatar-web'
const reactQuickStartExample = (animationKey: string | undefined) =>
`import { createAvatar } from '@bible-strong/avatar-react'
import '@bible-strong/avatar-react/styles.css'
import definition from './avatar.avatar.json'
const Avatar = createAvatar(definition)
export function App() {
return <Avatar ${animationKey ? `defaultAnimation="${animationKey}"` : 'defaultExpression="neutral"'} />
}`
const webQuickStartExample = (animationKey: string | undefined) =>
`import { createAvatar } from '@bible-strong/avatar-web'
import definition from './avatar.avatar.json'
const avatar = createAvatar('#avatar', {
definition,
${animationKey ? `defaultAnimation: '${animationKey}',` : `defaultExpression: 'neutral',`}
})`
function PoseControls({ controller }: { controller: StudioController }) {
const {
activeAvatar,
expression,
linked,
setLinked,
showWire,
t,
updateDimension,
updateHighlight,
updateImmediate,
updateSize,
updateSpacing,
updateWireVisibility,
} = controller
return (
<>
<ControlSection title="Corps" subtitle="Orientation et apparence générale de la pose.">
<InspectorCard className="color-panel">
<PanelTitle
level={3}
title="Couleur du corps"
subtitle="La pose peut remplacer temporairement la couleur de l’avatar."
/>
<ColorField
label="Corps"
value={expression.bodyColor ?? activeAvatar.colors.body}
onChange={bodyColor => updateImmediate({ ...expression, bodyColor })}
/>
{expression.bodyColor && (
<Button
className="inherit-colors"
variant="ghost"
size="icon-sm"
aria-label={t('Reprendre la couleur de l’avatar')}
onClick={() => {
const next = { ...expression }
delete next.bodyColor
updateImmediate(next)
}}
>
<RotateCcw />
</Button>
)}
</InspectorCard>
<InspectorCard>
<PanelTitle
level={3}
title="Rotation de la tête"
subtitle="Les libellés ↔ sont scrubbables, comme dans Figma."
/>
<NumericField
label="Rotation X"
value={expression.headX}
unit="°"
onActiveChange={active => updateHighlight(active ? 'head' : null)}
onChange={value => updateImmediate({ ...expression, headX: value })}
/>
<NumericField
label="Rotation Y"
value={expression.headY}
unit="°"
onActiveChange={active => updateHighlight(active ? 'head' : null)}
onChange={value => updateImmediate({ ...expression, headY: value })}
/>
<NumericField
label="Rotation Z"
value={expression.headZ}
unit="°"
onActiveChange={active => updateHighlight(active ? 'head' : null)}
onChange={value => updateImmediate({ ...expression, headZ: value })}
/>
</InspectorCard>
</ControlSection>
<ControlSection title="Yeux" subtitle="Forme, placement, orientation et couleur du regard.">
<InspectorCard className="color-panel">
<PanelTitle
level={3}
title="Couleur des yeux"
subtitle="La pose peut remplacer temporairement la couleur de l’avatar."
/>
<ColorField
label="Yeux"
value={expression.eyeColor ?? activeAvatar.colors.eyes}
onChange={eyeColor => updateImmediate({ ...expression, eyeColor })}
/>
{expression.eyeColor && (
<Button
className="inherit-colors"
variant="ghost"
size="icon-sm"
aria-label={t('Reprendre la couleur de l’avatar')}
onClick={() => {
const next = { ...expression }
delete next.eyeColor
updateImmediate(next)
}}
>
<RotateCcw />
</Button>
)}
</InspectorCard>
{(['width', 'height', 'size'] as const).map(dimension => (
<InspectorCard className="compact" key={dimension}>
<div className="panel-inline-title">
<h3>
{t(
{
width: 'Largeur',
height: 'Hauteur',
size: 'Taille proportionnelle',
}[dimension]
)}
</h3>
<LinkButton
linked={linked[dimension]}
label={`Lier ${dimension}`}
onClick={() =>
setLinked(current => ({
...current,
[dimension]: !current[dimension],
}))
}
/>
</div>
<div className="eye-columns">
{(['Left', 'Right'] as Side[]).map(side => {
const width = expression[`width${side}`]
const height = expression[`height${side}`]
const value =
dimension === 'width'
? width
: dimension === 'height'
? height
: Math.max(width, height)
return (
<NumericField
key={side}
label={side === 'Left' ? 'Œil gauche' : 'Œil droit'}
value={value}
min={10}
max={dimension === 'size' ? 110 : 100}
unit="u"
onActiveChange={active =>
updateHighlight(
active
? linked[dimension]
? 'both'
: side === 'Left'
? 'left'
: 'right'
: null
)
}
onChange={next =>
dimension === 'size'
? updateSize(side, next)
: updateDimension(side, dimension, next)
}
/>
)
})}
</div>
</InspectorCard>
))}
<InspectorCard>
<PanelTitle
level={3}
title="Position et espacement"
subtitle="Coordonnées communes projetées sur la forme choisie."
/>
<div className="eye-columns">
<div className="eye-column">
<h3>{t('Œil gauche')}</h3>
<NumericField
label="Horizontale"
value={expression.positionXLeft}
unit="u"
onActiveChange={active => updateHighlight(active ? 'left' : null)}
onChange={value => updateImmediate({ ...expression, positionXLeft: value })}
/>
<NumericField
label="Verticale"
value={expression.positionYLeft}
unit="u"
onActiveChange={active => updateHighlight(active ? 'left' : null)}
onChange={value => updateImmediate({ ...expression, positionYLeft: value })}
/>
</div>
<div className="eye-column">
<h3>{t('Œil droit')}</h3>
<NumericField
label="Horizontale"
value={expression.positionXRight}
unit="u"
onActiveChange={active => updateHighlight(active ? 'right' : null)}
onChange={value => updateImmediate({ ...expression, positionXRight: value })}
/>
<NumericField
label="Verticale"
value={expression.positionYRight}
unit="u"
onActiveChange={active => updateHighlight(active ? 'right' : null)}
onChange={value => updateImmediate({ ...expression, positionYRight: value })}
/>
</div>
</div>
<div className="position-spacing">
<NumericField
label="Espacement"
value={expression.spacing}
min={0}
max={150}
unit="u"
onActiveChange={active => updateHighlight(active ? 'both' : null)}
onChange={updateSpacing}
/>
</div>
</InspectorCard>
<InspectorCard>
<div className="panel-inline-title">
<PanelTitle
level={3}
title="Rotation locale"
subtitle="Inclinaison propre à chaque œil."
/>
<LinkButton
linked={linked.rotation}
label="Lier les rotations"
onClick={() => setLinked(current => ({ ...current, rotation: !current.rotation }))}
/>
</div>
<div className="eye-columns">
<NumericField
label="Œil gauche"
value={expression.leftAngle}
unit="°"
onActiveChange={active =>
updateHighlight(active ? (linked.rotation ? 'both' : 'left') : null)
}
onChange={value =>
updateImmediate({
...expression,
leftAngle: value,
...(linked.rotation ? { rightAngle: -value } : {}),
})
}
/>
<NumericField
label="Œil droit"
value={expression.rightAngle}
unit="°"
onActiveChange={active =>
updateHighlight(active ? (linked.rotation ? 'both' : 'right') : null)
}
onChange={value =>
updateImmediate({
...expression,
rightAngle: value,
...(linked.rotation ? { leftAngle: -value } : {}),
})
}
/>
</div>
</InspectorCard>
</ControlSection>
<ControlSection
title="Projection"
subtitle="Perspective et repères appliqués à la surface active."
>
<InspectorCard>
<PanelTitle level={3} title="Perspective" subtitle="Profondeur simulée du visage." />
<NumericField
label="Perspective"
value={expression.perspective}
step={0.01}
unit="×"
onChange={value => updateImmediate({ ...expression, perspective: value })}
/>
<div className="switch">
<span>{t('Afficher le maillage')}</span>
<Switch
checked={showWire}
onCheckedChange={updateWireVisibility}
aria-label={t('Afficher le maillage')}
/>
</div>
</InspectorCard>
</ControlSection>
</>
)
}
export function StudioInspector({ controller }: { controller: StudioController }) {
const [runtimePreviewOpen, setRuntimePreviewOpen] = useState(false)
const [guideOpen, setGuideOpen] = useState(false)
const [guideCopyFeedback, setGuideCopyFeedback] = useState<{
format: 'react' | 'javascript'
status: 'success' | 'error'
} | null>(null)
useEffect(() => {
if (!guideCopyFeedback) return
const timeout = window.setTimeout(() => setGuideCopyFeedback(null), COPY_FEEDBACK_DURATION_MS)
return () => window.clearTimeout(timeout)
}, [guideCopyFeedback])
const [exportAnimationsOpen, setExportAnimationsOpen] = useState(false)
const {
activateAvatar,
activeAvatar,
activeAvatarEyes,
activeAvatarId,
activeExpression,
activeSequence,
activeSequenceLabel,
activeState,
avatarDragOrigin,
avatarDragPreview,
avatars,
avatarsRef,
blink,
bodyEditing,
bodyNodes,
cancelAvatarEditing,
cancelAvatarMove,
cancelExpressionEditing,
cancelExpressionMove,
cancelSequenceEditing,
cancelStateMove,
commitAvatarMove,
commitExpressionMove,
commitStateMove,
copyAvatarRuntimeDefinition,
createNewAvatar,
downloadAvatarExport,
downloadAvatarRuntimeDefinition,
downloadStudioProject,
draggedAvatarId,
draggedExpressionId,
draggedStateId,
draggingAvatarId,
draggingExpressionId,
draggingStateId,
duplicateAvatar,
duplicateExpression,
duplicateSequenceEditing,
duplicateState,
editing,
editorPageOpen,
exportAnimationIdSet,
exportFormat,
expression,
expressionById,
expressionSemanticKeyError,
expressionDragOrigin,
expressionDragPreview,
expressions,
focusAvatarName,
language,
launchSequence,
linked,
mode,
openExpressionEditor,
openPhotoMode,
openSequenceEditor,
pauseState,
photoPanelSections,
photoTool,
playbackStatus,
playbackVisual,
prepareStudioProjectImport,
previewAvatarMove,
previewExpressionDraft,
previewExpressionMove,
previewStateMove,
projectImportError,
projectImportRef,
reduceMotion,
renameActiveAvatar,
runtimeDefinitionResult,
runtimeCopyStatus,
runtimeExportErrors,
saveAvatarEditing,
saveEditing,
saveSequenceEditing,
selectedExportAnimations,
selectedSequenceStepId,
selectedState,
sequenceEditing,
animationSemanticKeyError,
sequences,
setDeleteAvatarOpen,
setDeleteExpressionOpen,
setDeleteSequenceOpen,
setDraggingAvatarId,
setDraggingExpressionId,
setDraggingStateId,
setExportAnimationIds,
setExportFormat,
setFocusAvatarName,
setLinked,
setLanguage,
setMode,
setPhotoPanelSections,
setPhotoTool,
setSelectedSequenceStepId,
setSequenceEditing,
setSnapshotBackground,
setSnapshotColorFrom,
setSnapshotColorTo,
setSnapshotComposition,
setSnapshotFormat,
setSnapshotSize,
setSpringSpeed,
setStatePlayerExpanded,
showWire,
snapshotBackground,
snapshotColorFrom,
snapshotColorTo,
snapshotComposition,
snapshotFormat,
snapshotSize,
springSpeed,
springSpeedRef,
stateDragOrigin,
stateDragPreview,
statePlayerExpanded,
statePlaying,
stopState,
surface,
t,
takePicture,
toggleExportAnimation,
toggleStatePlayback,
transitionToExpression,
updateAvatarColors,
updateAvatarEyeDimension,
updateAvatarEyePosition,
updateAvatarEyeSize,
updateAvatarEyes,
updateDimension,
updateHighlight,
updateImmediate,
updateSize,
updateSpacing,
updateSurface,
updateWireVisibility,
workspaceBackButtonRef,
} = controller
const copyRuntimeGuide = async () => {
if (!navigator.clipboard) {
setGuideCopyFeedback({ format: exportFormat, status: 'error' })
return
}
try {
await navigator.clipboard.writeText(
buildRuntimeGuideText({
animationKey: runtimePreviewAnimation,
integration: exportFormat,
t,
})
)
setGuideCopyFeedback({ format: exportFormat, status: 'success' })
} catch {
setGuideCopyFeedback({ format: exportFormat, status: 'error' })
}
}
const runtimePreviewAnimation = runtimeDefinitionResult.ok
? runtimeDefinitionResult.value.animationOrder[0]
: undefined
const guideCopyStatus =
guideCopyFeedback?.format === exportFormat ? guideCopyFeedback.status : 'idle'
const updateSnapshotComposition = (patch: Partial<typeof snapshotComposition>) =>
setSnapshotComposition(current => ({ ...current, ...patch }))
const playbackFooterY = useMotionValue(0)
const playbackHandleY = useMotionValue(0)
const playbackHandleCounterY = useTransform(playbackHandleY, value => -value)
const playbackFooterDragOriginY = useRef(0)
const playbackDetailsRef = useRef<HTMLDivElement>(null)
const measuredSequenceIdRef = useRef<string | null>(null)
const [playbackFooterCollapsedOffset, setPlaybackFooterCollapsedOffset] = useState(0)
const playbackDetailsOpacity = useTransform(
playbackFooterY,
[0, Math.max(playbackFooterCollapsedOffset, 1)],
[1, 0]
)
useLayoutEffect(() => {
const details = playbackDetailsRef.current
if (!details || !activeSequence) return
const measure = () => {
const nextOffset = details.getBoundingClientRect().height
if (nextOffset <= 0) return
const firstMeasurement = measuredSequenceIdRef.current !== activeSequence.id
if (firstMeasurement) {
measuredSequenceIdRef.current = activeSequence.id
playbackFooterY.set(statePlayerExpanded ? 0 : nextOffset)
} else if (
playbackFooterCollapsedOffset > 0 &&
Math.abs(nextOffset - playbackFooterCollapsedOffset) > 0.5
) {
const progress = Math.min(
1,
Math.max(0, playbackFooterY.get() / playbackFooterCollapsedOffset)
)
playbackFooterY.set(progress * nextOffset)
}
setPlaybackFooterCollapsedOffset(nextOffset)
}
measure()
const observer = new ResizeObserver(measure)
observer.observe(details)
return () => observer.disconnect()
}, [
activeSequence,
language,
playbackFooterCollapsedOffset,
playbackFooterY,
statePlayerExpanded,
])
const snapPlaybackFooter = (expanded: boolean) => {
setStatePlayerExpanded(expanded)
animate(playbackFooterY, expanded ? 0 : playbackFooterCollapsedOffset, {
type: 'spring',
stiffness: 440,
damping: 40,
duration: reduceMotion ? 0 : undefined,
})
}
return (
<Drawer>
<main
className={`inspector ${editing ? 'expression-workspace-active' : sequenceEditing ? 'sequence-workspace-active' : bodyEditing ? 'body-workspace' : 'studio-workspace'}${activeSequence && !editorPageOpen && mode !== 'photo' ? ' state-player-active' : ''}${mode === 'photo' ? ' photo-inspector-active' : ''}`}
>
<StudioIdentity
className="inspector-identity"
language={language}
setLanguage={setLanguage}
t={t}
/>
{sequenceEditing && !editing && (
<motion.div
key={`sequence-${sequenceEditing.sourceId ?? 'new'}`}
className="workspace-page sequence-workspace"
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.22, ease: [0.22, 1, 0.36, 1] }}
>
<SequenceWorkspace
editing={sequenceEditing}
expressions={expressions}
surface={surface}
bodyNodes={bodyNodes}
colors={activeAvatar.colors}
avatarEyes={activeAvatarEyes}
renderStyle={activeAvatar.renderStyle}
selectedStepId={selectedSequenceStepId}
backButtonRef={workspaceBackButtonRef}
reduceMotion={Boolean(reduceMotion)}
onSelectedStepChange={setSelectedSequenceStepId}
onChange={draft =>
setSequenceEditing(current => (current ? { ...current, draft } : current))
}
onPreviewStep={step => {
const expressionIndex = findExpressionIndex(expressions, step.expressionId)
const preset = expressions[expressionIndex]
if (preset) transitionToExpression(preset, expressionIndex, step)
}}
onEditExpression={openExpressionEditor}
onPlay={() => launchSequence(sequenceEditing.draft, false, false)}
onPause={() => pauseState(false)}
onStop={() => stopState(false)}
playing={statePlaying}
active={activeState === sequenceEditing.draft.id}
onCancel={cancelSequenceEditing}
onSave={saveSequenceEditing}
onDuplicate={duplicateSequenceEditing}
onDelete={() => setDeleteSequenceOpen(true)}
semanticKeyError={animationSemanticKeyError(sequenceEditing.draft)}
/>
</motion.div>
)}
{editing && (
<motion.div
key={`expression-${editing.index ?? 'new'}`}
className="workspace-page expression-workspace"
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.22, ease: [0.22, 1, 0.36, 1] }}
>
<ExpressionWorkspace
editing={editing}
avatarColors={activeAvatar.colors}
backButtonRef={workspaceBackButtonRef}
onChange={previewExpressionDraft}
onCancel={cancelExpressionEditing}
onSave={saveEditing}
onDuplicate={() => duplicateExpression(editing.index, editing.draft, true)}
onDelete={() => setDeleteExpressionOpen(true)}
semanticKeyError={expressionSemanticKeyError(editing.draft)}
/>
</motion.div>
)}
{!sequenceEditing &&
!editing &&
(bodyEditing ? (
<header className="workspace-header body-workspace-header">
<Button
ref={workspaceBackButtonRef}
variant="ghost"
size="icon"
onClick={cancelAvatarEditing}
aria-label={t('Retour au studio')}
>
<ArrowLeft />
</Button>
<div className="workspace-heading">
<p className="eyebrow">{t('Construction du corps')}</p>
<Input
className="avatar-name-input"
aria-label={t('Nom de l’avatar')}
autoFocus={focusAvatarName}
value={activeAvatar.name}
onChange={event => renameActiveAvatar(event.currentTarget.value)}
onFocus={event => {
if (focusAvatarName) event.currentTarget.select()
}}
onBlur={event => {
if (!event.currentTarget.value.trim()) renameActiveAvatar('Unknown')
setFocusAvatarName(false)
}}
/>
<p>
{t('Choisis la forme principale puis assemble les primitives autour d’elle.')}
</p>
</div>
</header>
) : null)}
<AnimatePresence mode="wait" initial={false}>
<motion.div
className="mode-page-transition"
key={mode}
initial={reduceMotion ? false : { opacity: 0, x: 18 }}
animate={{ opacity: 1, x: 0 }}
exit={reduceMotion ? { opacity: 1, x: 0 } : { opacity: 0, x: -18 }}
transition={{ duration: 0.18, ease: [0.22, 1, 0.36, 1] }}
>
{!editorPageOpen && (
<header className="mode-page-header">
<div>
<p className="eyebrow">{activeAvatar.name}</p>
<h1>
{t(
mode === 'manual'
? 'Pose'
: mode === 'avatars'
? 'Avatars'
: mode === 'expressions'
? 'Expressions'
: mode === 'states'
? 'Animations'
: mode === 'photo'
? 'Mode photo'
: 'Exporter'
)}
</h1>
</div>
{mode === 'manual' && (
<Button
variant="outline"
size="icon"
type="button"
aria-label={t('Réinitialiser')}
onClick={() => transitionToExpression({ ...defaultExpression })}
>
<RotateCcw />
</Button>
)}
{mode === 'photo' && (
<Button variant="outline" type="button" onClick={() => setMode('export')}>
<ArrowLeft />
{t('Quitter')}
</Button>
)}
</header>
)}
{!editing && mode === 'manual' && (
<div className="panel-stack">
{bodyEditing && (
<>
<ControlSection
title="Corps"
subtitle="Construction, forme et couleur de la tête de l’avatar."
>
<BodyConstructionAccordion
controller={controller}
reduceMotion={Boolean(reduceMotion)}
/>
<InspectorCard className="color-panel">
<PanelTitle
level={3}
title="Couleur du corps"
subtitle="Couleur de base utilisée par les poses et les expressions."
/>
<ColorField
label="Corps"
value={activeAvatar.colors.body}
onChange={body => updateAvatarColors({ body })}
/>
</InspectorCard>
</ControlSection>
<ControlSection
title="Rendu"
subtitle="Le rendu Pixel est temporairement désactivé."
>
<InspectorCard className="render-style-panel render-style-disabled">
<PanelTitle
level={3}
title="Type de rendu"
subtitle="Le mode Vectoriel est utilisé pour l’instant."
/>
<div className="render-style-status">
<Badge variant="secondary">{t('Vectoriel')}</Badge>
</div>
</InspectorCard>
</ControlSection>
<ControlSection
title="Yeux"
subtitle="Forme, placement, orientation et couleur du regard par défaut."
>
<p className="section-description">
{t(
'Définis l’identité du regard de cet avatar. Les poses s’ajoutent ensuite à cette base.'
)}
</p>
{(['width', 'height', 'size'] as const).map(dimension => (
<InspectorCard className="compact" key={`avatar-${dimension}`}>
<div className="panel-inline-title">
<h3>
{t(
{
width: 'Largeur',
height: 'Hauteur',
size: 'Taille proportionnelle',
}[dimension]
)}
</h3>
<LinkButton
linked={linked[dimension]}
label={`Lier ${dimension}`}
onClick={() =>
setLinked(current => ({
...current,
[dimension]: !current[dimension],
}))
}
/>
</div>
<div className="eye-columns">
{(['Left', 'Right'] as Side[]).map(side => {
const width = activeAvatarEyes[`width${side}`]
const height = activeAvatarEyes[`height${side}`]
const value =
dimension === 'width'
? width
: dimension === 'height'
? height
: Math.max(width, height)
return (
<NumericField
key={side}
label={side === 'Left' ? 'Œil gauche' : 'Œil droit'}
value={value}
min={10}
max={dimension === 'size' ? 110 : 100}
unit="u"
onActiveChange={active =>
updateHighlight(
active
? linked[dimension]
? 'both'
: side === 'Left'
? 'left'
: 'right'
: null
)
}
onChange={next =>
dimension === 'size'
? updateAvatarEyeSize(side, next)
: updateAvatarEyeDimension(side, dimension, next)
}
/>
)
})}
</div>
</InspectorCard>
))}
<InspectorCard>
<div className="panel-inline-title">
<div>
<h3>{t('Position et espacement')}</h3>
<p className="panel-inline-subtitle">
{t('Coordonnées propres à l’avatar, indépendantes des poses.')}
</p>
</div>
<LinkButton
linked={linked.position}
label="Lier la position des yeux"
onClick={() =>
setLinked(current => ({ ...current, position: !current.position }))
}
/>
</div>
<div className="eye-columns">
{(['Left', 'Right'] as Side[]).map(side => (
<div className="eye-column" key={side}>
<h3>{t(side === 'Left' ? 'Œil gauche' : 'Œil droit')}</h3>
<NumericField
label="Horizontale"
value={activeAvatarEyes[`positionX${side}`]}
unit="u"
onActiveChange={active =>
updateHighlight(
active
? linked.position
? 'both'
: side === 'Left'
? 'left'
: 'right'
: null
)
}
onChange={value => updateAvatarEyePosition(side, 'X', value)}
/>
<NumericField
label="Verticale"
value={activeAvatarEyes[`positionY${side}`]}
unit="u"
onActiveChange={active =>
updateHighlight(
active
? linked.position
? 'both'
: side === 'Left'
? 'left'
: 'right'
: null
)
}
onChange={value => updateAvatarEyePosition(side, 'Y', value)}
/>
</div>
))}
</div>
<div className="position-spacing">
<NumericField
label="Espacement"
value={activeAvatarEyes.spacing}
min={0}
max={150}
unit="u"
onActiveChange={active => updateHighlight(active ? 'both' : null)}
onChange={spacing => updateAvatarEyes({ spacing })}
/>
</div>
</InspectorCard>
<InspectorCard>
<div className="panel-inline-title">
<PanelTitle
level={3}
title="Rotation locale"
subtitle="Inclinaison par défaut propre à chaque œil."
/>
<LinkButton
linked={linked.rotation}
label="Lier les rotations"
onClick={() =>
setLinked(current => ({ ...current, rotation: !current.rotation }))
}
/>
</div>
<div className="eye-columns">
<NumericField
label="Œil gauche"
value={activeAvatarEyes.leftAngle}
unit="°"
onActiveChange={active =>
updateHighlight(active ? (linked.rotation ? 'both' : 'left') : null)
}
onChange={leftAngle =>
updateAvatarEyes({
leftAngle,
...(linked.rotation ? { rightAngle: -leftAngle } : {}),
})
}
/>
<NumericField
label="Œil droit"
value={activeAvatarEyes.rightAngle}
unit="°"
onActiveChange={active =>
updateHighlight(active ? (linked.rotation ? 'both' : 'right') : null)
}
onChange={rightAngle =>
updateAvatarEyes({
rightAngle,
...(linked.rotation ? { leftAngle: -rightAngle } : {}),
})
}
/>
</div>
</InspectorCard>
<InspectorCard className="color-panel">
<PanelTitle
level={3}
title="Couleur des yeux"
subtitle="Couleur de base utilisée par les poses et les expressions."
/>
<ColorField
label="Yeux"
value={activeAvatar.colors.eyes}
onChange={eyes => updateAvatarColors({ eyes })}
/>
</InspectorCard>
</ControlSection>
</>
)}
{!bodyEditing && <PoseControls controller={controller} />}
</div>
)}
{!editorPageOpen && mode === 'avatars' && <AvatarPage controller={controller} />}
{!sequenceEditing && !editing && bodyEditing && (
<footer className="workspace-footer">
<div className="workspace-footer-secondary">
<Button
variant="destructive"
disabled={avatars.length <= 1}
onClick={() => setDeleteAvatarOpen(true)}
>
<Trash2 />
{t('Supprimer')}
</Button>
<Button variant="outline" onClick={() => duplicateAvatar(activeAvatar, true)}>
<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>
)}
{!sequenceEditing && !editing && !bodyEditing && mode === 'expressions' && (
<div className="panel-stack">
<InspectorCard>
<div className="preset-header">
<div>
<p className="eyebrow">{expressions.length} presets</p>
</div>
<span>{t('Double-clic pour modifier')}</span>
</div>
<div className="expression-grid">
{expressions.map((preset, index) => (
<motion.div
className="expression-sort-item"
data-dragging={draggingExpressionId === preset.id || undefined}
key={preset.id}
layout="position"
layoutId={`expression-${preset.id}`}
animate={{
opacity: draggingExpressionId === preset.id ? 0.28 : 1,
scale: draggingExpressionId === preset.id ? 0.96 : 1,
}}
transition={
reduceMotion
? { duration: 0 }
: { type: 'spring', stiffness: 520, damping: 42, mass: 0.7 }
}
>
<ExpressionCard
expression={preset}
index={index}
active={activeExpression === index}
surface={surface}
bodyNodes={bodyNodes}
colors={activeAvatar.colors}
avatarEyes={activeAvatarEyes}
renderStyle={activeAvatar.renderStyle}
previewId={String(index)}
onSelect={() => transitionToExpression(preset, index)}
onEdit={() => openExpressionEditor(index, preset)}
onDuplicate={() => duplicateExpression(index, preset)}
onDelete={() => {
openExpressionEditor(index, preset)
setDeleteExpressionOpen(true)
}}
runtimeError={expressionSemanticKeyError(preset)}
draggable
onDragStart={event => {
expressionDragOrigin.current = expressions
expressionDragPreview.current = expressions
draggedExpressionId.current = preset.id
setDraggingExpressionId(preset.id)
event.dataTransfer.effectAllowed = 'move'
}}
onDragEnter={() => previewExpressionMove(preset.id)}
onDragOver={event => {
event.preventDefault()
event.dataTransfer.dropEffect = 'move'
}}
onDrop={event => {
event.preventDefault()
commitExpressionMove(preset.id)
}}
onDragEnd={cancelExpressionMove}
/>
</motion.div>
))}
<Button
className="expression-add creation-card"
variant="outline"
type="button"
onDragOver={event => event.preventDefault()}
onDrop={event => {
event.preventDefault()
commitExpressionMove(null)
}}
onClick={() => openExpressionEditor(null, expression)}
aria-label={t('Nouvelle expression')}
>
<Plus />
</Button>
</div>
</InspectorCard>
<InspectorCard>
<PanelTitle
title="Mouvement"
subtitle="Motion interpole les valeurs et notre moteur effectue le slerp quaternion."
/>
<NumericField
label="Vitesse du ressort"
value={springSpeed}
step={0.5}
onChange={value => {
springSpeedRef.current = value
setSpringSpeed(value)
}}
/>
<div className="button-row">
<Button variant="outline" type="button" onClick={() => blink()}>
{t('Cligner')}
</Button>
<Button
variant="outline"
type="button"
onClick={() => {
const index = Math.floor(Math.random() * expressions.length)
transitionToExpression(expressions[index], index)
}}
>
{t('Expression aléatoire')}
</Button>
</div>
</InspectorCard>
</div>
)}
{!sequenceEditing && !editing && !bodyEditing && mode === 'states' && (
<div className="panel-stack">
<InspectorCard>
<div className="preset-header">
<div>
<p className="eyebrow">
{sequences.length} {t('animations')}
</p>
</div>
</div>
<div className="state-groups">
{groupSequences(sequences).map(group => (
<div
key={group.name}
onDragOver={event => event.preventDefault()}
onDrop={event => {
event.preventDefault()
commitStateMove(null, group.name)
}}
>
<strong>
{group.sequences.every(sequence => sequence.builtIn)
? t(group.name)
: group.name}
</strong>
<div className="state-buttons">
{group.sequences.map(sequence => {
const firstStep = sequence.steps[0]
const firstExpression = firstStep
? expressionById.get(firstStep.expressionId)
: undefined
const card = (
<Button
className="expression-card state-card"
variant="outline"
type="button"
draggable
aria-pressed={selectedState === sequence.id}
onDragStart={event => {
stateDragOrigin.current = sequences
stateDragPreview.current = sequences
draggedStateId.current = sequence.id
setDraggingStateId(sequence.id)
event.dataTransfer.effectAllowed = 'move'
}}
onDragEnter={() => previewStateMove(sequence.id, group.name)}
onDragOver={event => {
event.preventDefault()
event.stopPropagation()
event.dataTransfer.dropEffect = 'move'
}}
onDrop={event => {
event.preventDefault()
event.stopPropagation()
commitStateMove(sequence.id, group.name)
}}
onDragEnd={cancelStateMove}
onClick={() => launchSequence(sequence)}
onDoubleClick={() => openSequenceEditor(sequence)}
>
<ExpressionPreview
expression={
firstExpression ?? expressions[0] ?? defaultExpression
}
surface={surface}
bodyNodes={bodyNodes}
colors={activeAvatar.colors}
avatarEyes={activeAvatarEyes}
renderStyle={activeAvatar.renderStyle}
id={`state-card-${sequence.id}`}
/>
{animationSemanticKeyError(sequence) && (
<i
className="runtime-key-missing"
role="img"
aria-label={animationSemanticKeyError(sequence) ?? undefined}
title={animationSemanticKeyError(sequence) ?? undefined}
>
!
</i>
)}
<span>{sequence.builtIn ? t(sequence.name) : sequence.name}</span>
</Button>
)
return (
<motion.div
className="state-sort-item"
data-dragging={draggingStateId === sequence.id || undefined}
key={sequence.id}
layout="position"
layoutId={`state-${sequence.id}`}
animate={{
opacity: draggingStateId === sequence.id ? 0.28 : 1,
scale: draggingStateId === sequence.id ? 0.96 : 1,
}}
transition={
reduceMotion
? { duration: 0 }
: { type: 'spring', stiffness: 520, damping: 42, mass: 0.7 }
}
>
<ContextMenu>
<ContextMenuTrigger render={card} />
<ContextMenuContent>
<ContextMenuItem onClick={() => openSequenceEditor(sequence)}>
<Pencil />
{t('Modifier')}
</ContextMenuItem>
<ContextMenuItem onClick={() => duplicateState(sequence)}>
<Copy />
{t('Dupliquer')}
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem
variant="destructive"
onClick={() => {
openSequenceEditor(sequence)
setDeleteSequenceOpen(true)
}}
>
<Trash2 />
{t('Supprimer')}
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
</motion.div>
)
})}
</div>
</div>
))}
<div className="state-buttons">
<Button
className="expression-add creation-card"
variant="outline"
type="button"
onClick={() => openSequenceEditor()}
aria-label={t('Nouvelle animation')}
>
<Plus />
</Button>
</div>
</div>
</InspectorCard>
</div>
)}
{!sequenceEditing && !editing && !bodyEditing && mode === 'photo' && (
<div className="panel-stack photo-panel-stack">
<Accordion
className="photo-tool-accordion"
multiple
value={photoPanelSections}
onValueChange={nextSections => {
const sections = nextSections as typeof photoPanelSections
const currentSections = new Set(photoPanelSections)
const openedSection = sections.find(section => !currentSections.has(section))
setPhotoPanelSections(sections)
if (openedSection) setPhotoTool(openedSection)
}}
>
<AccordionItem
className="photo-tool-accordion-item"
value="pose"
data-active-tool={photoTool === 'pose' || undefined}
>
<AccordionTrigger className="photo-tool-accordion-trigger">
<span className="photo-tool-accordion-heading">
<span className="photo-tool-accordion-icon" aria-hidden="true">
<Move3D />
</span>
<span>
<strong>{t('Pose')}</strong>
<small>{t('Orientation, regard, couleurs et perspective.')}</small>
</span>
</span>
</AccordionTrigger>
<AccordionContent className="photo-tool-accordion-content">
<PoseControls controller={controller} />
</AccordionContent>
</AccordionItem>
<AccordionItem
className="photo-tool-accordion-item"
value="frame"
data-active-tool={photoTool === 'frame' || undefined}
>
<AccordionTrigger className="photo-tool-accordion-trigger">
<span className="photo-tool-accordion-heading">
<span className="photo-tool-accordion-icon" aria-hidden="true">
<Scan />
</span>
<span>
<strong>{t('Cadrage')}</strong>
<small>{t('Position, zoom et coins du cadre photo.')}</small>
</span>
</span>
</AccordionTrigger>
<AccordionContent className="photo-tool-accordion-content">
<div className="photo-frame-settings">
<div className="snapshot-composition-fields">
<NumericField
label="Position X"
value={snapshotComposition.x}
min={-180}
max={180}
step={1}
onChange={x => updateSnapshotComposition({ x })}
/>
<NumericField
label="Position Y"
value={snapshotComposition.y}
min={-180}
max={180}
step={1}
onChange={y => updateSnapshotComposition({ y })}
/>
<NumericField
label="Zoom"
value={snapshotComposition.scale * 100}
min={40}
max={300}
step={1}
unit="%"
onChange={zoom => updateSnapshotComposition({ scale: zoom / 100 })}
/>
<NumericField
label="Coins arrondis"
value={snapshotComposition.cornerRadius}
min={0}
max={50}
step={1}
unit="%"
onChange={cornerRadius => updateSnapshotComposition({ cornerRadius })}
/>
</div>
<Button
className="photo-reset-frame"
variant="outline"
type="button"
onClick={() =>
setSnapshotComposition(current => ({
...current,
x: 0,
y: 0,
scale: 1,
}))
}
>
<RotateCcw />
{t('Recentrer le cadrage')}
</Button>
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
<InspectorCard className="photo-expression-card">
<PanelTitle
title="Expression"
subtitle="Choisis l’expression visible sur la photo."
/>
<div className="expression-grid photo-expression-grid">
{expressions.map((preset, index) => (
<ExpressionCard
key={preset.id}
expression={preset}
index={index}
active={activeExpression === index}
surface={surface}
bodyNodes={bodyNodes}
colors={activeAvatar.colors}
avatarEyes={activeAvatarEyes}
renderStyle={activeAvatar.renderStyle}
previewId={`photo-${preset.id}`}
onSelect={() => transitionToExpression(preset, index)}
runtimeError={expressionSemanticKeyError(preset)}
/>
))}
</div>
</InspectorCard>
<InspectorCard>
<PanelTitle
title="Arrière-plan"
subtitle="Choisis un fond transparent, uni ou en dégradé."
/>
<Field className="snapshot-background-field" orientation="horizontal">
<FieldTitle>{t('Style')}</FieldTitle>
<Select
value={snapshotBackground}
items={[
{ value: 'transparent', label: t('Transparent') },
{ value: 'solid', label: t('Uni') },
{ value: 'linear', label: t('Dégradé linéaire') },
{ value: 'radial', label: t('Dégradé radial') },
]}
onValueChange={next =>
next && setSnapshotBackground(next as SnapshotBackground)
}
>
<SelectTrigger aria-label={t('Style d’arrière-plan')}>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="transparent">{t('Transparent')}</SelectItem>
<SelectItem value="solid">{t('Uni')}</SelectItem>
<SelectItem value="linear">{t('Dégradé linéaire')}</SelectItem>
<SelectItem value="radial">{t('Dégradé radial')}</SelectItem>
</SelectContent>
</Select>
<Button
className="snapshot-random-button"
variant="ghost"
size="sm"
type="button"
disabled={snapshotBackground === 'transparent'}
onClick={() => {
if (snapshotBackground === 'transparent') return
const palette = randomSnapshotPalette(
snapshotBackground,
expression.bodyColor ?? activeAvatar.colors.body,
{ colorFrom: snapshotColorFrom, colorTo: snapshotColorTo }
)
setSnapshotColorFrom(palette.colorFrom)
setSnapshotColorTo(palette.colorTo)
}}
>
<Shuffle />
{t('Aléatoire')}
</Button>
</Field>
{snapshotBackground !== 'transparent' && (
<div className="snapshot-colors">
<ColorField
label={snapshotBackground === 'solid' ? 'Couleur' : 'Départ'}
value={snapshotColorFrom}
onChange={setSnapshotColorFrom}
/>
{(snapshotBackground === 'linear' || snapshotBackground === 'radial') && (
<ColorField
label="Arrivée"
value={snapshotColorTo}
onChange={setSnapshotColorTo}
/>
)}
</div>
)}
</InspectorCard>
<InspectorCard>
<Field className="snapshot-background-field" orientation="horizontal">
<div>
<FieldTitle>{t('Format d’export')}</FieldTitle>
<small>{t('Choisis le type de fichier généré par le mode photo.')}</small>
</div>
<Select
value={snapshotFormat}
items={[
{ value: 'png', label: 'PNG' },
{ value: 'svg', label: 'SVG' },
]}
onValueChange={next => next && setSnapshotFormat(next as SnapshotFormat)}
>
<SelectTrigger aria-label={t('Format d’export du mode photo')}>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="png">PNG</SelectItem>
<SelectItem value="svg">SVG</SelectItem>
</SelectContent>
</Select>
</Field>
<Separator className="snapshot-settings-separator" />
<Field className="snapshot-background-field" orientation="horizontal">
<div>
<FieldTitle>{t('Définition')}</FieldTitle>
<small>{t('Dimensions du fichier exporté.')}</small>
</div>
<Select
value={snapshotSize}
items={['512', '1024', '2048'].map(value => ({
value,
label: `${value} px`,
}))}
onValueChange={next => next && setSnapshotSize(next)}
>
<SelectTrigger aria-label={t('Définition du mode photo')}>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="512">512 px</SelectItem>
<SelectItem value="1024">1024 px</SelectItem>
<SelectItem value="2048">2048 px</SelectItem>
</SelectContent>
</Select>
</Field>
</InspectorCard>
<Button className="snapshot-export-button" type="button" onClick={takePicture}>
<Camera />
{t('Prendre une photo')}
<span>{snapshotFormat.toUpperCase()}</span>
</Button>
</div>
)}
{!sequenceEditing && !editing && !bodyEditing && mode === 'export' && (
<Accordion className="export-panel" defaultValue={['avatar']}>
<ExportSection
value="avatar"
title="Exporter l’avatar"
subtitle="Choisis les animations puis utilise la même définition JSON avec React ou JavaScript."
>
<InspectorCard>
<div className="export-avatar-summary">
<ExpressionPreview
expression={expressions[0] ?? defaultExpression}
surface={activeAvatar.body.primary}
bodyNodes={activeAvatar.body.nodes}
colors={activeAvatar.colors}
avatarEyes={activeAvatarEyes}
renderStyle={activeAvatar.renderStyle}
id={`export-avatar-${activeAvatar.id}`}
/>
<div>
<small>{t('Avatar sélectionné')}</small>
<strong>{activeAvatar.name}</strong>
</div>
</div>
</InspectorCard>
<section className="export-format-section" aria-label={t('Format')}>
<PanelTitle
title="Format"
subtitle="Choisis l’intégration correspondant à ton projet."
/>
<div className="export-format-grid">
<Button
variant="outline"
type="button"
aria-pressed={exportFormat === 'react'}
onClick={() => setExportFormat('react')}
>
<FileCode2 />
<span>
<strong>React / TypeScript</strong>
<small>{t('JSON runtime + createAvatar')}</small>
</span>
</Button>
<Button
variant="outline"
type="button"
aria-pressed={exportFormat === 'javascript'}
onClick={() => setExportFormat('javascript')}
>
<FileCode2 />
<span>
<strong>{t('JavaScript / ESM')}</strong>
<small>{t('JSON runtime + avatar-web')}</small>
</span>
</Button>
</div>
</section>
<InspectorCard>
<div className="preset-header export-animation-header">
<div>
<p className="eyebrow">
{selectedExportAnimations.length}/{sequences.length} {t('sélectionnées')}
</p>
<h2>{t('Animations à exporter')}</h2>
</div>
<Button
variant="ghost"
size="sm"
type="button"
aria-expanded={exportAnimationsOpen}
onClick={() => setExportAnimationsOpen(open => !open)}
>
{exportAnimationsOpen ? <ChevronUp /> : <ChevronDown />}
{t(exportAnimationsOpen ? 'Masquer la sélection' : 'Personnaliser')}
</Button>
</div>
{exportAnimationsOpen && (
<div className="export-animation-picker">
<Button
className="export-animation-select-all"
variant="ghost"
size="sm"
type="button"
onClick={() =>
setExportAnimationIds(
selectedExportAnimations.length === sequences.length
? []
: sequences.map(animation => animation.id)
)
}
>
{t(
selectedExportAnimations.length === sequences.length
? 'Tout désélectionner'
: 'Tout sélectionner'
)}
</Button>
<div className="state-buttons export-animation-grid">
{sequences.map(animation => {
const firstStep = animation.steps[0]
const firstExpression = firstStep
? expressionById.get(firstStep.expressionId)
: undefined
return (
<Button
className="expression-card state-card"
variant="outline"
type="button"
key={animation.id}
aria-pressed={exportAnimationIdSet.has(animation.id)}
onClick={() => toggleExportAnimation(animation.id)}
>
<ExpressionPreview
expression={
firstExpression ?? expressions[0] ?? defaultExpression
}
surface={surface}
bodyNodes={bodyNodes}
colors={activeAvatar.colors}
avatarEyes={activeAvatarEyes}
renderStyle={activeAvatar.renderStyle}
id={`export-animation-${animation.id}`}
/>
<span>
{animation.builtIn ? t(animation.name) : animation.name}
</span>
</Button>
)
})}
</div>
</div>
)}
</InspectorCard>
{runtimeExportErrors.length > 0 && (
<InspectorCard>
<div className="runtime-export-error" role="alert">
<div className="runtime-export-error-heading">
<TriangleAlert />
<strong>{t('Export runtime incomplet')}</strong>
</div>
<ul className="runtime-error-list">
{runtimeExportErrors.map((error, index) => (
<li key={`${index}-${error}`}>{error}</li>
))}
</ul>
<p className="runtime-export-error-help">
{t(
'Corrige les clés signalées dans les éditeurs Expressions ou Animations.'
)}
</p>
<div className="runtime-export-error-actions">
<Button
type="button"
variant="outline"
onClick={() => setMode('expressions')}
>
{t('Expressions')}
</Button>
<Button type="button" variant="outline" onClick={() => setMode('states')}>
{t('Animations')}
</Button>
</div>
</div>
</InspectorCard>
)}
<InspectorCard className="runtime-quick-start-card">
<div className="runtime-quick-start-heading">
<div>
<p className="eyebrow">{t('Démarrage rapide')}</p>
</div>
</div>
<div className="runtime-quick-start-actions">
<Button
type="button"
variant="ghost"
aria-label={
guideCopyStatus === 'success'
? t('Guide d’utilisation copié dans le presse-papiers.')
: guideCopyStatus === 'error'
? t('Impossible de copier le guide d’utilisation.')
: undefined
}
onClick={() => void copyRuntimeGuide()}
>
{guideCopyStatus === 'success' ? <Check /> : <Copy />}
{t('Copier les instructions pour l’IA')}
</Button>
<Button type="button" variant="ghost" onClick={() => setGuideOpen(true)}>
{t('Voir le guide complet')}
<ArrowRight />
</Button>
</div>
<div className="runtime-quick-start-step">
<span>{t('Installation')}</span>
<code>
<HighlightedRuntimeCode>
{exportFormat === 'react' ? reactQuickStartInstall : webQuickStartInstall}
</HighlightedRuntimeCode>
</code>
</div>
<div className="runtime-quick-start-step">
<span>{t('Utilisation minimale')}</span>
<pre tabIndex={0}>
<code>
<HighlightedRuntimeCode>
{exportFormat === 'react'
? reactQuickStartExample(runtimePreviewAnimation)
: webQuickStartExample(runtimePreviewAnimation)}
</HighlightedRuntimeCode>
</code>
</pre>
</div>
</InspectorCard>
<InspectorCard className="runtime-export-card">
<div className="runtime-export-heading">
<div>
<small>{t('Prêt à exporter')}</small>
<strong>
{selectedExportAnimations.length} {t('animations')} ·{' '}
{runtimeDefinitionResult.ok
? runtimeDefinitionResult.value.expressionOrder.length
: 0}{' '}
{t('expressions')}
</strong>
</div>
<p className="runtime-export-description">
{t(
exportFormat === 'javascript'
? 'Le ZIP contient le JSON exporté, une démo index.html et son README. La démo charge avatar-web depuis un CDN.'
: 'Le ZIP contient le JSON exporté et un projet Vite React TypeScript prêt à lancer avec npm install puis npm run dev.'
)}
</p>
</div>
<div className="runtime-export-actions">
<Button
className="export-download"
type="button"
disabled={!runtimeDefinitionResult.ok}
onClick={downloadAvatarRuntimeDefinition}
>
<Download />
{t('Télécharger la définition .avatar.json')}
</Button>
<Button
type="button"
variant="outline"
disabled={!runtimeDefinitionResult.ok}
onClick={downloadAvatarExport}
>
<Download />
{t(
exportFormat === 'javascript'
? 'Télécharger la démo ESM (.zip)'
: 'Télécharger la démo React (.zip)'
)}
</Button>
<div className="runtime-export-secondary-actions">
<Button
type="button"
variant="outline"
disabled={!runtimeDefinitionResult.ok}
onClick={() => setRuntimePreviewOpen(true)}
>
<Play />
{t('Preview')}
</Button>
<Button
type="button"
variant="outline"
disabled={!runtimeDefinitionResult.ok}
aria-label={
runtimeCopyStatus === 'success'
? t('JSON runtime copié dans le presse-papiers.')
: runtimeCopyStatus === 'error'
? t('Impossible de copier le JSON runtime.')
: undefined
}
onClick={() => void copyAvatarRuntimeDefinition()}
>
{runtimeCopyStatus === 'success' ? <Check /> : <Copy />}
{t('Copier le JSON')}
</Button>
</div>
</div>
</InspectorCard>
<RuntimePreviewDialog
definition={runtimeDefinitionResult.ok ? runtimeDefinitionResult.value : null}
initialAnimation={runtimePreviewAnimation}
open={runtimePreviewOpen}
onOpenChange={setRuntimePreviewOpen}
/>
<RuntimeGuideDialog
animationKey={runtimePreviewAnimation}
integration={exportFormat}
open={guideOpen}
onOpenChange={setGuideOpen}
/>
</ExportSection>
<ExportSection
value="snapshot"
title="Mode photo"
subtitle="Prépare une image statique directement dans l’aperçu principal."
>
<InspectorCard className="photo-mode-launch-card">
<div className="photo-mode-launch-icon" aria-hidden="true">
<Camera />
</div>
<div>
<strong>{t('Composer dans le live preview')}</strong>
<p>
{t(
'Choisis une expression, ajuste la pose et cadre l’avatar dans un espace dédié.'
)}
</p>
</div>
<Button type="button" onClick={openPhotoMode}>
{t('Ouvrir le mode photo')}
<ArrowRight />
</Button>
</InspectorCard>
</ExportSection>
<ExportSection
value="project"
title="Projet du Studio"
subtitle="Transfère tous les avatars, expressions et animations vers un autre navigateur."
>
<div className="project-transfer-actions">
<Button variant="outline" type="button" onClick={downloadStudioProject}>
<Download />
{t('Télécharger le projet JSON')}
</Button>
<Button
variant="outline"
type="button"
onClick={() => projectImportRef.current?.click()}
>
<Upload />
{t('Importer un projet JSON')}
</Button>
<input
ref={projectImportRef}
className="project-import-input"
type="file"
accept="application/json,.json"
aria-label={t('Importer un projet JSON')}
onChange={event => {
prepareStudioProjectImport(event.currentTarget.files?.[0])
event.currentTarget.value = ''
}}
/>
</div>
{projectImportError && (
<p className="project-transfer-error" role="alert">
{projectImportError}
</p>
)}
</ExportSection>
</Accordion>
)}
</motion.div>
</AnimatePresence>
{activeSequence && !editorPageOpen && mode !== 'photo' && (
<motion.footer
className={`state-playback-footer${statePlayerExpanded ? ' is-expanded' : ''}`}
style={{ y: playbackFooterY }}
>
<div className="state-playback-drag-handle-slot">
<motion.div style={{ y: playbackHandleCounterY }}>
<motion.button
className="state-playback-drag-handle"
style={{ y: playbackHandleY }}
type="button"
drag="y"
dragMomentum={false}
aria-expanded={statePlayerExpanded}
aria-label={t(
statePlayerExpanded
? 'Masquer les détails de l’animation'
: 'Afficher les détails de l’animation'
)}
onTap={() => snapPlaybackFooter(!statePlayerExpanded)}
onDragStart={() => {
playbackFooterDragOriginY.current = playbackFooterY.get()
}}
onDrag={(_, info) => {
playbackFooterY.set(
Math.min(
playbackFooterCollapsedOffset,
Math.max(0, playbackFooterDragOriginY.current + info.offset.y)
)
)
}}
onDragEnd={(_, info) => {
playbackHandleY.set(0)
const projectedY = playbackFooterY.get() + info.velocity.y * 0.16
snapPlaybackFooter(projectedY < playbackFooterCollapsedOffset / 2)
}}
>
<span />
</motion.button>
</motion.div>
</div>
<div className="state-playback-bar">
<div className="state-playback-timeline">
{activeSequence.steps.map((step, position) => {
const expressionIndex = findExpressionIndex(expressions, step.expressionId)
const preset = expressions[expressionIndex]
if (!preset) return null
return (
<Button
className="state-playback-step"
variant="outline"
type="button"
key={step.id}
aria-pressed={activeExpression === expressionIndex}
onClick={() => transitionToExpression(preset, expressionIndex, step)}
>
<ExpressionPreview
expression={preset}
surface={surface}
bodyNodes={bodyNodes}
colors={activeAvatar.colors}
avatarEyes={activeAvatarEyes}
renderStyle={activeAvatar.renderStyle}
id={`player-${activeSequence.id}-${position}`}
/>
{playbackVisual.position === position && (
<span
className="state-playback-progress"
key={`${step.id}-${playbackVisual.run}`}
aria-hidden="true"
style={
{
animationDuration: `${Math.max(playbackVisual.durationMs, 1)}ms`,
animationPlayState: statePlaying ? 'running' : 'paused',
} as CSSProperties
}
/>
)}
</Button>
)
})}
</div>
<div className="state-playback-controls">
<StatePlayer
name={activeSequenceLabel}
status={playbackStatus}
onToggle={toggleStatePlayback}
onStop={stopState}
/>
</div>
</div>
<motion.div
ref={playbackDetailsRef}
className="state-playback-details-shell"
style={{ opacity: playbackDetailsOpacity }}
aria-hidden={!statePlayerExpanded}
>
<div className="state-playback-details">
<div className="state-playback-details-header">
<div>
<p className="eyebrow">{t('Détails de l’animation')}</p>
<h2>{activeSequenceLabel}</h2>
<p>
{activeSequence.builtIn
? t(activeSequence.description)
: activeSequence.description}
</p>
</div>
<Badge variant="secondary">
{t('Mode de lecture')} · {t(activeSequence.playbackMode)}
</Badge>
</div>
<div className="state-playback-detail-grid">
<div>
<span>{t('Expressions')}</span>
<strong>{activeSequence.steps.length}</strong>
<small>
{activeSequence.steps
.map(step => formatSeconds(step.holdMs, language))
.join(' · ')}
</small>
</div>
<div>
<span>{t('Premier clignement')}</span>
<strong>
{activeSequence.blink.enabled
? formatSeconds(activeSequence.blink.initialDelayMs, language)
: t('Désactivé')}
</strong>
<small>{t('après le lancement')}</small>
</div>
<div>
<span>{t('Intervalle du clignement')}</span>
<strong>
{formatSeconds(activeSequence.blink.minIntervalMs, language)}–
{formatSeconds(activeSequence.blink.maxIntervalMs, language)}
</strong>
<small>{t('tirage aléatoire')}</small>
</div>
<div>
<span>{t('Durée du clignement')}</span>
<strong>{activeSequence.blink.durationMs} ms</strong>
<small>{t('fermeture et ouverture')}</small>
</div>
</div>
</div>
</motion.div>
</motion.footer>
)}
{!editorPageOpen && mode !== 'photo' && (
<nav className="mobile-mode-tabs" aria-label={t('Mode d’édition')}>
<Button
className="mobile-mode-tab mobile-avatar-tab"
variant="ghost"
type="button"
aria-pressed={mode === 'avatars'}
aria-label={t('Choisir un avatar')}
onClick={() => setMode('avatars')}
>
<ExpressionPreview
expression={expressions[0] ?? defaultExpression}
surface={activeAvatar.body.primary}
bodyNodes={activeAvatar.body.nodes}
colors={activeAvatar.colors}
avatarEyes={activeAvatarEyes}
renderStyle={activeAvatar.renderStyle}
id={`active-avatar-tab-${activeAvatar.id}`}
/>
<span>{activeAvatar.name}</span>
</Button>
{(
[
['manual', t('Pose'), Move3D],
['expressions', t('Expressions'), Smile],
['states', t('Animations'), Play],
['export', t('Exporter'), Download],
] as const
).map(([value, label, Icon]) => (
<Button
className="mobile-mode-tab"
variant="ghost"
type="button"
key={value}
aria-pressed={mode === value}
onClick={() => setMode(value)}
>
<Icon />
<span>{label}</span>
</Button>
))}
</nav>
)}
</main>
</Drawer>
)
}