import { ArrowLeft, CircleCheck, ChevronDown, ChevronUp, Copy, Download, FileCode2, Move3D, Pencil, Play, Plus, RotateCcw, Smile, Trash2, TriangleAlert, Upload, } from 'lucide-react' import { AnimatePresence, animate, motion, useMotionValue, useTransform } from 'motion/react' import { type CSSProperties, useLayoutEffect, useRef, useState } from 'react' import { Avatar as RuntimeAvatar, type AvatarController as RuntimeAvatarController, } from '@bible-strong/avatar-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 { 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, SnapshotPreview, StatePlayer, } from '@/app/components/common' import { ColorField, LinkButton, NumericField } from '@/app/components/controls' import { formatSeconds, scaleSurface, type Side, type SnapshotFormat } from '@/app/studio-utils' import { SequenceWorkspace } from '@/features/animation/components/SequenceWorkspace' import { findExpressionIndex, groupSequences } from '@/features/animation/sequences' import { defaultAvatarEyes, defaultPixelRenderStyle, type AvatarRenderStyle, } 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 { AvatarPage } from '@/features/studio/components/AvatarDrawer' import { StudioIdentity } from '@/features/studio/components/StudioIdentity' import type { StudioController } from '@/features/studio/useStudioController' const runtimeInstallExample = 'npm install @bible-strong/avatar-core @bible-strong/avatar-react react react-dom' const runtimeReactExample = `import { validateAvatarDefinition } from '@bible-strong/avatar-core' import { Avatar } from '@bible-strong/avatar-react' import '@bible-strong/avatar-react/styles.css' import avatarJson from './strobi.avatar.json' const avatar = validateAvatarDefinition(avatarJson) if (!avatar.ok) throw new Error(avatar.errors[0].message) export function Strobi() { return }` const codeTokenPattern = /(\/\/.*|'.*?'|".*?"|@[a-z0-9-/]+|<\/?[A-Z][A-Za-z]*|\b(?:npm|install|import|from|const|if|throw|new|export|function|return)\b)/g const highlightedCode = (source: string) => source.split(codeTokenPattern).map((token, index) => { if (!token) return null const kind = token.startsWith('//') ? 'comment' : token.startsWith("'") || token.startsWith('"') || token.startsWith('@') ? 'string' : token.startsWith('<') ? 'tag' : /^(?:npm|install|import|from|const|if|throw|new|export|function|return)$/.test(token) ? 'keyword' : 'plain' return ( {token} ) }) export function StudioInspector({ controller }: { controller: StudioController }) { const [runtimeExampleOpen, setRuntimeExampleOpen] = useState(false) const runtimeExampleRef = useRef(null) 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, clearLocalStudioDocument, copyAvatarRuntimeDefinition, createNewAvatar, deleteSelectedBodyNode, downloadAvatarExport, downloadAvatarRuntimeDefinition, downloadStudioProject, draggedAvatarId, draggedExpressionId, draggedStateId, draggingAvatarId, draggingExpressionId, draggingStateId, duplicateAvatar, duplicateExpression, duplicateSelectedBodyNode, duplicateSequenceEditing, duplicateState, editing, editorPageOpen, exportAnimationIdSet, exportFormat, expression, expressionById, expressionSemanticKeyError, expressionDragOrigin, expressionDragPreview, expressions, focusAvatarName, language, launchSequence, linked, mode, openExpressionEditor, openSequenceEditor, pauseState, playbackStatus, playbackVisual, prepareStudioProjectImport, previewAvatarMove, previewExpressionDraft, previewExpressionMove, previewStateMove, projectImportError, projectImportRef, reduceMotion, renameActiveAvatar, renderedColors, renderedScene, runtimeDefinitionResult, runtimeCopyStatus, runtimeExportErrors, runtimeStandardAvailability, saveAvatarEditing, saveEditing, saveSequenceEditing, selectBodyNode, selectedBodyNode, selectedBodyNodeId, selectedExportAnimations, selectedSequenceStepId, selectedState, sequenceEditing, animationSemanticKeyError, sequences, setDeleteAvatarOpen, setDeleteExpressionOpen, setDeleteSequenceOpen, setDraggingAvatarId, setDraggingExpressionId, setDraggingStateId, setExportAnimationIds, setExportFormat, setFocusAvatarName, setLinked, setLanguage, 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, updateAvatarRenderStyle, updateAvatarEyeDimension, updateAvatarEyePosition, updateAvatarEyeSize, updateAvatarEyes, updateDimension, updateHighlight, updateImmediate, updateNodeVector, updateSelectedBodyNode, updateSize, updateSpacing, updateSurface, updateWireVisibility, workspaceBackButtonRef, } = controller const pixelRenderStyle = activeAvatar.renderStyle.type === 'pixel' ? activeAvatar.renderStyle : null 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' : 'Exporter' )}

