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 }` 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 ( <> updateImmediate({ ...expression, bodyColor })} /> {expression.bodyColor && ( )} updateHighlight(active ? 'head' : null)} onChange={value => updateImmediate({ ...expression, headX: value })} /> updateHighlight(active ? 'head' : null)} onChange={value => updateImmediate({ ...expression, headY: value })} /> updateHighlight(active ? 'head' : null)} onChange={value => updateImmediate({ ...expression, headZ: value })} /> updateImmediate({ ...expression, eyeColor })} /> {expression.eyeColor && ( )} {(['width', 'height', 'size'] as const).map(dimension => (

{t( { width: 'Largeur', height: 'Hauteur', size: 'Taille proportionnelle', }[dimension] )}

setLinked(current => ({ ...current, [dimension]: !current[dimension], })) } />
{(['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 ( updateHighlight( active ? linked[dimension] ? 'both' : side === 'Left' ? 'left' : 'right' : null ) } onChange={next => dimension === 'size' ? updateSize(side, next) : updateDimension(side, dimension, next) } /> ) })}
))}

{t('Œil gauche')}

updateHighlight(active ? 'left' : null)} onChange={value => updateImmediate({ ...expression, positionXLeft: value })} /> updateHighlight(active ? 'left' : null)} onChange={value => updateImmediate({ ...expression, positionYLeft: value })} />

{t('Œil droit')}

updateHighlight(active ? 'right' : null)} onChange={value => updateImmediate({ ...expression, positionXRight: value })} /> updateHighlight(active ? 'right' : null)} onChange={value => updateImmediate({ ...expression, positionYRight: value })} />
updateHighlight(active ? 'both' : null)} onChange={updateSpacing} />
setLinked(current => ({ ...current, rotation: !current.rotation }))} />
updateHighlight(active ? (linked.rotation ? 'both' : 'left') : null) } onChange={value => updateImmediate({ ...expression, leftAngle: value, ...(linked.rotation ? { rightAngle: -value } : {}), }) } /> updateHighlight(active ? (linked.rotation ? 'both' : 'right') : null) } onChange={value => updateImmediate({ ...expression, rightAngle: value, ...(linked.rotation ? { leftAngle: -value } : {}), }) } />
updateImmediate({ ...expression, perspective: value })} />
{t('Afficher le maillage')}
) } 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) => 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(null) const measuredSequenceIdRef = useRef(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 (
{sequenceEditing && !editing && ( 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)} /> )} {editing && ( duplicateExpression(editing.index, editing.draft, true)} onDelete={() => setDeleteExpressionOpen(true)} semanticKeyError={expressionSemanticKeyError(editing.draft)} /> )} {!sequenceEditing && !editing && (bodyEditing ? (

{t('Construction du corps')}

renameActiveAvatar(event.currentTarget.value)} onFocus={event => { if (focusAvatarName) event.currentTarget.select() }} onBlur={event => { if (!event.currentTarget.value.trim()) renameActiveAvatar('Unknown') setFocusAvatarName(false) }} />

{t('Choisis la forme principale puis assemble les primitives autour d’elle.')}

) : null)} {!editorPageOpen && (

{activeAvatar.name}

{t( mode === 'manual' ? 'Pose' : mode === 'avatars' ? 'Avatars' : mode === 'expressions' ? 'Expressions' : mode === 'states' ? 'Animations' : mode === 'photo' ? 'Mode photo' : 'Exporter' )}

{mode === 'manual' && ( )} {mode === 'photo' && ( )}
)} {!editing && mode === 'manual' && (
{bodyEditing && ( <> updateAvatarColors({ body })} />
{t('Vectoriel')}

{t( 'Définis l’identité du regard de cet avatar. Les poses s’ajoutent ensuite à cette base.' )}

{(['width', 'height', 'size'] as const).map(dimension => (

{t( { width: 'Largeur', height: 'Hauteur', size: 'Taille proportionnelle', }[dimension] )}

setLinked(current => ({ ...current, [dimension]: !current[dimension], })) } />
{(['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 ( updateHighlight( active ? linked[dimension] ? 'both' : side === 'Left' ? 'left' : 'right' : null ) } onChange={next => dimension === 'size' ? updateAvatarEyeSize(side, next) : updateAvatarEyeDimension(side, dimension, next) } /> ) })}
))}

{t('Position et espacement')}

{t('Coordonnées propres à l’avatar, indépendantes des poses.')}

setLinked(current => ({ ...current, position: !current.position })) } />
{(['Left', 'Right'] as Side[]).map(side => (

{t(side === 'Left' ? 'Œil gauche' : 'Œil droit')}

updateHighlight( active ? linked.position ? 'both' : side === 'Left' ? 'left' : 'right' : null ) } onChange={value => updateAvatarEyePosition(side, 'X', value)} /> updateHighlight( active ? linked.position ? 'both' : side === 'Left' ? 'left' : 'right' : null ) } onChange={value => updateAvatarEyePosition(side, 'Y', value)} />
))}
updateHighlight(active ? 'both' : null)} onChange={spacing => updateAvatarEyes({ spacing })} />
setLinked(current => ({ ...current, rotation: !current.rotation })) } />
updateHighlight(active ? (linked.rotation ? 'both' : 'left') : null) } onChange={leftAngle => updateAvatarEyes({ leftAngle, ...(linked.rotation ? { rightAngle: -leftAngle } : {}), }) } /> updateHighlight(active ? (linked.rotation ? 'both' : 'right') : null) } onChange={rightAngle => updateAvatarEyes({ rightAngle, ...(linked.rotation ? { leftAngle: -rightAngle } : {}), }) } />
updateAvatarColors({ eyes })} />
)} {!bodyEditing && }
)} {!editorPageOpen && mode === 'avatars' && } {!sequenceEditing && !editing && bodyEditing && (
)} {!sequenceEditing && !editing && !bodyEditing && mode === 'expressions' && (

{expressions.length} presets

{t('Double-clic pour modifier')}
{expressions.map((preset, index) => ( 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} /> ))}
{ springSpeedRef.current = value setSpringSpeed(value) }} />
)} {!sequenceEditing && !editing && !bodyEditing && mode === 'states' && (

{sequences.length} {t('animations')}

{groupSequences(sequences).map(group => (
event.preventDefault()} onDrop={event => { event.preventDefault() commitStateMove(null, group.name) }} > {group.sequences.every(sequence => sequence.builtIn) ? t(group.name) : group.name}
{group.sequences.map(sequence => { const firstStep = sequence.steps[0] const firstExpression = firstStep ? expressionById.get(firstStep.expressionId) : undefined const card = ( ) return ( openSequenceEditor(sequence)}> {t('Modifier')} duplicateState(sequence)}> {t('Dupliquer')} { openSequenceEditor(sequence) setDeleteSequenceOpen(true) }} > {t('Supprimer')} ) })}
))}
)} {!sequenceEditing && !editing && !bodyEditing && mode === 'photo' && (
{ 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) }} > {t('Pose')} {t('Orientation, regard, couleurs et perspective.')} {t('Cadrage')} {t('Position, zoom et coins du cadre photo.')}
updateSnapshotComposition({ x })} /> updateSnapshotComposition({ y })} /> updateSnapshotComposition({ scale: zoom / 100 })} /> updateSnapshotComposition({ cornerRadius })} />
{expressions.map((preset, index) => ( transitionToExpression(preset, index)} runtimeError={expressionSemanticKeyError(preset)} /> ))}
{t('Style')} {snapshotBackground !== 'transparent' && (
{(snapshotBackground === 'linear' || snapshotBackground === 'radial') && ( )}
)}
{t('Format d’export')} {t('Choisis le type de fichier généré par le mode photo.')}
{t('Définition')} {t('Dimensions du fichier exporté.')}
)} {!sequenceEditing && !editing && !bodyEditing && mode === 'export' && (
{t('Avatar sélectionné')} {activeAvatar.name}

{selectedExportAnimations.length}/{sequences.length} {t('sélectionnées')}

{t('Animations à exporter')}

{exportAnimationsOpen && (
{sequences.map(animation => { const firstStep = animation.steps[0] const firstExpression = firstStep ? expressionById.get(firstStep.expressionId) : undefined return ( ) })}
)}
{runtimeExportErrors.length > 0 && (
{t('Export runtime incomplet')}
    {runtimeExportErrors.map((error, index) => (
  • {error}
  • ))}

{t( 'Corrige les clés signalées dans les éditeurs Expressions ou Animations.' )}

)}

{t('Démarrage rapide')}

{t('Installation')} {exportFormat === 'react' ? reactQuickStartInstall : webQuickStartInstall}
{t('Utilisation minimale')}
                        
                          
                            {exportFormat === 'react'
                              ? reactQuickStartExample(runtimePreviewAnimation)
                              : webQuickStartExample(runtimePreviewAnimation)}
                          
                        
                      
{t('Prêt à exporter')} {selectedExportAnimations.length} {t('animations')} ·{' '} {runtimeDefinitionResult.ok ? runtimeDefinitionResult.value.expressionOrder.length : 0}{' '} {t('expressions')}

{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.' )}

{t('Composer dans le live preview')}

{t( 'Choisis une expression, ajuste la pose et cadre l’avatar dans un espace dédié.' )}

{ prepareStudioProjectImport(event.currentTarget.files?.[0]) event.currentTarget.value = '' }} />
{projectImportError && (

{projectImportError}

)}
)}
{activeSequence && !editorPageOpen && mode !== 'photo' && (
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) }} >
{activeSequence.steps.map((step, position) => { const expressionIndex = findExpressionIndex(expressions, step.expressionId) const preset = expressions[expressionIndex] if (!preset) return null return ( ) })}

{t('Détails de l’animation')}

{activeSequenceLabel}

{activeSequence.builtIn ? t(activeSequence.description) : activeSequence.description}

{t('Mode de lecture')} · {t(activeSequence.playbackMode)}
{t('Expressions')} {activeSequence.steps.length} {activeSequence.steps .map(step => formatSeconds(step.holdMs, language)) .join(' · ')}
{t('Premier clignement')} {activeSequence.blink.enabled ? formatSeconds(activeSequence.blink.initialDelayMs, language) : t('Désactivé')} {t('après le lancement')}
{t('Intervalle du clignement')} {formatSeconds(activeSequence.blink.minIntervalMs, language)}– {formatSeconds(activeSequence.blink.maxIntervalMs, language)} {t('tirage aléatoire')}
{t('Durée du clignement')} {activeSequence.blink.durationMs} ms {t('fermeture et ouverture')}
)} {!editorPageOpen && mode !== 'photo' && ( )}
) }