bible-strong-avatar-lab/src/features/studio/components/StudioInspector.tsx
smontlouis 3b2e134169 feat(studio): prepare app for GitHub release
Add distribution metadata, Strobi app icons, CI, and comprehensive documentation. Migrate the project to pnpm and reorganize the Studio into domain-focused modules and components.
2026-08-13 18:56:47 +02:00

1978 lines
80 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,
ChevronDown,
ChevronUp,
Copy,
Download,
FileCode2,
Pencil,
Plus,
RotateCcw,
Trash2,
Upload,
} from 'lucide-react'
import { motion } from 'motion/react'
import { type CSSProperties } from 'react'
import { Accordion } 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 { 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 { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
import {
ControlSection,
ExportSection,
InspectorCard,
PanelTitle,
SnapshotPreview,
StatePlayer,
} from '@/app/components/common'
import { ColorField, LinkButton, NumericField } from '@/app/components/controls'
import {
formatSeconds,
scaleSurface,
type Mode,
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 { bodyPrimitiveTypes, MAX_BODY_NODES } from '@/features/avatar/body'
import {
ExpressionCard,
ExpressionPreview,
ExpressionWorkspace,
SurfaceThumbnail,
} from '@/features/avatar/components/ExpressionWorkspace'
import { defaultExpression } from '@/features/avatar/presets'
import { surfaceLabels, surfacePresets } from '@/features/avatar/surfaces'
import { type SnapshotBackground } from '@/features/export/snapshotExporter'
import type { StudioController } from '@/features/studio/useStudioController'
export function StudioInspector({ controller }: { controller: StudioController }) {
const {
activateAvatar,
activeAvatar,
activeAvatarEyes,
activeAvatarId,
activeExpression,
activeSequence,
activeSequenceLabel,
activeState,
addBodyNode,
avatarDragOrigin,
avatarDragPreview,
avatars,
avatarsRef,
blink,
bodyEditing,
bodyNodes,
cancelAvatarEditing,
cancelAvatarMove,
cancelExpressionEditing,
cancelExpressionMove,
cancelSequenceEditing,
cancelStateMove,
commitAvatarMove,
commitExpressionMove,
commitStateMove,
createNewAvatar,
deleteSelectedBodyNode,
downloadAvatarExport,
downloadStudioProject,
draggedAvatarId,
draggedExpressionId,
draggedStateId,
draggingAvatarId,
draggingExpressionId,
draggingStateId,
duplicateAvatar,
duplicateExpression,
duplicateSelectedBodyNode,
duplicateSequenceEditing,
duplicateState,
editing,
editorPageOpen,
exportAnimationIdSet,
exportFormat,
expression,
expressionById,
expressionDragOrigin,
expressionDragPreview,
expressions,
focusAvatarName,
language,
launchSequence,
linked,
mode,
openExpressionEditor,
openSequenceEditor,
pauseState,
playbackStatus,
playbackVisual,
prepareStudioProjectImport,
previewAvatarMove,
previewExpressionDraft,
previewExpressionMove,
previewStateMove,
projectImportError,
projectImportRef,
reduceMotion,
renameActiveAvatar,
renderedColors,
renderedScene,
saveAvatarEditing,
saveEditing,
saveSequenceEditing,
selectBodyNode,
selectedBodyNode,
selectedBodyNodeId,
selectedExportAnimations,
selectedSequenceStepId,
selectedState,
sequenceEditing,
sequences,
setDeleteAvatarOpen,
setDeleteExpressionOpen,
setDeleteSequenceOpen,
setDraggingAvatarId,
setDraggingExpressionId,
setDraggingStateId,
setExportAnimationIds,
setExportFormat,
setFocusAvatarName,
setLinked,
setMode,
setSelectedSequenceStepId,
setSequenceEditing,
setSnapshotBackground,
setSnapshotColorFrom,
setSnapshotColorTo,
setSnapshotFormat,
setSnapshotSize,
setSpringSpeed,
setStatePlayerExpanded,
showWire,
snapshotBackground,
snapshotColorFrom,
snapshotColorTo,
snapshotFormat,
snapshotSize,
springSpeed,
springSpeedRef,
stateDragOrigin,
stateDragPreview,
statePlayerExpanded,
statePlaying,
stopState,
surface,
t,
toggleExportAnimation,
toggleStatePlayback,
transitionToExpression,
updateAvatarColors,
updateAvatarEyeDimension,
updateAvatarEyePosition,
updateAvatarEyeSize,
updateAvatarEyes,
updateDimension,
updateHighlight,
updateImmediate,
updateNodeVector,
updateSelectedBodyNode,
updateSize,
updateSpacing,
updateSurface,
updateWireVisibility,
workspaceBackButtonRef,
} = controller
return (
<main
className={`inspector ${editing ? 'expression-workspace-active' : sequenceEditing ? 'sequence-workspace-active' : bodyEditing ? 'body-workspace' : 'studio-workspace'}${activeSequence && !editorPageOpen ? ' state-player-active' : ''}`}
>
{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}
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)}
/>
</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)}
/>
</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>
) : (
<>
<section className="avatar-shelf" aria-label={t('Choisir un avatar')}>
<div className="avatar-shelf-heading">
<strong>Avatars</strong>
<span>{t('Double-clic pour modifier')}</span>
</div>
<div className="avatar-grid">
{avatars.map(avatar => (
<motion.div
className="avatar-sort-item"
data-dragging={draggingAvatarId === avatar.id || undefined}
key={avatar.id}
layout="position"
animate={{
opacity: draggingAvatarId === avatar.id ? 0.28 : 1,
scale: draggingAvatarId === avatar.id ? 0.96 : 1,
}}
transition={
reduceMotion
? { duration: 0 }
: { type: 'spring', stiffness: 520, damping: 42, mass: 0.7 }
}
>
<ContextMenu>
<ContextMenuTrigger
render={
<Button
className="avatar-card"
variant="outline"
aria-pressed={activeAvatarId === avatar.id}
type="button"
draggable
onDragStart={event => {
avatarDragOrigin.current = avatarsRef.current
avatarDragPreview.current = avatarsRef.current
draggedAvatarId.current = avatar.id
setDraggingAvatarId(avatar.id)
event.dataTransfer.effectAllowed = 'move'
}}
onDragEnter={() => previewAvatarMove(avatar.id)}
onDragOver={event => {
event.preventDefault()
event.dataTransfer.dropEffect = 'move'
}}
onDrop={event => {
event.preventDefault()
commitAvatarMove(avatar.id)
}}
onDragEnd={cancelAvatarMove}
onClick={() => activateAvatar(avatar.id, false, true)}
onDoubleClick={() => {
setFocusAvatarName(false)
activateAvatar(avatar.id, true)
}}
>
<ExpressionPreview
expression={expressions[0] ?? defaultExpression}
surface={avatar.body.primary}
bodyNodes={avatar.body.nodes}
colors={avatar.colors}
avatarEyes={avatar.eyes ?? defaultAvatarEyes}
id={`avatar-${avatar.id}`}
/>
<span>{avatar.name}</span>
</Button>
}
/>
<ContextMenuContent>
<ContextMenuItem
onClick={() => {
setFocusAvatarName(false)
activateAvatar(avatar.id, true)
}}
>
<Pencil /> {t('Modifier')}
</ContextMenuItem>
<ContextMenuItem onClick={() => duplicateAvatar(avatar)}>
<Copy /> {t('Dupliquer')}
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem
variant="destructive"
disabled={avatars.length <= 1}
onClick={() => {
activateAvatar(avatar.id, false, true)
setDeleteAvatarOpen(true)
}}
>
<Trash2 /> {t('Supprimer')}
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
</motion.div>
))}
<Button
variant="outline"
className="avatar-add creation-card"
onClick={createNewAvatar}
aria-label={t('Nouvel avatar')}
>
<Plus />
</Button>
</div>
</section>
<Tabs value={mode} onValueChange={value => setMode(value as Mode)}>
<TabsList className="tabs" aria-label={t('Mode d’édition')}>
<TabsTrigger value="manual">{t('Pose')}</TabsTrigger>
<TabsTrigger value="expressions">{t('Expressions')}</TabsTrigger>
<TabsTrigger value="states">{t('Animations')}</TabsTrigger>
<TabsTrigger value="export">{t('Exporter')}</TabsTrigger>
</TabsList>
</Tabs>
</>
))}
{!editing && mode === 'manual' && (
<div className="panel-stack">
{bodyEditing && (
<>
<ControlSection
title="Corps"
subtitle="Construction, forme et couleur de la tête de l’avatar."
>
<InspectorCard className="body-panel">
<PanelTitle
level={3}
title="Construction du corps"
subtitle="Une forme principale porte les yeux. Les autres primitives se placent autour d’elle."
/>
<div className="body-tree">
<Button
variant="outline"
type="button"
aria-pressed={selectedBodyNodeId === 'primary'}
onClick={() => selectBodyNode('primary')}
>
<span className="body-node-icon body-node-icon-primary">
<SurfaceThumbnail surface={surface} />
</span>
<span>
<strong>{t('Forme principale')}</strong>
<small>
{t(surfaceLabels[surface.type])} · {t('porte les yeux')}
</small>
</span>
</Button>
{bodyNodes.map(node => (
<Button
variant="outline"
type="button"
key={node.id}
aria-pressed={selectedBodyNodeId === node.id}
onClick={() => selectBodyNode(node.id)}
>
<span className="body-node-icon">
<SurfaceThumbnail surface={node.surface} />
</span>
<span>
<strong>{t(node.name)}</strong>
<small>{t(surfaceLabels[node.surface.type])}</small>
</span>
</Button>
))}
</div>
<div className="body-add">
<span>
{t('Ajouter une forme')} · {bodyNodes.length}/{MAX_BODY_NODES}
</span>
<div>
{bodyPrimitiveTypes.map(type => (
<Button
className="surface-card body-add-card"
variant="outline"
type="button"
key={type}
disabled={bodyNodes.length >= MAX_BODY_NODES}
onClick={() => addBodyNode(type)}
>
<SurfaceThumbnail surface={surfacePresets[type]} />
<span>{t(surfaceLabels[type])}</span>
</Button>
))}
</div>
</div>
{selectedBodyNode && (
<div className="body-node-editor">
<div className="body-node-actions">
<strong>{selectedBodyNode.name}</strong>
<div>
<Button
variant="outline"
size="sm"
type="button"
disabled={bodyNodes.length >= MAX_BODY_NODES}
onClick={duplicateSelectedBodyNode}
>
{t('Dupliquer')}
</Button>
<Button
variant="destructive"
size="sm"
type="button"
onClick={deleteSelectedBodyNode}
>
{t('Supprimer')}
</Button>
</div>
</div>
<p className="body-gizmo-help">
<Badge variant="outline">{t('Gizmo local')}</Badge>
{t(
'Glisse un axe pour déplacer la forme, ou un anneau pour la faire tourner.'
)}
</p>
<div className="surface-fields">
<NumericField
label="Échelle"
value={Math.max(
selectedBodyNode.surface.width,
selectedBodyNode.surface.height,
selectedBodyNode.surface.depth
)}
min={10}
max={300}
unit="u"
onChange={size =>
updateSelectedBodyNode(node => ({
...node,
surface: scaleSurface(node.surface, size, {
width: 10,
height: 10,
depth: 10,
}),
}))
}
/>
{(['width', 'height', 'depth'] as const).map(dimension => (
<NumericField
key={dimension}
label={
{ width: 'Largeur', height: 'Hauteur', depth: 'Profondeur' }[
dimension
]
}
value={selectedBodyNode.surface[dimension]}
min={10}
max={300}
unit="u"
onChange={value =>
updateSelectedBodyNode(node => ({
...node,
surface: { ...node.surface, [dimension]: value },
}))
}
/>
))}
{(selectedBodyNode.surface.type === 'cube' ||
selectedBodyNode.surface.type === 'diamond' ||
selectedBodyNode.surface.type === 'cylinder') && (
<NumericField
label="Rondeur"
value={selectedBodyNode.surface.roundness}
min={0}
max={2}
step={0.01}
onChange={roundness =>
updateSelectedBodyNode(node => ({
...node,
surface: { ...node.surface, roundness },
}))
}
/>
)}
{(selectedBodyNode.surface.type === 'cylinder' ||
selectedBodyNode.surface.type === 'cone') && (
<NumericField
label="Rondeur globale"
value={selectedBodyNode.surface.morphRoundness ?? 0}
min={0}
max={2}
step={0.01}
onChange={morphRoundness =>
updateSelectedBodyNode(node => ({
...node,
surface: { ...node.surface, morphRoundness },
}))
}
/>
)}
{selectedBodyNode.surface.type === 'cone' && (
<>
<NumericField
label="Rondeur pointe"
value={selectedBodyNode.surface.tipRoundness ?? 0}
min={0}
max={2}
step={0.01}
onChange={tipRoundness =>
updateSelectedBodyNode(node => ({
...node,
surface: { ...node.surface, tipRoundness },
}))
}
/>
<NumericField
label="Rondeur base"
value={selectedBodyNode.surface.baseRoundness ?? 0}
min={0}
max={2}
step={0.01}
onChange={baseRoundness =>
updateSelectedBodyNode(node => ({
...node,
surface: { ...node.surface, baseRoundness },
}))
}
/>
</>
)}
</div>
<div className="body-transform-grid">
<div>
<h3>{t('Position locale')}</h3>
{(['X', 'Y', 'Z'] as const).map((axis, index) => (
<NumericField
key={axis}
label={axis}
value={selectedBodyNode.position[index]}
unit="u"
onChange={value =>
updateNodeVector('position', index as 0 | 1 | 2, value)
}
/>
))}
</div>
<div>
<h3>{t('Rotation locale')}</h3>
{(['X', 'Y', 'Z'] as const).map((axis, index) => (
<NumericField
key={axis}
label={axis}
value={selectedBodyNode.rotation[index]}
unit="°"
onChange={value =>
updateNodeVector('rotation', index as 0 | 1 | 2, value)
}
/>
))}
</div>
</div>
</div>
)}
</InspectorCard>
<InspectorCard className="surface-panel">
<PanelTitle
level={3}
title="Forme principale"
subtitle="Cette surface est la référence du visage et porte les yeux."
/>
<div className="surface-grid">
{bodyPrimitiveTypes.map(type => {
const previewSurface = type === surface.type ? surface : surfacePresets[type]
return (
<Button
className="surface-card"
variant="outline"
type="button"
key={type}
aria-pressed={surface.type === type}
onClick={() => {
selectBodyNode('primary')
if (type !== surface.type) {
updateSurface({ ...surfacePresets[type] })
}
}}
>
<SurfaceThumbnail surface={previewSurface} />
<span>{t(surfaceLabels[type])}</span>
</Button>
)
})}
</div>
<div className="surface-fields">
<NumericField
label="Échelle"
value={Math.max(surface.width, surface.height, surface.depth)}
min={120}
max={300}
unit="u"
onChange={size =>
updateSurface(
scaleSurface(surface, size, { width: 120, height: 120, depth: 100 })
)
}
/>
<NumericField
label="Largeur"
value={surface.width}
min={120}
max={300}
unit="u"
onChange={width => updateSurface({ ...surface, width })}
/>
<NumericField
label="Hauteur"
value={surface.height}
min={120}
max={300}
unit="u"
onChange={height => updateSurface({ ...surface, height })}
/>
<NumericField
label="Profondeur"
value={surface.depth}
min={100}
max={300}
unit="u"
onChange={depth => updateSurface({ ...surface, depth })}
/>
{(surface.type === 'cube' || surface.type === 'diamond') && (
<NumericField
label="Rondeur"
value={surface.roundness}
min={0}
max={2}
step={0.01}
onActiveChange={active => updateHighlight(active ? 'head' : null)}
onChange={roundness => updateSurface({ ...surface, roundness })}
/>
)}
{surface.type === 'cylinder' && (
<NumericField
label="Rondeur des arêtes"
value={surface.roundness}
min={0}
max={2}
step={0.01}
onActiveChange={active => updateHighlight(active ? 'head' : null)}
onChange={roundness => updateSurface({ ...surface, roundness })}
/>
)}
{(surface.type === 'cylinder' || surface.type === 'cone') && (
<NumericField
label="Rondeur globale"
value={surface.morphRoundness ?? 0}
min={0}
max={2}
step={0.01}
onActiveChange={active => updateHighlight(active ? 'head' : null)}
onChange={morphRoundness => updateSurface({ ...surface, morphRoundness })}
/>
)}
{surface.type === 'cone' && (
<>
<NumericField
label="Rondeur pointe"
value={surface.tipRoundness ?? 0}
min={0}
max={2}
step={0.01}
onActiveChange={active => updateHighlight(active ? 'head' : null)}
onChange={tipRoundness => updateSurface({ ...surface, tipRoundness })}
/>
<NumericField
label="Rondeur base"
value={surface.baseRoundness ?? 0}
min={0}
max={2}
step={0.01}
onActiveChange={active => updateHighlight(active ? 'head' : null)}
onChange={baseRoundness => updateSurface({ ...surface, baseRoundness })}
/>
</>
)}
</div>
</InspectorCard>
<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="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 && (
<>
<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>
<Button
className="reset"
variant="outline"
type="button"
onClick={() => transitionToExpression({ ...defaultExpression })}
>
{t('Réinitialiser')}
</Button>
</InspectorCard>
</ControlSection>
</>
)}
</div>
)}
{!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>
</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>
<h2>{t('Expressions')}</h2>
</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}
previewId={String(index)}
onSelect={() => transitionToExpression(preset, index)}
onEdit={() => openExpressionEditor(index, preset)}
onDuplicate={() => duplicateExpression(index, preset)}
onDelete={() => {
openExpressionEditor(index, preset)
setDeleteExpressionOpen(true)
}}
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>
<h2>{t('Animations')}</h2>
</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}
id={`state-card-${sequence.id}`}
/>
<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 === 'export' && (
<Accordion className="export-panel">
<ExportSection
value="avatar"
title="Exporter l’avatar"
subtitle="Télécharge un composant autonome avec les animations de ton choix."
>
<InspectorCard>
<div className="export-avatar-summary">
<ExpressionPreview
expression={expressions[0] ?? defaultExpression}
surface={activeAvatar.body.primary}
bodyNodes={activeAvatar.body.nodes}
colors={activeAvatar.colors}
avatarEyes={activeAvatarEyes}
id={`export-avatar-${activeAvatar.id}`}
/>
<div>
<small>{t('Avatar sélectionné')}</small>
<strong>{activeAvatar.name}</strong>
</div>
</div>
</InspectorCard>
<InspectorCard>
<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('Package React local (.zip)')}</small>
</span>
</Button>
<Button
variant="outline"
type="button"
aria-pressed={exportFormat === 'javascript'}
onClick={() => setExportFormat('javascript')}
>
<FileCode2 />
<span>
<strong>{t('Module JavaScript')}</strong>
<small>{t('Projet HTML + module JS (.zip)')}</small>
</span>
</Button>
</div>
</InspectorCard>
<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"
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>
<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}
id={`export-animation-${animation.id}`}
/>
<span>{animation.builtIn ? t(animation.name) : animation.name}</span>
</Button>
)
})}
</div>
</InspectorCard>
<Button
className="export-download"
type="button"
disabled={!selectedExportAnimations.length}
onClick={downloadAvatarExport}
>
<Download />
{t(
exportFormat === 'react' ? 'Télécharger le package React' : 'Télécharger le module'
)}
</Button>
</ExportSection>
<ExportSection
value="snapshot"
title="Mode photo"
subtitle="Capture une image statique de l’avatar."
>
<InspectorCard className="snapshot-preview-card">
<SnapshotPreview
scene={renderedScene}
colors={renderedColors}
background={snapshotBackground}
colorFrom={snapshotColorFrom}
colorTo={snapshotColorTo}
/>
<div>
<small>{t('Aperçu du mode photo')}</small>
<strong>{activeAvatar.name}</strong>
<span>
{snapshotSize} × {snapshotSize} px · {snapshotFormat.toUpperCase()}
</span>
</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>
</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>
</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>
)}
{activeSequence && !editorPageOpen && (
<motion.footer
className={`state-playback-footer${statePlayerExpanded ? ' is-expanded' : ''}`}
initial={{ opacity: 0, y: 18 }}
animate={{ opacity: 1, y: 0 }}
>
<Button
className="state-playback-expand"
variant="outline"
size="icon-sm"
type="button"
aria-expanded={statePlayerExpanded}
aria-label={t(
statePlayerExpanded
? 'Masquer les détails de l’animation'
: 'Afficher les détails de l’animation'
)}
onClick={() => setStatePlayerExpanded(expanded => !expanded)}
>
{statePlayerExpanded ? <ChevronDown /> : <ChevronUp />}
</Button>
{statePlayerExpanded && (
<motion.div
className="state-playback-details"
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
>
<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>
</motion.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}
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.footer>
)}
</main>
)
}