{mode === 'manual' && ( )}
)} {!editing && mode === 'manual' && (
{bodyEditing && ( <>
{bodyNodes.map(node => ( ))}
{t('Ajouter une forme')} · {bodyNodes.length}/{MAX_BODY_NODES}
{bodyPrimitiveTypes.map(type => ( ))}
{selectedBodyNode && (
{selectedBodyNode.name}

{t('Gizmo local')} {t( 'Glisse un axe pour déplacer la forme, ou un anneau pour la faire tourner.' )}

updateSelectedBodyNode(node => ({ ...node, surface: scaleSurface(node.surface, size, { width: 10, height: 10, depth: 10, }), })) } /> {(['width', 'height', 'depth'] as const).map(dimension => ( updateSelectedBodyNode(node => ({ ...node, surface: { ...node.surface, [dimension]: value }, })) } /> ))} {(selectedBodyNode.surface.type === 'cube' || selectedBodyNode.surface.type === 'diamond' || selectedBodyNode.surface.type === 'cylinder') && ( updateSelectedBodyNode(node => ({ ...node, surface: { ...node.surface, roundness }, })) } /> )} {(selectedBodyNode.surface.type === 'cylinder' || selectedBodyNode.surface.type === 'cone') && ( updateSelectedBodyNode(node => ({ ...node, surface: { ...node.surface, morphRoundness }, })) } /> )} {selectedBodyNode.surface.type === 'cone' && ( <> updateSelectedBodyNode(node => ({ ...node, surface: { ...node.surface, tipRoundness }, })) } /> updateSelectedBodyNode(node => ({ ...node, surface: { ...node.surface, baseRoundness }, })) } /> )}

{t('Position locale')}

{(['X', 'Y', 'Z'] as const).map((axis, index) => ( updateNodeVector('position', index as 0 | 1 | 2, value) } /> ))}

{t('Rotation locale')}

{(['X', 'Y', 'Z'] as const).map((axis, index) => ( updateNodeVector('rotation', index as 0 | 1 | 2, value) } /> ))}
)}
{bodyPrimitiveTypes.map(type => { const previewSurface = type === surface.type ? surface : surfacePresets[type] return ( ) })}
updateSurface( scaleSurface(surface, size, { width: 120, height: 120, depth: 100 }) ) } /> updateSurface({ ...surface, width })} /> updateSurface({ ...surface, height })} /> updateSurface({ ...surface, depth })} /> {(surface.type === 'cube' || surface.type === 'diamond') && ( updateHighlight(active ? 'head' : null)} onChange={roundness => updateSurface({ ...surface, roundness })} /> )} {surface.type === 'cylinder' && ( updateHighlight(active ? 'head' : null)} onChange={roundness => updateSurface({ ...surface, roundness })} /> )} {(surface.type === 'cylinder' || surface.type === 'cone') && ( updateHighlight(active ? 'head' : null)} onChange={morphRoundness => updateSurface({ ...surface, morphRoundness }) } /> )} {surface.type === 'cone' && ( <> updateHighlight(active ? 'head' : null)} onChange={tipRoundness => updateSurface({ ...surface, tipRoundness }) } /> updateHighlight(active ? 'head' : null)} onChange={baseRoundness => updateSurface({ ...surface, baseRoundness }) } /> )}
updateAvatarColors({ body })} />
{t('Style')} {pixelRenderStyle && (
updateAvatarRenderStyle({ ...pixelRenderStyle, resolution: Math.round(resolution), }) } />
)}

{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 && ( <> 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')}
)}
)} {!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 === 'export' && (
{t('Définition runtime')} {activeAvatar.name} {t('Fichier .avatar.json portable')}
{runtimeDefinitionResult.ok ? : }
{t( runtimeDefinitionResult.ok ? 'Prêt pour l’export runtime' : 'Export runtime incomplet' )} {runtimeDefinitionResult.ok && runtimeStandardAvailability && ( {runtimeStandardAvailability.available.length}/6{' '} {t('animations standard disponibles')} )}
{runtimeExportErrors.length > 0 && ( <>
    {runtimeExportErrors.map((error, index) => (
  • {error}
  • ))}
)}
{t('Installation')}
                        {highlightedCode(runtimeInstallExample)}
                      
{t('Exemple React minimal')}
                        {highlightedCode(runtimeReactExample)}
                      
{runtimeExampleOpen && runtimeDefinitionResult.ok && (
{t('Aperçu avec le package React')} defaultAnimation="idle"
)}
{runtimeCopyStatus !== 'idle' && (

{t( runtimeCopyStatus === 'success' ? 'JSON runtime copié dans le presse-papiers.' : 'Impossible de copier le JSON runtime.' )}

)}
{t('Avatar sélectionné')} {activeAvatar.name}

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

{t('Animations à exporter')}

{sequences.map(animation => { const firstStep = animation.steps[0] const firstExpression = firstStep ? expressionById.get(firstStep.expressionId) : undefined return ( ) })}
{t('Aperçu du mode photo')} {activeAvatar.name} {snapshotSize} × {snapshotSize} px · {snapshotFormat.toUpperCase()}
{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é.')}
{ prepareStudioProjectImport(event.currentTarget.files?.[0]) event.currentTarget.value = '' }} />
{projectImportError && (

{projectImportError}

)}
)}
{activeSequence && !editorPageOpen && (
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 && ( )}
) }