import { animate, useMotionValue, useMotionValueEvent, useReducedMotion } from 'motion/react' import { useEffect, useEffectEvent, useRef, useState } from 'react' import { useStudioLanguage } from '@/i18n' import { AMBIENT_FRAME_MS, bounded, createExpressionId, downloadBlob, INSPECTOR_FRAME_MS, interpolateHexColor, poseWithAvatarEyes, resolveColors, RETARGET_BLEND_MS, type ExportFormat, type Highlight, type Mode, type PlaybackStatus, type Side, type SnapshotFormat, } from '@/app/studio-utils' import { advancePlaybackTimeline, beginPlayback, createPlaybackTimeline, pausePlaybackTimeline, schedulePlaybackBlink, schedulePlaybackStep, stopPlaybackTimeline, } from '@/features/animation/playback' import { createSequence, duplicateSequence, findExpressionIndex, getSequenceSpring, readSequenceClock, remapSequencesAfterExpressionDelete, type AvatarSequence, type SequenceStep, } from '@/features/animation/sequences' import { ambientBodyOffset, ambientEyeOffset, applyAmbientBodyMotion, hasAmbientMotion, } from '@/features/avatar/ambientMotion' import { avatarDefinitionFileName, createAvatarDefinition, getSemanticKeyIssue, type SemanticKeyIssueCode, } from '@/features/avatar/avatarDefinition' import { cloneAvatarBehavior, createAvatar, createUnkeyedExpressionCopy, defaultAvatarEyes, resolveAvatarBehavior, type AvatarBehaviorLibrary, type AvatarColors, type AvatarEyeDefaults, type StudioAvatar, } from '@/features/avatar/avatars' import { bodyPrimitiveTypes, createBodyNode, duplicateBodyNode, MAX_BODY_NODES, type BodyNode, } from '@/features/avatar/body' import { scaleEye, updateEyeDimension, updateEyePosition, } from '@/features/avatar/expressionEditing' import { expressionFields, poseFromExpression, renderAvatar, type AvatarPose, type Expression, } from '@/features/avatar/geometry' import { defaultExpression } from '@/features/avatar/presets' import { type SurfaceConfig } from '@/features/avatar/surfaces' import { avatarDemoFileName, generateJavaScriptEsmPackage, generateReactVitePackage, } from '@/features/export/exporter' import { serializeAvatarSnapshot, serializePixelSnapshot, snapshotFileName, type SnapshotBackground, } from '@/features/export/snapshotExporter' import { resetBodyEditorView, resolveCanvasPreviewExpression, shouldSyncCanvasPreviewToReact, type CanvasPreviewTarget, } from '@/features/rendering/canvasPreview' import { createRenderedRotationGizmo, paintRenderedRotationGizmo, } from '@/features/rendering/renderedRotationGizmo' import { createRenderedColors, createRenderedScene, paintRenderedColors, paintRenderedOffset, paintRenderedScene, } from '@/features/rendering/renderedScene' import { paintPixelAvatar } from '@/features/rendering/pixelRenderer' import { createStudioDocumentStore, loadStudioDocument, parseImportedStudioDocument, persistStudioDocument, serializeStudioDocument, type StatePlaybackSelection, type StudioDocument, } from '@/features/studio/studioDocument' export function useStudioController() { const { language, setLanguage, t } = useStudioLanguage() const [mode, setMode] = useState('avatars') const [initialDocument] = useState(loadStudioDocument) const [documentStore] = useState(() => createStudioDocumentStore(initialDocument)) const initialLibrary = initialDocument.library const [avatars, setAvatars] = useState(initialLibrary.avatars) const [activeAvatarId, setActiveAvatarId] = useState(initialLibrary.activeAvatarId) const initialAvatar = initialLibrary.avatars.find(avatar => avatar.id === initialLibrary.activeAvatarId) ?? initialLibrary.avatars[0] const [baseBehavior] = useState(() => ({ expressions: initialDocument.expressions, sequences: initialDocument.sequences, })) const initialBehavior = resolveAvatarBehavior(initialAvatar, baseBehavior) const [surface, setSurface] = useState(initialAvatar.body.primary) const [bodyNodes, setBodyNodes] = useState(initialAvatar.body.nodes) const [selectedBodyNodeId, setSelectedBodyNodeId] = useState<'primary' | string | null>('primary') const [selectedEyeSide, setSelectedEyeSide] = useState<-1 | 1 | null>(null) const [expressions, setExpressions] = useState(initialBehavior.expressions) const [sequences, setSequences] = useState(initialBehavior.sequences) const [exportFormat, setExportFormat] = useState('react') const [exportAnimationIds, setExportAnimationIds] = useState(() => initialBehavior.sequences.map(animation => animation.id) ) const [snapshotBackground, setSnapshotBackground] = useState('transparent') const [snapshotColorFrom, setSnapshotColorFrom] = useState('#F5F7FC') const [snapshotColorTo, setSnapshotColorTo] = useState('#C9D5FF') const [snapshotSize, setSnapshotSize] = useState('1024') const [snapshotFormat, setSnapshotFormat] = useState('png') const [photoFlash, setPhotoFlash] = useState(0) const [runtimeCopyFeedback, setRuntimeCopyFeedback] = useState<{ status: 'idle' | 'success' | 'error' source?: readonly unknown[] }>({ status: 'idle' }) const initialStatePlayback = initialDocument.playback const updateStudioLibrary = (library: typeof initialDocument.library) => documentStore.update({ library }) const persistStatePlayback = (playback: StatePlaybackSelection) => documentStore.update({ playback }) const [bodyEditing, setBodyEditing] = useState(false) const modeRef = useRef(mode) useEffect(() => { modeRef.current = mode }, [mode]) const avatarEditSnapshot = useRef<{ avatars: StudioAvatar[] activeAvatarId: string } | null>(null) const workspaceBackButtonRef = useRef(null) const [focusAvatarName, setFocusAvatarName] = useState(false) const initialExpression = expressions[0] ?? defaultExpression const [expression, setExpression] = useState({ ...initialExpression }) const initialDisplayColors = resolveColors(initialExpression, initialAvatar.colors) const [renderedColors] = useState(() => createRenderedColors(initialDisplayColors)) const setDisplayColors = (next: AvatarColors) => { paintRenderedColors(renderedColors, next) } const [deleteAvatarOpen, setDeleteAvatarOpen] = useState(false) const [deleteExpressionOpen, setDeleteExpressionOpen] = useState(false) const [deleteSequenceOpen, setDeleteSequenceOpen] = useState(false) const [pendingProjectImport, setPendingProjectImport] = useState<{ document: StudioDocument fileName: string } | null>(null) const [projectImportError, setProjectImportError] = useState(null) const projectImportRef = useRef(null) const [statePlayerExpanded, setStatePlayerExpanded] = useState(false) const [activeExpression, setActiveExpression] = useState(null) const [editing, setEditing] = useState<{ index: number | null; draft: Expression } | null>(null) const [showWire, setShowWire] = useState(false) const [springSpeed, setSpringSpeed] = useState(7) const [linked, setLinked] = useState({ width: true, height: true, size: true, position: true, rotation: true, }) const [highlight, setHighlight] = useState(null) const [selectedState, setSelectedState] = useState(() => sequences.some(sequence => sequence.id === initialStatePlayback.stateId) ? initialStatePlayback.stateId! : sequences.some(sequence => sequence.id === 'idle') ? 'idle' : (sequences[0]?.id ?? '') ) const [activeState, setActiveState] = useState(null) const [statePlaying, setStatePlaying] = useState(false) const [playbackStatus, setPlaybackStatus] = useState(() => initialStatePlayback.stateId === null ? 'stopped' : 'paused' ) const [playbackVisual, setPlaybackVisual] = useState({ position: null as number | null, run: 0, durationMs: 0, }) const [sequenceEditing, setSequenceEditing] = useState<{ sourceId: string | null draft: AvatarSequence } | null>(null) const [selectedSequenceStepId, setSelectedSequenceStepId] = useState(null) const stateTimer = useRef | null>(null) const activeSequenceRef = useRef(null) const editorStateSnapshot = useRef<{ stateId: string playing: boolean expression: Expression } | null>(null) const initialStatePlaybackApplied = useRef(false) const blinkTimer = useRef | null>(null) const [initialPlaybackTimeline] = useState(createPlaybackTimeline) const playbackTimeline = useRef(initialPlaybackTimeline) const reduceMotion = useReducedMotion() const avatarsRef = useRef(avatars) const expressionsRef = useRef(expressions) const sequencesRef = useRef(sequences) const draggedAvatarId = useRef(null) const avatarDragOrigin = useRef(null) const avatarDragPreview = useRef(avatars) const [draggingAvatarId, setDraggingAvatarId] = useState(null) const draggedExpressionId = useRef(null) const expressionDragOrigin = useRef(null) const expressionDragPreview = useRef(expressions) const [draggingExpressionId, setDraggingExpressionId] = useState(null) const draggedStateId = useRef(null) const stateDragOrigin = useRef(null) const stateDragPreview = useRef(sequences) const [draggingStateId, setDraggingStateId] = useState(null) const activeAvatarIdRef = useRef(activeAvatarId) const surfaceRef = useRef(surface) const bodyNodesRef = useRef(bodyNodes) const showWireRef = useRef(showWire) const highlightRef = useRef(highlight) const persistActiveBehavior = ( nextExpressions: Expression[], nextSequences: AvatarSequence[] ) => { const behavior = cloneAvatarBehavior({ expressions: nextExpressions, sequences: nextSequences, }) const nextAvatars = avatarsRef.current.map(avatar => avatar.id === activeAvatarIdRef.current ? { ...avatar, behavior } : avatar ) avatarsRef.current = nextAvatars setAvatars(nextAvatars) updateStudioLibrary({ activeAvatarId: activeAvatarIdRef.current, avatars: nextAvatars }) } const updateStudioExpressions = (nextExpressions: Expression[]) => { expressionsRef.current = nextExpressions persistActiveBehavior(nextExpressions, sequencesRef.current) } const updateStudioSequences = (nextSequences: AvatarSequence[]) => { sequencesRef.current = nextSequences persistActiveBehavior(expressionsRef.current, nextSequences) } const [initialRender] = useState(() => { const pose = poseFromExpression(initialExpression) return { pose, geometry: renderAvatar( poseWithAvatarEyes(initialExpression, initialAvatar.eyes), surface, 1, { bodyNodes } ), } }) const { pose: initialPose, geometry: initialGeometry } = initialRender const displayedPose = useRef(initialPose) const transitionFrame = useRef(null) const ambientFrame = useRef(null) const eyeAmbientStartedAt = useRef(-1) const bodyAmbientStartedAt = useRef(-1) const lastEyeAmbientElapsed = useRef(0) const lastBodyAmbientElapsed = useRef(0) const lastAmbientFrame = useRef(0) const eyeAmbientSignature = useRef('none') const bodyAmbientSignature = useRef('none') const lastAmbientStrength = useRef(1) const transitionTarget = useRef({ ...initialExpression }) const canonicalTarget = useRef({ ...initialExpression }) const retargetFrom = useRef(null) const retargetTo = useRef(null) const retargetStartedAt = useRef(null) const [transitionVelocity] = useState( () => Object.fromEntries(expressionFields.map(field => [field, 0])) as Record< (typeof expressionFields)[number], number > ) const lastTransitionTime = useRef(null) const lastInspectorFrame = useRef(0) const springSpeedRef = useRef(springSpeed) const sequenceTransitionRef = useRef>({ transitionMs: 500, transition: 'smooth', }) const activeSequenceTransition = useRef<{ target: Expression index: number | null settings: Pick remainingMs: number } | null>(null) const pausedSequenceTransition = useRef(null) const blinkControls = useRef | null>(null) const blinkAnimating = useRef(false) const blinkValue = useMotionValue(1) const [renderedScene] = useState(() => createRenderedScene(initialGeometry)) const [renderedRotationGizmo] = useState(() => createRenderedRotationGizmo(initialExpression)) const bodyColorAnimation = useRef | null>(null) const eyeColorAnimation = useRef | null>(null) const paintPose = ( pose: AvatarPose, blink?: number, frameTimeMs?: number, ambientStrength?: number ) => { displayedPose.current = pose const resolvedAmbientStrength = ambientStrength ?? (transitionFrame.current === null ? 1 : lastAmbientStrength.current) lastAmbientStrength.current = resolvedAmbientStrength paintRenderedRotationGizmo(renderedRotationGizmo, pose.expression) const avatar = avatarsRef.current.find(item => item.id === activeAvatarIdRef.current) const eyeAmbientEnabled = !reduceMotion && pose.expression.eyeMotion !== 'none' const bodyAmbientEnabled = !reduceMotion && pose.expression.bodyMotion !== 'none' const eyeSignature = eyeAmbientEnabled ? pose.expression.eyeMotion : 'none' const bodySignature = bodyAmbientEnabled ? pose.expression.bodyMotion : 'none' if (eyeSignature !== eyeAmbientSignature.current) { eyeAmbientSignature.current = eyeSignature eyeAmbientStartedAt.current = -1 lastEyeAmbientElapsed.current = 0 } if (bodySignature !== bodyAmbientSignature.current) { bodyAmbientSignature.current = bodySignature bodyAmbientStartedAt.current = -1 lastBodyAmbientElapsed.current = 0 } if (eyeAmbientEnabled && frameTimeMs !== undefined) { if (eyeAmbientStartedAt.current < 0) eyeAmbientStartedAt.current = frameTimeMs lastEyeAmbientElapsed.current = frameTimeMs - eyeAmbientStartedAt.current } if (bodyAmbientEnabled && frameTimeMs !== undefined) { if (bodyAmbientStartedAt.current < 0) bodyAmbientStartedAt.current = frameTimeMs lastBodyAmbientElapsed.current = frameTimeMs - bodyAmbientStartedAt.current } const renderedExpression = bodyAmbientEnabled ? applyAmbientBodyMotion( pose.expression, lastBodyAmbientElapsed.current, resolvedAmbientStrength ) : pose.expression const eyeOffset = eyeAmbientEnabled ? ambientEyeOffset(pose.expression, lastEyeAmbientElapsed.current, resolvedAmbientStrength) : { x: 0, y: 0 } const renderPose = avatar ? poseWithAvatarEyes(renderedExpression, avatar.eyes ?? defaultAvatarEyes) : poseFromExpression(renderedExpression) const geometry = renderAvatar(renderPose, surfaceRef.current, blink ?? blinkValue.get(), { includeWire: showWireRef.current || highlightRef.current === 'head', bodyNodes: bodyNodesRef.current, eyeOffset, }) paintRenderedScene(renderedScene, geometry) paintRenderedOffset( renderedScene, bodyAmbientEnabled ? ambientBodyOffset( pose.expression, lastBodyAmbientElapsed.current, resolvedAmbientStrength ) : { x: 0, y: 0 } ) } useMotionValueEvent(blinkValue, 'change', latest => paintPose(displayedPose.current, latest)) const paintAmbientFrame = useEffectEvent((time: number) => { if (transitionFrame.current === null && time - lastAmbientFrame.current >= AMBIENT_FRAME_MS) { lastAmbientFrame.current = time paintPose(displayedPose.current, undefined, time) } }) const ambientLoopActive = !reduceMotion && hasAmbientMotion(editing?.draft ?? expression) useEffect(() => { if (!ambientLoopActive) return const tick = (time: number) => { paintAmbientFrame(time) ambientFrame.current = requestAnimationFrame(tick) } ambientFrame.current = requestAnimationFrame(tick) return () => { if (ambientFrame.current !== null) cancelAnimationFrame(ambientFrame.current) } }, [ambientLoopActive]) useEffect( () => () => { if (transitionFrame.current !== null) cancelAnimationFrame(transitionFrame.current) blinkControls.current?.stop() bodyColorAnimation.current?.stop() eyeColorAnimation.current?.stop() if (stateTimer.current) clearTimeout(stateTimer.current) if (blinkTimer.current) clearTimeout(blinkTimer.current) }, [] ) const stopTransition = (resetVelocity: boolean) => { if (transitionFrame.current !== null) cancelAnimationFrame(transitionFrame.current) transitionFrame.current = null lastTransitionTime.current = null retargetFrom.current = null retargetTo.current = null retargetStartedAt.current = null lastInspectorFrame.current = 0 if (resetVelocity) { expressionFields.forEach(field => { transitionVelocity[field] = 0 }) } } const stopColorTransitions = () => { bodyColorAnimation.current?.stop() eyeColorAnimation.current?.stop() bodyColorAnimation.current = null eyeColorAnimation.current = null } const freezeLivePreviewForManipulation = () => { if (statePlaying) pauseState() const renderedExpression = { ...displayedPose.current.expression } stopTransition(true) stopColorTransitions() activeSequenceTransition.current = null pausedSequenceTransition.current = null transitionTarget.current = renderedExpression canonicalTarget.current = renderedExpression setExpression(renderedExpression) setActiveExpression(null) return renderedExpression } const updateImmediate = (next: Expression, preservePlayback = false) => { if (!preservePlayback && statePlaying) pauseState() stopTransition(true) stopColorTransitions() const pose = poseFromExpression(next) transitionTarget.current = next canonicalTarget.current = next setExpression(next) const avatar = avatarsRef.current.find(item => item.id === activeAvatarIdRef.current) if (avatar) setDisplayColors(resolveColors(next, avatar.colors)) setActiveExpression(null) paintPose(pose) } const transitionToExpression = ( next: Expression, index: number | null = null, transitionSettings?: Pick ) => { if (!transitionSettings && statePlaying) pauseState() sequenceTransitionRef.current = transitionSettings ?? { transitionMs: 500, transition: 'smooth', } setActiveExpression(index) const avatar = avatarsRef.current.find(item => item.id === activeAvatarIdRef.current) if (reduceMotion || transitionSettings?.transitionMs === 0) { updateImmediate(next, Boolean(transitionSettings)) setActiveExpression(index) return } const current = displayedPose.current.expression const nearestAngle = (target: number, from: number) => { let resolved = target while (resolved - from > 180) resolved -= 360 while (resolved - from < -180) resolved += 360 return resolved } canonicalTarget.current = next const resolvedTarget = { ...next, headX: nearestAngle(next.headX, current.headX), headY: nearestAngle(next.headY, current.headY), headZ: nearestAngle(next.headZ, current.headZ), leftAngle: nearestAngle(next.leftAngle, current.leftAngle), rightAngle: nearestAngle(next.rightAngle, current.rightAngle), } if (transitionSettings) { stopTransition(true) stopColorTransitions() const durationMs = transitionSettings.transitionMs lastAmbientStrength.current = 0 const from = { ...current } const fromColors = { body: renderedColors.body.get(), eyes: renderedColors.eyes.get(), } const targetColors = avatar ? resolveColors(next, avatar.colors) : fromColors let startedAt: number | null = null activeSequenceTransition.current = { target: next, index, settings: transitionSettings, remainingMs: durationMs, } const tickSequenceTransition = (time: number) => { if (startedAt === null) startedAt = time const elapsed = time - startedAt const progress = Math.min(elapsed / durationMs, 1) const eased = transitionSettings.transition === 'smooth' ? progress * progress * (3 - 2 * progress) : transitionSettings.transition === 'snappy' ? 1 - (1 - progress) ** 3 : 1 - Math.exp(-6 * progress) * Math.cos(8 * progress) const animated = { ...from, eyeMotion: next.eyeMotion, bodyMotion: next.bodyMotion } expressionFields.forEach(field => { animated[field] = from[field] + (resolvedTarget[field] - from[field]) * eased }) activeSequenceTransition.current = { target: next, index, settings: transitionSettings, remainingMs: Math.max(durationMs - elapsed, 0), } setDisplayColors({ body: interpolateHexColor(fromColors.body, targetColors.body, bounded(eased, 0, 1)), eyes: interpolateHexColor(fromColors.eyes, targetColors.eyes, bounded(eased, 0, 1)), }) paintPose(poseFromExpression(animated), undefined, time, bounded(eased, 0, 1)) if ( modeRef.current === 'manual' && time - lastInspectorFrame.current >= INSPECTOR_FRAME_MS ) { lastInspectorFrame.current = time setExpression(animated) } if (progress < 1) { transitionFrame.current = requestAnimationFrame(tickSequenceTransition) return } transitionFrame.current = null activeSequenceTransition.current = null canonicalTarget.current = next transitionTarget.current = next setExpression(next) setDisplayColors(targetColors) paintPose(poseFromExpression(next)) } transitionFrame.current = requestAnimationFrame(tickSequenceTransition) return } if (avatar) { const targetColors = resolveColors(next, avatar.colors) bodyColorAnimation.current = animate(renderedColors.body, targetColors.body, { duration: 0.35, ease: 'easeInOut', }) eyeColorAnimation.current = animate(renderedColors.eyes, targetColors.eyes, { duration: 0.35, ease: 'easeInOut', }) } if (transitionFrame.current !== null) { retargetFrom.current = { ...transitionTarget.current } retargetTo.current = resolvedTarget retargetStartedAt.current = -1 return } transitionTarget.current = resolvedTarget lastAmbientStrength.current = 0 const initialTransitionDistance = expressionFields.reduce( (total, field) => total + Math.abs(resolvedTarget[field] - current[field]), 0 ) let transitionProgress = 0 const tick = (time: number) => { const previousTime = lastTransitionTime.current ?? time const deltaTime = Math.min(Math.max((time - previousTime) / 1000, 1 / 240), 1 / 30) lastTransitionTime.current = time const { stiffness, damping } = getSequenceSpring( sequenceTransitionRef.current.transition, sequenceTransitionRef.current.transitionMs, springSpeedRef.current ) const mass = 0.85 const currentExpression = displayedPose.current.expression if (retargetStartedAt.current !== null && retargetFrom.current && retargetTo.current) { if (retargetStartedAt.current < 0) retargetStartedAt.current = time const linearProgress = Math.min((time - retargetStartedAt.current) / RETARGET_BLEND_MS, 1) const smoothProgress = linearProgress ** 3 * (linearProgress * (linearProgress * 6 - 15) + 10) const blendedTarget = { ...retargetTo.current } expressionFields.forEach(field => { blendedTarget[field] = retargetFrom.current![field] + (retargetTo.current![field] - retargetFrom.current![field]) * smoothProgress }) transitionTarget.current = blendedTarget if (linearProgress === 1) { transitionTarget.current = retargetTo.current retargetFrom.current = null retargetTo.current = null retargetStartedAt.current = null } } const target = transitionTarget.current const remainingTransitionDistance = expressionFields.reduce( (total, field) => total + Math.abs(target[field] - currentExpression[field]), 0 ) const geometricProgress = initialTransitionDistance <= 0 ? 1 : 1 - remainingTransitionDistance / initialTransitionDistance transitionProgress = Math.max(transitionProgress, bounded(geometricProgress, 0, 1)) let settled = true const animated = { ...currentExpression } animated.eyeMotion = target.eyeMotion animated.bodyMotion = target.bodyMotion expressionFields.forEach(field => { const displacement = target[field] - currentExpression[field] const acceleration = (stiffness * displacement - damping * transitionVelocity[field]) / mass const velocity = transitionVelocity[field] + acceleration * deltaTime const value = currentExpression[field] + velocity * deltaTime transitionVelocity[field] = velocity animated[field] = value const tolerance = field === 'perspective' ? 0.0001 : 0.005 if (Math.abs(displacement) > tolerance || Math.abs(velocity) > tolerance) settled = false }) if (settled) { const finalExpression = canonicalTarget.current stopTransition(true) setExpression(finalExpression) paintPose(poseFromExpression(finalExpression)) return } paintPose(poseFromExpression(animated), undefined, time, transitionProgress) if (modeRef.current === 'manual' && time - lastInspectorFrame.current >= INSPECTOR_FRAME_MS) { lastInspectorFrame.current = time setExpression(animated) } transitionFrame.current = requestAnimationFrame(tick) } transitionFrame.current = requestAnimationFrame(tick) } const blink = (durationMs?: number) => { blinkControls.current?.stop() blinkValue.jump(1) blinkAnimating.current = true const sequence = sequences.find(item => item.id === (activeState ?? selectedState)) const blinkDuration = durationMs ?? sequence?.blink.durationMs ?? 280 blinkControls.current = animate(blinkValue, [1, 0, 1], { duration: reduceMotion ? 0 : blinkDuration / 1000, times: [0, 0.42, 1], ease: ['easeIn', 'easeOut'], onComplete: () => { blinkAnimating.current = false }, }) } const updateDimension = (side: Side, dimension: 'width' | 'height', value: number) => { updateImmediate(updateEyeDimension(expression, side, dimension, value, linked[dimension])) } const updateSize = (side: Side, value: number) => { updateImmediate(scaleEye(expression, side, value, linked.size)) } const updateSpacing = (value: number) => { updateImmediate({ ...expression, spacing: value }) } const updateActiveAvatar = (update: (avatar: StudioAvatar) => StudioAvatar) => { const next = avatarsRef.current.map(avatar => avatar.id === activeAvatarIdRef.current ? update(avatar) : avatar ) avatarsRef.current = next setAvatars(next) if (!avatarEditSnapshot.current) { updateStudioLibrary({ activeAvatarId: activeAvatarIdRef.current, avatars: next }) } } const selectBodyNode = (id: 'primary' | string | null) => { setSelectedBodyNodeId(id) if (id) setSelectedEyeSide(null) } const updateSurface = (next: SurfaceConfig) => { surfaceRef.current = next setSurface(next) updateActiveAvatar(avatar => ({ ...avatar, body: { primary: next, nodes: bodyNodesRef.current }, })) paintPose(displayedPose.current) } const updateBodyNodes = (next: BodyNode[]) => { bodyNodesRef.current = next setBodyNodes(next) updateActiveAvatar(avatar => ({ ...avatar, body: { primary: surfaceRef.current, nodes: next }, })) paintPose(displayedPose.current) } const updateAvatarColors = (changes: Partial) => { const avatar = avatarsRef.current.find(item => item.id === activeAvatarIdRef.current) if (!avatar) return const colors = { ...avatar.colors, ...changes } updateActiveAvatar(current => ({ ...current, colors })) setDisplayColors(resolveColors(expression, colors)) } const updateAvatarEyes = (changes: Partial) => { const avatar = avatarsRef.current.find(item => item.id === activeAvatarIdRef.current) if (!avatar) return const eyes = { ...(avatar.eyes ?? defaultAvatarEyes), ...changes } updateActiveAvatar(current => ({ ...current, eyes })) paintPose(displayedPose.current) } const activateAvatar = (id: string, editBody = false, preserveMode = false) => { const avatar = avatarsRef.current.find(item => item.id === id) if (!avatar) return const resumeActiveSequence = !editBody && statePlaying if (editBody) { suspendStateForEditor() stopState(false) } else if (resumeActiveSequence) { pauseState(false) } if (editBody && !avatarEditSnapshot.current) { avatarEditSnapshot.current = { avatars: avatarsRef.current, activeAvatarId: activeAvatarIdRef.current, } } const currentStateExpression = displayedPose.current.expression const nextBehavior = resolveAvatarBehavior(avatar, baseBehavior) const nextExpressions = nextBehavior.expressions const nextSequences = nextBehavior.sequences stopTransition(true) activeAvatarIdRef.current = id surfaceRef.current = avatar.body.primary bodyNodesRef.current = avatar.body.nodes setActiveAvatarId(id) setSurface(avatar.body.primary) setBodyNodes(avatar.body.nodes) expressionsRef.current = nextExpressions sequencesRef.current = nextSequences expressionDragPreview.current = nextExpressions stateDragPreview.current = nextSequences setExpressions(nextExpressions) setSequences(nextSequences) setExportAnimationIds(nextSequences.map(animation => animation.id)) const nextActiveSequence = !editBody && activeState ? (nextSequences.find(sequence => sequence.id === activeState) ?? null) : null const nextSelectedState = nextActiveSequence?.id ?? (nextSequences.some(sequence => sequence.id === 'idle') ? 'idle' : (nextSequences[0]?.id ?? '')) activeSequenceRef.current = nextActiveSequence setActiveState(nextActiveSequence?.id ?? null) setSelectedState(nextSelectedState) setPlaybackVisual(current => ({ ...current, position: null })) selectBodyNode('primary') setActiveExpression(null) setEditing(null) setBodyEditing(editBody) if (!preserveMode || editBody) { modeRef.current = 'manual' setMode('manual') } const selectedExpression = nextActiveSequence ? currentStateExpression : { ...(nextExpressions[0] ?? defaultExpression) } const nextExpression = editBody ? resetBodyEditorView(selectedExpression) : selectedExpression setExpression(nextExpression) if (editBody) { transitionToExpression(nextExpression, null, { transitionMs: 450, transition: 'smooth', }) } else { setDisplayColors(resolveColors(nextExpression, avatar.colors)) canonicalTarget.current = nextExpression transitionTarget.current = nextExpression paintPose(poseFromExpression(nextExpression)) } if (nextActiveSequence && resumeActiveSequence) { pausedSequenceTransition.current = null launchSequence(nextActiveSequence, true, false) } if (!avatarEditSnapshot.current) { updateStudioLibrary({ activeAvatarId: id, avatars: avatarsRef.current }) } } const createNewAvatar = () => { avatarEditSnapshot.current = { avatars: avatarsRef.current, activeAvatarId: activeAvatarIdRef.current, } const avatar = createAvatar('Unknown') const next = [...avatarsRef.current, avatar] avatarsRef.current = next setAvatars(next) setFocusAvatarName(true) activateAvatar(avatar.id, true) } const duplicateAvatar = (source: StudioAvatar, editDuplicate = false) => { const snapshotAvatars = avatarEditSnapshot.current?.avatars ?? avatarsRef.current const sourceIndex = snapshotAvatars.findIndex(avatar => avatar.id === source.id) const baseAvatars = sourceIndex < 0 ? [...snapshotAvatars, source] : snapshotAvatars const duplicate: StudioAvatar = { ...structuredClone(source), id: `avatar-${crypto.randomUUID()}`, name: `${source.name} ${t('copie')}`, } const next = [...baseAvatars, duplicate] avatarEditSnapshot.current = null avatarsRef.current = next setAvatars(next) updateStudioLibrary({ activeAvatarId: duplicate.id, avatars: next }) activateAvatar(duplicate.id, editDuplicate) } const previewAvatarMove = (targetId: string) => { const draggedId = draggedAvatarId.current if (!draggedId || draggedId === targetId) return const current = avatarDragPreview.current const dragged = current.find(avatar => avatar.id === draggedId) const targetIndex = current.findIndex(avatar => avatar.id === targetId) if (!dragged || targetIndex < 0) return const next = current.filter(avatar => avatar.id !== draggedId) next.splice(targetIndex, 0, dragged) avatarDragPreview.current = next setAvatars(next) } const commitAvatarMove = (targetId: string) => { previewAvatarMove(targetId) const next = avatarDragPreview.current avatarsRef.current = next updateStudioLibrary({ activeAvatarId: activeAvatarIdRef.current, avatars: next }) avatarDragOrigin.current = null draggedAvatarId.current = null setDraggingAvatarId(null) } const cancelAvatarMove = () => { if (draggedAvatarId.current && avatarDragOrigin.current) { avatarDragPreview.current = avatarDragOrigin.current setAvatars(avatarDragOrigin.current) } avatarDragOrigin.current = null draggedAvatarId.current = null setDraggingAvatarId(null) } const cancelAvatarEditing = () => { const snapshot = avatarEditSnapshot.current if (!snapshot) { setBodyEditing(false) setMode('avatars') restoreStateAfterEditor() return } avatarEditSnapshot.current = null avatarsRef.current = snapshot.avatars setAvatars(snapshot.avatars) activateAvatar(snapshot.activeAvatarId, false, true) setMode('avatars') restoreStateAfterEditor() } const saveAvatarEditing = () => { avatarEditSnapshot.current = null updateStudioLibrary({ activeAvatarId: activeAvatarIdRef.current, avatars: avatarsRef.current }) setBodyEditing(false) restoreStateAfterEditor() } const renameActiveAvatar = (name: string) => { updateActiveAvatar(avatar => ({ ...avatar, name })) } const deleteActiveAvatar = () => { if (avatarsRef.current.length <= 1) return avatarEditSnapshot.current = null const remaining = avatarsRef.current.filter(avatar => avatar.id !== activeAvatarIdRef.current) avatarsRef.current = remaining setAvatars(remaining) updateStudioLibrary({ activeAvatarId: remaining[0].id, avatars: remaining }) setDeleteAvatarOpen(false) activateAvatar(remaining[0].id) restoreStateAfterEditor() } const addBodyNode = (type: (typeof bodyPrimitiveTypes)[number]) => { if (bodyNodesRef.current.length >= MAX_BODY_NODES) return const node = createBodyNode(type, bodyNodesRef.current.length) updateBodyNodes([...bodyNodesRef.current, node]) selectBodyNode(node.id) } const updateSelectedBodyNode = (update: (node: BodyNode) => BodyNode) => { if (selectedBodyNodeId === 'primary') return updateBodyNodes( bodyNodesRef.current.map(node => (node.id === selectedBodyNodeId ? update(node) : node)) ) } const commitBodyNode = (nextNode: BodyNode) => { updateBodyNodes(bodyNodesRef.current.map(node => (node.id === nextNode.id ? nextNode : node))) } const previewSelectedBodyNode = (nextNode: BodyNode) => { const next = bodyNodesRef.current.map(node => (node.id === nextNode.id ? nextNode : node)) bodyNodesRef.current = next setBodyNodes(next) paintPose(displayedPose.current) } const deleteSelectedBodyNode = () => { if (selectedBodyNodeId === 'primary') return updateBodyNodes(bodyNodesRef.current.filter(node => node.id !== selectedBodyNodeId)) selectBodyNode('primary') } const duplicateSelectedBodyNode = () => { if (selectedBodyNodeId === 'primary' || bodyNodesRef.current.length >= MAX_BODY_NODES) return const source = bodyNodesRef.current.find(node => node.id === selectedBodyNodeId) if (!source) return const duplicate = duplicateBodyNode(source) updateBodyNodes([...bodyNodesRef.current, duplicate]) selectBodyNode(duplicate.id) } const updateHighlight = (next: Highlight) => { if (next && statePlaying) pauseState() highlightRef.current = next setHighlight(next) if (next === 'head') paintPose(displayedPose.current) } const updateWireVisibility = (next: boolean) => { showWireRef.current = next setShowWire(next) if (next) paintPose(displayedPose.current) } const clearStateTimers = () => { if (stateTimer.current) clearTimeout(stateTimer.current) if (blinkTimer.current) clearTimeout(blinkTimer.current) stateTimer.current = null blinkTimer.current = null playbackTimeline.current = { ...playbackTimeline.current, stepDueAt: null, blinkDueAt: null, } } const pauseState = (persist = true) => { playbackTimeline.current = pausePlaybackTimeline(playbackTimeline.current, readSequenceClock()) if (transitionFrame.current !== null && activeSequenceTransition.current) { cancelAnimationFrame(transitionFrame.current) transitionFrame.current = null pausedSequenceTransition.current = activeSequenceTransition.current activeSequenceTransition.current = null } if (blinkAnimating.current) blinkControls.current?.pause() clearStateTimers() setStatePlaying(false) setPlaybackStatus('paused') if (persist && activeState) persistStatePlayback({ stateId: activeState, playing: false }) } const stopState = (persist = true) => { clearStateTimers() playbackTimeline.current = stopPlaybackTimeline(playbackTimeline.current) activeSequenceTransition.current = null pausedSequenceTransition.current = null stopTransition(true) blinkControls.current?.stop() blinkAnimating.current = false blinkValue.jump(1) paintPose(displayedPose.current, 1) setStatePlaying(false) setPlaybackStatus('stopped') setPlaybackVisual(current => ({ ...current, position: null })) if (persist) persistStatePlayback({ stateId: null, playing: false }) } const launchSequence = (sequence: AvatarSequence, resume = false, persist = true) => { clearStateTimers() if (!sequence.steps.length) { stopState(persist) return } const id = sequence.id playbackTimeline.current = beginPlayback(playbackTimeline.current, resume) setSelectedState(id) setActiveState(id) activeSequenceRef.current = sequence setStatePlaying(true) setPlaybackStatus('playing') if (persist) persistStatePlayback({ stateId: id, playing: true }) const scheduleAdvance = (delay: number) => { playbackTimeline.current = schedulePlaybackStep( playbackTimeline.current, readSequenceClock(), delay ) stateTimer.current = setTimeout(advance, delay) } const playCurrentStep = () => { playbackTimeline.current = { ...playbackTimeline.current, stepDueAt: null } const step = sequence.steps[playbackTimeline.current.position] const availableExpressions = expressionsRef.current const expressionIndex = findExpressionIndex(availableExpressions, step.expressionId) const preset = availableExpressions[expressionIndex] const durationMs = (reduceMotion ? 0 : step.transitionMs) + step.holdMs setPlaybackVisual(current => ({ position: playbackTimeline.current.position, run: current.run + 1, durationMs, })) if (preset) { transitionToExpression(preset, expressionIndex, step) } scheduleAdvance(durationMs) } const advance = () => { const advanced = advancePlaybackTimeline(playbackTimeline.current, sequence) playbackTimeline.current = advanced.timeline const { cursor } = advanced if (cursor.complete) { if (blinkTimer.current) clearTimeout(blinkTimer.current) blinkTimer.current = null playbackTimeline.current = { ...playbackTimeline.current, blinkDueAt: null } setStatePlaying(false) setPlaybackStatus('stopped') setPlaybackVisual(current => ({ ...current, position: null })) if (persist) persistStatePlayback({ stateId: null, playing: false }) return } playCurrentStep() } const scheduleBlink = (delay: number) => { playbackTimeline.current = schedulePlaybackBlink( playbackTimeline.current, readSequenceClock(), delay ) blinkTimer.current = setTimeout(blinkLoop, delay) } const blinkLoop = () => { playbackTimeline.current = { ...playbackTimeline.current, blinkDueAt: null } blink(sequence.blink.durationMs) const { minIntervalMs, maxIntervalMs } = sequence.blink scheduleBlink( sequence.blink.durationMs + minIntervalMs + Math.random() * (maxIntervalMs - minIntervalMs) ) } if (resume) { const pausedTransition = pausedSequenceTransition.current if (pausedTransition) { transitionToExpression(pausedTransition.target, pausedTransition.index, { ...pausedTransition.settings, transitionMs: pausedTransition.remainingMs, }) pausedSequenceTransition.current = null } if (blinkAnimating.current) blinkControls.current?.play() const currentStep = sequence.steps[playbackTimeline.current.position] scheduleAdvance( playbackTimeline.current.stepRemainingMs || (reduceMotion ? 0 : currentStep.transitionMs) + currentStep.holdMs ) } else { playCurrentStep() } if (sequence.blink.enabled) { scheduleBlink( resume ? playbackTimeline.current.blinkRemainingMs || sequence.blink.initialDelayMs : sequence.blink.initialDelayMs ) } } const toggleStatePlayback = () => { if (!activeState || !activeSequenceRef.current) return if (statePlaying) pauseState() else launchSequence(activeSequenceRef.current, playbackStatus === 'paused') } const suspendStateForEditor = () => { if (editorStateSnapshot.current || !activeState) return editorStateSnapshot.current = { stateId: activeState, playing: statePlaying, expression: { ...displayedPose.current.expression }, } if (statePlaying) pauseState(false) setActiveState(null) } const restoreStateAfterEditor = (availableSequences = sequences) => { const snapshot = editorStateSnapshot.current editorStateSnapshot.current = null if (!snapshot) return const sequence = availableSequences.find(item => item.id === snapshot.stateId) if (!sequence) { stopState(false) persistStatePlayback({ stateId: null, playing: false }) return } activeSequenceRef.current = sequence setSelectedState(sequence.id) setActiveState(sequence.id) if (snapshot.playing) { launchSequence(sequence, true, false) return } setStatePlaying(false) transitionToExpression(snapshot.expression) } useEffect(() => { if (initialStatePlaybackApplied.current) return initialStatePlaybackApplied.current = true const sequence = sequences.find(item => item.id === selectedState) if (sequence && initialStatePlayback.stateId !== null) { activeSequenceRef.current = sequence setActiveState(sequence.id) if (initialStatePlayback.playing) launchSequence(sequence, false, false) else setPlaybackStatus('paused') } return () => { initialStatePlaybackApplied.current = false } }, []) const saveEditing = () => { if (!editing) return const index = editing.index ?? expressions.length const savedDraft = editing.index === null ? { ...editing.draft, id: createExpressionId() } : editing.draft const next = editing.index === null ? [...expressions, savedDraft] : expressions.map((item, itemIndex) => itemIndex === editing.index ? { ...savedDraft } : item ) setExpressions(next) updateStudioExpressions(next) setEditing(null) transitionToExpression(savedDraft, index) if (!sequenceEditing) restoreStateAfterEditor() } const duplicateExpression = (_index: number | null, draft: Expression, editDuplicate = false) => { const duplicate = createUnkeyedExpressionCopy(draft, createExpressionId()) const next = [...expressions, duplicate] const duplicateIndex = next.length - 1 setExpressions(next) updateStudioExpressions(next) if (editDuplicate) openExpressionEditor(duplicateIndex, duplicate) else transitionToExpression(duplicate, duplicateIndex) } const previewExpressionMove = (targetId: string | null) => { const draggedId = draggedExpressionId.current if (!draggedId || draggedId === targetId) return const current = expressionDragPreview.current const dragged = current.find(item => item.id === draggedId) if (!dragged) return const activeId = activeExpression === null ? null : current[activeExpression]?.id const next = current.filter(item => item.id !== draggedId) const targetIndex = targetId ? next.findIndex(item => item.id === targetId) : next.length next.splice(targetIndex < 0 ? next.length : targetIndex, 0, dragged) expressionDragPreview.current = next setExpressions(next) if (activeId) setActiveExpression(next.findIndex(item => item.id === activeId)) } const commitExpressionMove = (targetId: string | null) => { previewExpressionMove(targetId) updateStudioExpressions(expressionDragPreview.current) expressionDragOrigin.current = null draggedExpressionId.current = null setDraggingExpressionId(null) } const cancelExpressionMove = () => { if (draggedExpressionId.current && expressionDragOrigin.current) { expressionDragPreview.current = expressionDragOrigin.current setExpressions(expressionDragOrigin.current) } expressionDragOrigin.current = null draggedExpressionId.current = null setDraggingExpressionId(null) } const previewExpressionDraft = (draft: Expression) => { setEditing(current => (current ? { ...current, draft } : current)) const avatar = avatarsRef.current.find(item => item.id === activeAvatarIdRef.current) if (avatar) setDisplayColors(resolveColors(draft, avatar.colors)) paintPose(poseFromExpression(draft)) } const previewCanvasExpression = (next: Expression, target: CanvasPreviewTarget) => { const now = performance.now() const updateInspector = shouldSyncCanvasPreviewToReact(bodyEditing, target) && now - lastInspectorFrame.current >= INSPECTOR_FRAME_MS if (updateInspector) { lastInspectorFrame.current = now if (editing) { setEditing(current => (current ? { ...current, draft: next } : current)) } else { setExpression(next) } } if (bodyEditing) { paintRenderedRotationGizmo(renderedRotationGizmo, next) paintRenderedScene( renderedScene, renderAvatar( poseFromExpression( resolveCanvasPreviewExpression(next, activeAvatarEyes, bodyEditing, target) ), surfaceRef.current, blinkValue.get(), { includeWire: showWireRef.current || highlightRef.current === 'head', bodyNodes: bodyNodesRef.current, } ) ) return } const avatar = avatarsRef.current.find(item => item.id === activeAvatarIdRef.current) if (avatar) setDisplayColors(resolveColors(next, avatar.colors)) paintPose(poseFromExpression(next)) } const openExpressionEditor = (index: number | null, draft: Expression) => { suspendStateForEditor() setBodyEditing(false) setMode('expressions') setEditing({ index, draft: index === null ? createUnkeyedExpressionCopy(draft, createExpressionId()) : { ...draft }, }) const avatar = avatarsRef.current.find(item => item.id === activeAvatarIdRef.current) if (avatar) setDisplayColors(resolveColors(draft, avatar.colors)) paintPose(poseFromExpression(draft)) } const cancelExpressionEditing = () => { setEditing(null) const avatar = avatarsRef.current.find(item => item.id === activeAvatarIdRef.current) if (avatar) setDisplayColors(resolveColors(expression, avatar.colors)) paintPose(poseFromExpression(expression)) if (!sequenceEditing) restoreStateAfterEditor() } const deleteEditing = () => { if (editing?.index === null || editing?.index === undefined) return const next = expressions.length <= 1 ? [{ ...defaultExpression }] : expressions.filter((_, index) => index !== editing.index) const fallback = next[Math.min(editing.index, next.length - 1)] ?? defaultExpression const deletedExpressionId = expressions[editing.index]?.id ?? editing.draft.id const fallbackExpressionId = fallback.id const nextSequences = remapSequencesAfterExpressionDelete( sequences, deletedExpressionId, fallbackExpressionId ) setExpressions(next) setSequences(nextSequences) if (sequenceEditing) { const draft = remapSequencesAfterExpressionDelete( [sequenceEditing.draft], deletedExpressionId, fallbackExpressionId )[0] setSequenceEditing({ ...sequenceEditing, draft }) } updateStudioExpressions(next) updateStudioSequences(nextSequences) setActiveExpression(null) setEditing(null) setDeleteExpressionOpen(false) setExpression(fallback) const avatar = avatarsRef.current.find(item => item.id === activeAvatarIdRef.current) if (avatar) setDisplayColors(resolveColors(fallback, avatar.colors)) paintPose(poseFromExpression(fallback)) if (!sequenceEditing) restoreStateAfterEditor() } const openSequenceEditor = (sequence?: AvatarSequence) => { suspendStateForEditor() setEditing(null) setBodyEditing(false) setMode('states') const draft = sequence ? { ...sequence, steps: sequence.steps.map(step => ({ ...step })), blink: { ...sequence.blink }, } : createSequence(expressions[activeExpression ?? 0]?.id ?? expressions[0]?.id) setSequenceEditing({ sourceId: sequence?.id ?? null, draft }) setSelectedSequenceStepId(draft.steps[0]?.id ?? null) const firstStep = draft.steps[0] const expressionIndex = firstStep ? findExpressionIndex(expressions, firstStep.expressionId) : -1 const preset = expressions[expressionIndex] if (preset) transitionToExpression(preset, expressionIndex, firstStep) } const cancelSequenceEditing = () => { if (activeState === sequenceEditing?.draft.id) stopState(false) setSequenceEditing(null) setSelectedSequenceStepId(null) restoreStateAfterEditor() } const saveSequenceEditing = () => { if (!sequenceEditing?.draft.steps.length || !sequenceEditing.draft.name.trim()) return const saved = { ...sequenceEditing.draft, name: sequenceEditing.draft.name.trim(), group: sequenceEditing.draft.group.trim() || 'Custom', } const next = sequenceEditing.sourceId ? sequences.map(sequence => (sequence.id === sequenceEditing.sourceId ? saved : sequence)) : [...sequences, saved] setSequences(next) updateStudioSequences(next) setSelectedState(saved.id) if (activeState === saved.id) stopState(false) setSequenceEditing(null) setSelectedSequenceStepId(null) restoreStateAfterEditor(next) } const duplicateSequenceEditing = () => { if (!sequenceEditing) return if (activeState === sequenceEditing.draft.id) stopState(false) const duplicate = duplicateSequence(sequenceEditing.draft) const next = [...sequences, duplicate] setSequences(next) updateStudioSequences(next) setSelectedState(duplicate.id) setSequenceEditing({ sourceId: duplicate.id, draft: duplicate }) setSelectedSequenceStepId(duplicate.steps[0]?.id ?? null) } const duplicateState = (sequence: AvatarSequence) => { if (activeState === sequence.id) stopState(false) const duplicate = duplicateSequence(sequence) const next = [...sequences, duplicate] setSequences(next) updateStudioSequences(next) setSelectedState(duplicate.id) } const previewStateMove = (targetId: string | null, targetGroup: string) => { const draggedId = draggedStateId.current if (!draggedId || draggedId === targetId) return const current = stateDragPreview.current const dragged = current.find(sequence => sequence.id === draggedId) if (!dragged) return const next = current.filter(sequence => sequence.id !== draggedId) const moved = { ...dragged, group: targetGroup } if (targetId) { const targetIndex = next.findIndex(sequence => sequence.id === targetId) next.splice(targetIndex < 0 ? next.length : targetIndex, 0, moved) } else { let lastGroupIndex = -1 for (let index = next.length - 1; index >= 0; index -= 1) { if (next[index].group !== targetGroup) continue lastGroupIndex = index break } next.splice(lastGroupIndex + 1, 0, moved) } stateDragPreview.current = next setSequences(next) } const commitStateMove = (targetId: string | null, targetGroup: string) => { previewStateMove(targetId, targetGroup) updateStudioSequences(stateDragPreview.current) stateDragOrigin.current = null draggedStateId.current = null setDraggingStateId(null) } const cancelStateMove = () => { if (draggedStateId.current && stateDragOrigin.current) { stateDragPreview.current = stateDragOrigin.current setSequences(stateDragOrigin.current) } stateDragOrigin.current = null draggedStateId.current = null setDraggingStateId(null) } const deleteSequenceEditing = () => { if (!sequenceEditing?.sourceId) return const next = sequences.filter(sequence => sequence.id !== sequenceEditing.sourceId) const fallback = next[0] setSequences(next) updateStudioSequences(next) if (activeState === sequenceEditing.sourceId) stopState(false) setSelectedState(fallback?.id ?? '') setSequenceEditing(null) setSelectedSequenceStepId(null) setDeleteSequenceOpen(false) restoreStateAfterEditor(next) } const selectedBodyNode = selectedBodyNodeId === 'primary' ? null : (bodyNodes.find(node => node.id === selectedBodyNodeId) ?? null) const updateNodeVector = (property: 'position' | 'rotation', index: 0 | 1 | 2, value: number) => { updateSelectedBodyNode(node => { const vector = [...node[property]] as [number, number, number] vector[index] = value return { ...node, [property]: vector } }) } const activeAvatar = avatars.find(avatar => avatar.id === activeAvatarId) ?? avatars[0] const runtimeCopySource = [activeAvatar, exportAnimationIds, expressions, sequences] as const const runtimeCopyStatus = runtimeCopyFeedback.source?.length === runtimeCopySource.length && runtimeCopyFeedback.source.every((value, index) => value === runtimeCopySource[index]) ? runtimeCopyFeedback.status : 'idle' const activeAvatarEyes = activeAvatar.eyes ?? defaultAvatarEyes const activeSequence = sequences.find(sequence => sequence.id === activeState) ?? null const activeSequenceLabel = activeSequence ? activeSequence.builtIn ? t(activeSequence.name) : activeSequence.name : null const expressionById = new Map(expressions.map(item => [item.id, item])) const semanticKeyIssueMessage = (issue: SemanticKeyIssueCode | 'duplicate_semantic_key') => t( issue === 'missing_semantic_key' ? 'Ajoute une clé pour inclure cet élément dans l’export runtime.' : issue === 'invalid_semantic_key' ? 'Utilise des lettres minuscules, des chiffres et des tirets, par exemple happy-smile.' : issue === 'reserved_semantic_key' ? 'neutral est réservé à l’apparence neutre de l’avatar.' : 'Cette clé est déjà utilisée dans cette bibliothèque.' ) const expressionSemanticKeyError = (draft: Expression) => { const issue = getSemanticKeyIssue(draft.semanticKey, 'expression') if (issue) return semanticKeyIssueMessage(issue) if ( expressions.some( expression => expression.id !== draft.id && expression.semanticKey === draft.semanticKey ) ) { return semanticKeyIssueMessage('duplicate_semantic_key') } return null } const animationSemanticKeyError = (draft: AvatarSequence) => { const issue = getSemanticKeyIssue(draft.semanticKey, 'animation') if (issue) return semanticKeyIssueMessage(issue) if ( sequences.some( sequence => sequence.id !== draft.id && sequence.semanticKey === draft.semanticKey ) ) { return semanticKeyIssueMessage('duplicate_semantic_key') } return null } const exportAnimationIdSet = new Set(exportAnimationIds) const selectedExportAnimations = sequences.filter(animation => exportAnimationIdSet.has(animation.id) ) const runtimeDefinitionResult = createAvatarDefinition({ avatar: activeAvatar, behavior: { expressions, sequences: selectedExportAnimations }, }) const runtimeExportErrors = runtimeDefinitionResult.ok ? [] : (() => { const messages = new Set() const hasExpressionErrors = runtimeDefinitionResult.errors.some(error => error.path.startsWith('/studio/expressions/') ) runtimeDefinitionResult.errors.forEach(error => { if (error.code === 'unresolved_expression_reference' && hasExpressionErrors) return const expressionMatch = error.path.match(/^\/studio\/expressions\/(\d+)/) if (expressionMatch) { const index = Number(expressionMatch[1]) const item = expressions[index] messages.add( `${t('Expression')} ${item?.semanticKey || String(index).padStart(2, '0')}: ${semanticKeyIssueMessage(error.code as SemanticKeyIssueCode | 'duplicate_semantic_key')}` ) return } const animationMatch = error.path.match(/^\/studio\/animations\/(\d+)/) if (animationMatch) { const index = Number(animationMatch[1]) const item = selectedExportAnimations[index] messages.add( error.code === 'unresolved_expression_reference' ? `${t('Animation')} ${item?.name ?? index}: ${t('Une étape référence une expression qui ne peut pas être exportée.')}` : `${t('Animation')} ${item?.semanticKey || item?.name || index}: ${semanticKeyIssueMessage(error.code as SemanticKeyIssueCode | 'duplicate_semantic_key')}` ) return } messages.add(`${t('Valeur incompatible avec le format runtime')} (${error.path || '/'})`) }) return [...messages] })() const toggleExportAnimation = (animationId: string) => { setExportAnimationIds(current => current.includes(animationId) ? current.filter(id => id !== animationId) : [...current, animationId] ) } const downloadAvatarExport = () => { if (!runtimeDefinitionResult.ok) return downloadBlob( exportFormat === 'javascript' ? generateJavaScriptEsmPackage(runtimeDefinitionResult.value, activeAvatar.name) : generateReactVitePackage(runtimeDefinitionResult.value, activeAvatar.name), avatarDemoFileName(activeAvatar.name, exportFormat) ) } const downloadAvatarRuntimeDefinition = () => { if (!runtimeDefinitionResult.ok) return downloadBlob( new Blob([JSON.stringify(runtimeDefinitionResult.value, null, 2)], { type: 'application/json;charset=utf-8', }), avatarDefinitionFileName(activeAvatar.name) ) } const copyAvatarRuntimeDefinition = async () => { if (!runtimeDefinitionResult.ok) return if (!navigator.clipboard) { setRuntimeCopyFeedback({ status: 'error', source: runtimeCopySource }) return } try { await navigator.clipboard.writeText(JSON.stringify(runtimeDefinitionResult.value, null, 2)) setRuntimeCopyFeedback({ status: 'success', source: runtimeCopySource }) } catch { setRuntimeCopyFeedback({ status: 'error', source: runtimeCopySource }) } } const currentStudioDocument = (): StudioDocument => ({ version: 2, library: { activeAvatarId, avatars }, expressions: baseBehavior.expressions, sequences: baseBehavior.sequences, playback: { stateId: playbackStatus === 'stopped' ? null : (activeState ?? (selectedState || null)), playing: statePlaying, }, }) const downloadStudioProject = () => { const blob = new Blob([serializeStudioDocument(currentStudioDocument())], { type: 'application/json', }) downloadBlob(blob, 'avatar-studio-project.json') } const currentSnapshotSvg = () => serializeAvatarSnapshot( activeAvatar.name, renderedScene, { body: renderedColors.body.get(), eyes: renderedColors.eyes.get(), }, { background: snapshotBackground, colorFrom: snapshotColorFrom, colorTo: snapshotColorTo, size: Number(snapshotSize), } ) const createPixelSnapshotCanvas = () => { const renderStyle = activeAvatar.renderStyle if (renderStyle.type !== 'pixel') return null const size = Number(snapshotSize) const canvas = document.createElement('canvas') canvas.width = size canvas.height = size const context = canvas.getContext('2d') if (!context) return null if (snapshotBackground === 'solid') { context.fillStyle = snapshotColorFrom context.fillRect(0, 0, size, size) } else if (snapshotBackground === 'linear') { const gradient = context.createLinearGradient(0, 0, size, size) gradient.addColorStop(0, snapshotColorFrom) gradient.addColorStop(1, snapshotColorTo) context.fillStyle = gradient context.fillRect(0, 0, size, size) } else if (snapshotBackground === 'radial') { const gradient = context.createRadialGradient( size * 0.5, size * 0.42, 0, size * 0.5, size * 0.42, size * 0.7 ) gradient.addColorStop(0, snapshotColorFrom) gradient.addColorStop(1, snapshotColorTo) context.fillStyle = gradient context.fillRect(0, 0, size, size) } const avatarCanvas = document.createElement('canvas') avatarCanvas.width = renderStyle.resolution avatarCanvas.height = renderStyle.resolution const avatarContext = avatarCanvas.getContext('2d', { willReadFrequently: true }) if (!avatarContext) return null paintPixelAvatar( avatarContext, { headPath: renderedScene.headPath.get(), backPaths: renderedScene.backPaths.flatMap(item => { const value = item.get() return value ? [value] : [] }), frontPaths: renderedScene.frontPaths.flatMap(item => { const value = item.get() return value ? [value] : [] }), leftPath: renderedScene.leftPath.get(), rightPath: renderedScene.rightPath.get(), leftOpacity: renderedScene.leftOpacity.get(), rightOpacity: renderedScene.rightOpacity.get(), offsetX: renderedScene.offsetX.get(), offsetY: renderedScene.offsetY.get(), bodyColor: renderedColors.body.get(), eyeColor: renderedColors.eyes.get(), }, renderStyle ) context.imageSmoothingEnabled = false context.drawImage(avatarCanvas, 0, 0, size, size) return canvas } const downloadSnapshotSvg = () => { const pixelCanvas = createPixelSnapshotCanvas() const source = pixelCanvas ? serializePixelSnapshot( activeAvatar.name, pixelCanvas.toDataURL('image/png'), Number(snapshotSize) ) : currentSnapshotSvg() downloadBlob( new Blob([source], { type: 'image/svg+xml;charset=utf-8' }), snapshotFileName(activeAvatar.name) ) } const downloadSnapshotPng = () => { const pixelCanvas = createPixelSnapshotCanvas() if (pixelCanvas) { pixelCanvas.toBlob(blob => { if (blob) downloadBlob(blob, snapshotFileName(activeAvatar.name, 'png')) }, 'image/png') return } const size = Number(snapshotSize) const source = new Blob([currentSnapshotSvg()], { type: 'image/svg+xml;charset=utf-8' }) const sourceUrl = URL.createObjectURL(source) const image = new Image() image.onload = () => { const canvas = document.createElement('canvas') canvas.width = size canvas.height = size canvas.getContext('2d')?.drawImage(image, 0, 0, size, size) URL.revokeObjectURL(sourceUrl) canvas.toBlob(blob => { if (blob) downloadBlob(blob, snapshotFileName(activeAvatar.name, 'png')) }, 'image/png') } image.onerror = () => URL.revokeObjectURL(sourceUrl) image.src = sourceUrl } const takePicture = () => { setPhotoFlash(current => current + 1) requestAnimationFrame(() => { if (snapshotFormat === 'png') downloadSnapshotPng() else downloadSnapshotSvg() }) } const prepareStudioProjectImport = (file: File | undefined) => { if (!file) return setProjectImportError(null) if (file.size > 10_000_000) { setProjectImportError( t('Ce fichier ne contient pas un projet Avatar Studio valide et compatible.') ) return } file .text() .then(source => { const imported = parseImportedStudioDocument(source, currentStudioDocument()) setPendingProjectImport({ document: imported, fileName: file.name }) }) .catch(() => { setProjectImportError( t('Ce fichier ne contient pas un projet Avatar Studio valide et compatible.') ) }) } const confirmStudioProjectImport = () => { if (!pendingProjectImport) return if (!persistStudioDocument(pendingProjectImport.document)) { setProjectImportError( t( 'Le projet n’a pas pu être enregistré dans ce navigateur. Libère de l’espace puis réessaie.' ) ) setPendingProjectImport(null) return } window.location.reload() } const canvasExpression = editing?.draft ?? expression const editorPageOpen = bodyEditing || editing !== null || sequenceEditing !== null const expressionPendingDeletionId = editing?.index !== null && editing?.index !== undefined ? (expressions[editing.index]?.id ?? editing.draft.id) : null const animationsAffectedByExpressionDeletion = expressionPendingDeletionId ? sequences.filter(sequence => sequence.steps.some(step => step.expressionId === expressionPendingDeletionId) ) : [] useEffect(() => { if (!editorPageOpen || focusAvatarName) return const frame = requestAnimationFrame(() => workspaceBackButtonRef.current?.focus()) return () => cancelAnimationFrame(frame) }, [editorPageOpen, focusAvatarName]) const updateAvatarEyeDimension = (side: Side, dimension: 'width' | 'height', value: number) => { const next = updateEyeDimension( { ...defaultExpression, ...activeAvatarEyes }, side, dimension, value, linked[dimension] ) updateAvatarEyes({ [`${dimension}Left`]: next[`${dimension}Left`], [`${dimension}Right`]: next[`${dimension}Right`], }) } const updateAvatarEyeSize = (side: Side, value: number) => { const next = scaleEye({ ...defaultExpression, ...activeAvatarEyes }, side, value, linked.size) updateAvatarEyes({ widthLeft: next.widthLeft, widthRight: next.widthRight, heightLeft: next.heightLeft, heightRight: next.heightRight, }) } const updateAvatarEyePosition = (side: Side, axis: 'X' | 'Y', value: number) => { const next = updateEyePosition( { ...defaultExpression, ...activeAvatarEyes }, side, axis, value, linked.position ) updateAvatarEyes({ [`position${axis}Left`]: next[`position${axis}Left`], [`position${axis}Right`]: next[`position${axis}Right`], }) } const persistEditedEyeExpression = (next: Expression) => { updateAvatarEyes({ widthLeft: next.widthLeft, widthRight: next.widthRight, heightLeft: next.heightLeft, heightRight: next.heightRight, spacing: next.spacing, positionXLeft: next.positionXLeft, positionXRight: next.positionXRight, positionYLeft: next.positionYLeft, positionYRight: next.positionYRight, leftAngle: next.leftAngle, rightAngle: next.rightAngle, }) } return { activateAvatar, activeAvatar, activeAvatarEyes, activeAvatarId, activeExpression, activeSequence, activeSequenceLabel, activeState, addBodyNode, animationsAffectedByExpressionDeletion, avatarDragOrigin, avatarDragPreview, avatars, avatarsRef, blink, bodyEditing, bodyNodes, cancelAvatarEditing, cancelAvatarMove, cancelExpressionEditing, cancelExpressionMove, cancelSequenceEditing, cancelStateMove, canvasExpression, commitAvatarMove, commitBodyNode, commitExpressionMove, commitStateMove, confirmStudioProjectImport, copyAvatarRuntimeDefinition, createNewAvatar, deleteActiveAvatar, deleteAvatarOpen, deleteEditing, deleteExpressionOpen, deleteSelectedBodyNode, deleteSequenceEditing, deleteSequenceOpen, 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, freezeLivePreviewForManipulation, highlight, language, launchSequence, linked, mode, openExpressionEditor, openSequenceEditor, pauseState, pendingProjectImport, persistEditedEyeExpression, photoFlash, playbackStatus, playbackVisual, prepareStudioProjectImport, previewAvatarMove, previewCanvasExpression, previewExpressionDraft, previewExpressionMove, previewSelectedBodyNode, previewStateMove, projectImportError, projectImportRef, reduceMotion, renameActiveAvatar, renderedColors, renderedRotationGizmo, renderedScene, runtimeDefinitionResult, runtimeCopyStatus, runtimeExportErrors, saveAvatarEditing, saveEditing, saveSequenceEditing, selectBodyNode, selectedBodyNode, selectedBodyNodeId, selectedExportAnimations, selectedEyeSide, selectedSequenceStepId, selectedState, sequenceEditing, animationSemanticKeyError, sequences, setDeleteAvatarOpen, setDeleteExpressionOpen, setDeleteSequenceOpen, setDraggingAvatarId, setDraggingExpressionId, setDraggingStateId, setEditing, setExportAnimationIds, setExportFormat, setFocusAvatarName, setLanguage, setLinked, setMode, setPendingProjectImport, setSelectedEyeSide, setSelectedSequenceStepId, setSequenceEditing, setSnapshotBackground, setSnapshotColorFrom, setSnapshotColorTo, setSnapshotFormat, setSnapshotSize, setSpringSpeed, setStatePlayerExpanded, showWire, snapshotBackground, snapshotColorFrom, snapshotColorTo, snapshotFormat, snapshotSize, springSpeed, springSpeedRef, stateDragOrigin, stateDragPreview, statePlayerExpanded, statePlaying, stopState, surface, t, takePicture, toggleExportAnimation, toggleStatePlayback, transitionToExpression, updateAvatarColors, updateAvatarEyeDimension, updateAvatarEyePosition, updateAvatarEyeSize, updateAvatarEyes, updateDimension, updateHighlight, updateImmediate, updateNodeVector, updateSelectedBodyNode, updateSize, updateSpacing, updateSurface, updateWireVisibility, workspaceBackButtonRef, } } export type StudioController = ReturnType