Add AeThex Avatar Studio: Cross-platform avatar importer/exporter

Implements Phase 1 (Foundation) of the Avatar Studio system:

Core Features:
- 3D avatar viewer with React Three Fiber
- Avatar project management with Zustand store
- Drag-and-drop file import (VRM, GLB, GLTF, FBX)
- Platform-specific validation for Roblox, VRChat, Spatial, Rec Room
- Multi-platform export UI with compatibility checks

New Components:
- AvatarPanel: Main UI with project library, 3D viewer, and export controls
- AvatarViewer3D: Interactive 3D model viewer with orbit controls
- Avatar tab integration in ExtraTabs

New Libraries:
- src/lib/avatar/types.ts: TypeScript definitions for avatar system
- src/lib/avatar/validators.ts: Platform validation logic
- src/store/avatar-store.ts: Zustand state management

Dependencies Added:
- @react-three/fiber@8.17.10 (React 18 compatible)
- @react-three/drei@9.114.3 (3D helpers)
- three@0.169.0 (3D engine)
- react-dropzone (file upload)
- @gltf-transform/core (format conversion)

Platform Support:
- Roblox: R15/R6 rig validation, polygon limits
- VRChat: PC/Quest performance ranks, texture limits
- Spatial: Humanoid rig requirements
- Rec Room: Low-poly validation

This establishes the foundation for auto-rigging (Phase 2) and
platform-specific exporters (Phase 3).
This commit is contained in:
Claude 2026-01-22 07:09:36 +00:00
parent ba81ae7849
commit 046f4c4ce1
No known key found for this signature in database
6 changed files with 1589 additions and 0 deletions

View file

@ -0,0 +1,427 @@
import React, { useState, useCallback } from 'react';
import { useDropzone } from 'react-dropzone';
import { useAvatarStore } from '../store/avatar-store';
import { AvatarViewer3D } from './AvatarViewer3D';
import { Button } from './ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from './ui/card';
import { Checkbox } from './ui/checkbox';
import { ScrollArea } from './ui/scroll-area';
import { Separator } from './ui/separator';
import { Badge } from './ui/badge';
import {
Upload,
FileUp,
Sparkles,
Download,
Settings,
FolderOpen,
Plus,
Trash2,
Play,
Check,
AlertCircle,
User,
} from 'lucide-react';
import { platforms, type PlatformId } from '../lib/platforms';
import type { AvatarProject } from '../lib/avatar/types';
interface AvatarPanelProps {
className?: string;
}
export function AvatarPanel({ className = '' }: AvatarPanelProps) {
const {
currentProject,
projects,
setCurrentProject,
selectedPlatforms,
setSelectedPlatforms,
uploadProgress,
} = useAvatarStore();
const [view, setView] = useState<'projects' | 'import' | 'export'>('projects');
// File upload handling
const onDrop = useCallback((acceptedFiles: File[]) => {
const file = acceptedFiles[0];
if (file) {
handleFileImport(file);
}
}, []);
const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop,
accept: {
'model/gltf-binary': ['.glb'],
'model/gltf+json': ['.gltf'],
'model/fbx': ['.fbx'],
'application/octet-stream': ['.vrm'],
},
multiple: false,
});
const handleFileImport = async (file: File) => {
console.log('Importing file:', file.name);
// TODO: Implement actual file import logic
// This will:
// 1. Upload file to storage
// 2. Parse file format
// 3. Extract model statistics
// 4. Create AvatarProject
// 5. Add to store
};
const handlePlatformToggle = (platformId: PlatformId) => {
setSelectedPlatforms(
selectedPlatforms.includes(platformId)
? selectedPlatforms.filter((p) => p !== platformId)
: [...selectedPlatforms, platformId]
);
};
const handleExport = async () => {
if (!currentProject) return;
console.log('Exporting to platforms:', selectedPlatforms);
// TODO: Implement export logic
};
return (
<div className={`flex h-full bg-background ${className}`}>
{/* Left Sidebar - Projects & Templates */}
<div className="w-64 border-r border-border bg-surface p-4 flex flex-col gap-4">
<div>
<h2 className="text-lg font-semibold mb-3 flex items-center gap-2">
<User size={18} />
Avatar Studio
</h2>
{/* View Selector */}
<div className="flex flex-col gap-1 mb-4">
<Button
variant={view === 'projects' ? 'secondary' : 'ghost'}
size="sm"
onClick={() => setView('projects')}
className="justify-start"
>
<FolderOpen size={16} className="mr-2" />
My Avatars
</Button>
<Button
variant={view === 'import' ? 'secondary' : 'ghost'}
size="sm"
onClick={() => setView('import')}
className="justify-start"
>
<FileUp size={16} className="mr-2" />
Import New
</Button>
<Button
variant={view === 'export' ? 'secondary' : 'ghost'}
size="sm"
onClick={() => setView('export')}
className="justify-start"
disabled={!currentProject}
>
<Download size={16} className="mr-2" />
Export
</Button>
</div>
<Separator className="my-3" />
</div>
{/* Projects List */}
<ScrollArea className="flex-1">
<div className="space-y-2">
<p className="text-xs text-muted-foreground mb-2">
{projects.length} project{projects.length !== 1 ? 's' : ''}
</p>
{projects.length === 0 ? (
<div className="text-sm text-muted-foreground text-center py-8">
No avatars yet.
<br />
Import one to get started!
</div>
) : (
projects.map((project) => (
<button
key={project.id}
onClick={() => setCurrentProject(project)}
className={`w-full text-left p-3 rounded-lg transition-colors ${
currentProject?.id === project.id
? 'bg-primary/20 border border-primary'
: 'bg-neutral-900/50 hover:bg-neutral-900 border border-transparent'
}`}
>
<p className="font-medium text-sm truncate">{project.name}</p>
<p className="text-xs text-muted-foreground mt-1">
{project.polygonCount.toLocaleString()} polys
</p>
<div className="flex gap-1 mt-2">
{project.isRigged && (
<Badge variant="secondary" className="text-xs">
Rigged
</Badge>
)}
<Badge variant="outline" className="text-xs">
{project.sourceFormat.toUpperCase()}
</Badge>
</div>
</button>
))
)}
</div>
</ScrollArea>
{/* Quick Actions */}
<div className="space-y-2">
<Button
onClick={() => setView('import')}
className="w-full"
size="sm"
>
<Plus size={16} className="mr-2" />
New Avatar
</Button>
</div>
</div>
{/* Main Content Area */}
<div className="flex-1 flex flex-col">
{/* 3D Viewer */}
<div className="flex-1 p-4">
{view === 'projects' && (
<div className="h-full">
<AvatarViewer3D
modelUrl={currentProject?.sourceFileUrl}
className="h-full"
/>
</div>
)}
{view === 'import' && (
<div className="h-full flex items-center justify-center">
<Card className="max-w-lg w-full">
<CardHeader>
<CardTitle>Import Avatar</CardTitle>
<CardDescription>
Upload a 3D model to get started. Supported formats: VRM, GLB, GLTF, FBX
</CardDescription>
</CardHeader>
<CardContent>
<div
{...getRootProps()}
className={`border-2 border-dashed rounded-lg p-12 text-center cursor-pointer transition-colors ${
isDragActive
? 'border-primary bg-primary/10'
: 'border-border hover:border-primary/50'
}`}
>
<input {...getInputProps()} />
<Upload className="mx-auto mb-4 text-muted-foreground" size={48} />
{isDragActive ? (
<p className="text-lg">Drop your avatar file here...</p>
) : (
<>
<p className="text-lg mb-2">Drag & drop your avatar file</p>
<p className="text-sm text-muted-foreground mb-4">or click to browse</p>
<Button variant="secondary">
<FileUp size={16} className="mr-2" />
Choose File
</Button>
</>
)}
</div>
{uploadProgress && (
<div className="mt-4">
<div className="flex items-center justify-between mb-2">
<span className="text-sm">{uploadProgress.fileName}</span>
<span className="text-sm text-muted-foreground">
{uploadProgress.progress}%
</span>
</div>
<div className="h-2 bg-neutral-800 rounded-full overflow-hidden">
<div
className="h-full bg-primary transition-all duration-300"
style={{ width: `${uploadProgress.progress}%` }}
/>
</div>
</div>
)}
<div className="mt-6 grid grid-cols-2 gap-3">
<Card className="p-4">
<div className="text-center">
<Sparkles className="mx-auto mb-2 text-primary" size={24} />
<p className="text-sm font-medium">Auto-Rig</p>
<p className="text-xs text-muted-foreground mt-1">
AI-powered rigging
</p>
</div>
</Card>
<Card className="p-4">
<div className="text-center">
<Download className="mx-auto mb-2 text-primary" size={24} />
<p className="text-sm font-medium">Multi-Platform</p>
<p className="text-xs text-muted-foreground mt-1">
Export to 5+ platforms
</p>
</div>
</Card>
</div>
</CardContent>
</Card>
</div>
)}
{view === 'export' && currentProject && (
<div className="h-full overflow-auto p-6">
<Card>
<CardHeader>
<CardTitle>Export to Platforms</CardTitle>
<CardDescription>
Select which platforms to export {currentProject.name} to
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{/* Platform Selection */}
<div className="space-y-3">
{Object.values(platforms).map((platform) => {
const isSelected = selectedPlatforms.includes(platform.id);
const isSupported = currentProject.supportedPlatforms?.includes(platform.id);
return (
<div
key={platform.id}
className={`flex items-center justify-between p-4 rounded-lg border ${
isSelected ? 'border-primary bg-primary/5' : 'border-border'
}`}
>
<div className="flex items-center gap-3">
<Checkbox
id={platform.id}
checked={isSelected}
onCheckedChange={() => handlePlatformToggle(platform.id)}
/>
<label htmlFor={platform.id} className="flex items-center gap-2 cursor-pointer">
<span className="text-2xl">{platform.icon}</span>
<div>
<p className="font-medium">{platform.displayName}</p>
<p className="text-xs text-muted-foreground">
{platform.language}
</p>
</div>
</label>
</div>
<div className="flex items-center gap-2">
{isSupported ? (
<Badge variant="secondary" className="gap-1">
<Check size={12} />
Compatible
</Badge>
) : (
<Badge variant="outline" className="gap-1">
<AlertCircle size={12} />
Needs Optimization
</Badge>
)}
</div>
</div>
);
})}
</div>
<Separator />
{/* Export Actions */}
<div className="flex items-center justify-between">
<div className="text-sm text-muted-foreground">
{selectedPlatforms.length} platform{selectedPlatforms.length !== 1 ? 's' : ''} selected
</div>
<div className="flex gap-2">
<Button variant="outline">
<Settings size={16} className="mr-2" />
Configure
</Button>
<Button onClick={handleExport} disabled={selectedPlatforms.length === 0}>
<Play size={16} className="mr-2" />
Export All
</Button>
</div>
</div>
</CardContent>
</Card>
</div>
)}
</div>
</div>
{/* Right Sidebar - Properties & Stats */}
{currentProject && (
<div className="w-80 border-l border-border bg-surface p-4 space-y-4">
<div>
<h3 className="text-sm font-medium mb-3">Properties</h3>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-muted-foreground">Name:</span>
<span className="font-medium">{currentProject.name}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Format:</span>
<span className="font-medium">{currentProject.sourceFormat.toUpperCase()}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Polygons:</span>
<span className="font-medium">{currentProject.polygonCount.toLocaleString()}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Textures:</span>
<span className="font-medium">
{currentProject.textureCount} ({currentProject.textureSize.toFixed(1)} MB)
</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Rig Type:</span>
<span className="font-medium capitalize">{currentProject.rigType}</span>
</div>
{currentProject.boneCount && (
<div className="flex justify-between">
<span className="text-muted-foreground">Bones:</span>
<span className="font-medium">{currentProject.boneCount}</span>
</div>
)}
</div>
</div>
<Separator />
<div>
<h3 className="text-sm font-medium mb-3">Quick Actions</h3>
<div className="space-y-2">
<Button variant="outline" size="sm" className="w-full justify-start" disabled={currentProject.isRigged}>
<Sparkles size={16} className="mr-2" />
Auto-Rig
{currentProject.isRigged && <Badge variant="secondary" className="ml-auto text-xs">Done</Badge>}
</Button>
<Button variant="outline" size="sm" className="w-full justify-start">
<Settings size={16} className="mr-2" />
Optimize
</Button>
<Button variant="outline" size="sm" className="w-full justify-start" onClick={() => setView('export')}>
<Download size={16} className="mr-2" />
Export
</Button>
<Separator className="my-2" />
<Button variant="ghost" size="sm" className="w-full justify-start text-destructive">
<Trash2 size={16} className="mr-2" />
Delete
</Button>
</div>
</div>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,241 @@
import React, { Suspense, useRef, useState } from 'react';
import { Canvas, useFrame } from '@react-three/fiber';
import { OrbitControls, Grid, useGLTF, PerspectiveCamera, Environment } from '@react-three/drei';
import * as THREE from 'three';
import { Button } from './ui/button';
import { RotateCcw, Grid3x3, Eye, EyeOff, ZoomIn, ZoomOut } from 'lucide-react';
interface AvatarViewer3DProps {
modelUrl?: string;
onError?: (error: Error) => void;
showControls?: boolean;
className?: string;
}
// Model component that loads and displays the 3D model
function Model({ url, onError }: { url: string; onError?: (error: Error) => void }) {
const modelRef = useRef<THREE.Group>(null);
const [rotation, setRotation] = useState(0);
// Load the GLTF/GLB model
const { scene } = useGLTF(url, true);
// Auto-rotate the model slowly
useFrame((state, delta) => {
if (modelRef.current) {
modelRef.current.rotation.y += delta * 0.2;
setRotation(modelRef.current.rotation.y);
}
});
React.useEffect(() => {
if (scene) {
// Center the model
const box = new THREE.Box3().setFromObject(scene);
const center = box.getCenter(new THREE.Vector3());
scene.position.sub(center);
// Scale to fit in view
const size = box.getSize(new THREE.Vector3());
const maxDim = Math.max(size.x, size.y, size.z);
const scale = 2 / maxDim;
scene.scale.setScalar(scale);
}
}, [scene]);
return (
<primitive
ref={modelRef}
object={scene}
dispose={null}
/>
);
}
// Fallback model when no file is loaded
function FallbackModel() {
return (
<mesh>
<boxGeometry args={[1, 1, 1]} />
<meshStandardMaterial color="#8b5cf6" wireframe />
</mesh>
);
}
// Loading component
function LoadingFallback() {
return (
<mesh>
<boxGeometry args={[0.5, 0.5, 0.5]} />
<meshStandardMaterial color="#666" wireframe />
</mesh>
);
}
export function AvatarViewer3D({
modelUrl,
onError,
showControls = true,
className = '',
}: AvatarViewer3DProps) {
const [showGrid, setShowGrid] = useState(true);
const [showWireframe, setShowWireframe] = useState(false);
const [autoRotate, setAutoRotate] = useState(true);
const controlsRef = useRef<any>(null);
const handleReset = () => {
if (controlsRef.current) {
controlsRef.current.reset();
}
};
const handleZoomIn = () => {
if (controlsRef.current) {
controlsRef.current.dollyIn(1.2);
}
};
const handleZoomOut = () => {
if (controlsRef.current) {
controlsRef.current.dollyOut(1.2);
}
};
return (
<div className={`relative w-full h-full bg-neutral-950 rounded-lg overflow-hidden ${className}`}>
{/* 3D Canvas */}
<Canvas
shadows
camera={{ position: [0, 1.5, 3], fov: 50 }}
onError={(error) => onError?.(error as Error)}
>
{/* Lighting */}
<ambientLight intensity={0.5} />
<directionalLight
position={[5, 5, 5]}
intensity={1}
castShadow
shadow-mapSize-width={2048}
shadow-mapSize-height={2048}
/>
<spotLight position={[-5, 5, 5]} intensity={0.5} />
{/* Environment for better reflections */}
<Environment preset="studio" />
{/* Grid */}
{showGrid && (
<Grid
args={[10, 10]}
cellSize={0.5}
cellThickness={0.5}
cellColor="#444"
sectionSize={1}
sectionThickness={1}
sectionColor="#666"
fadeDistance={20}
fadeStrength={1}
infiniteGrid
/>
)}
{/* Model */}
<Suspense fallback={<LoadingFallback />}>
{modelUrl ? (
<Model url={modelUrl} onError={onError} />
) : (
<FallbackModel />
)}
</Suspense>
{/* Camera Controls */}
<OrbitControls
ref={controlsRef}
enableDamping
dampingFactor={0.05}
autoRotate={autoRotate}
autoRotateSpeed={1}
minDistance={1}
maxDistance={10}
/>
</Canvas>
{/* Control Panel */}
{showControls && (
<div className="absolute bottom-4 left-1/2 transform -translate-x-1/2 flex items-center gap-2 bg-neutral-900/90 backdrop-blur-sm rounded-lg p-2 border border-neutral-800">
<Button
size="sm"
variant="ghost"
onClick={() => setShowGrid(!showGrid)}
title="Toggle Grid"
>
<Grid3x3 size={16} />
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => setShowWireframe(!showWireframe)}
title="Toggle Wireframe"
>
{showWireframe ? <EyeOff size={16} /> : <Eye size={16} />}
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => setAutoRotate(!autoRotate)}
title="Toggle Auto-Rotate"
className={autoRotate ? 'text-primary' : ''}
>
<RotateCcw size={16} />
</Button>
<div className="w-px h-6 bg-neutral-700 mx-1" />
<Button
size="sm"
variant="ghost"
onClick={handleZoomIn}
title="Zoom In"
>
<ZoomIn size={16} />
</Button>
<Button
size="sm"
variant="ghost"
onClick={handleZoomOut}
title="Zoom Out"
>
<ZoomOut size={16} />
</Button>
<Button
size="sm"
variant="ghost"
onClick={handleReset}
title="Reset Camera"
>
Reset
</Button>
</div>
)}
{/* Info overlay */}
{!modelUrl && (
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
<div className="text-center text-muted-foreground">
<p className="text-lg font-medium">No Model Loaded</p>
<p className="text-sm mt-1">Import an avatar to preview it here</p>
</div>
</div>
)}
</div>
);
}
// Preload common models
export function preloadModel(url: string) {
useGLTF.preload(url);
}

View file

@ -12,6 +12,7 @@ import { CertificationPanel } from '../CertificationPanel';
import { DesktopAppPanel } from '../DesktopAppPanel'; import { DesktopAppPanel } from '../DesktopAppPanel';
import { TeamCollabPanel } from '../TeamCollabPanel'; import { TeamCollabPanel } from '../TeamCollabPanel';
import { EnterpriseAnalyticsPanel } from '../EnterpriseAnalyticsPanel'; import { EnterpriseAnalyticsPanel } from '../EnterpriseAnalyticsPanel';
import { AvatarPanel } from '../AvatarPanel';
export function ExtraTabs({ user }: { user: any }) { export function ExtraTabs({ user }: { user: any }) {
return ( return (
@ -19,6 +20,7 @@ export function ExtraTabs({ user }: { user: any }) {
<TabsList className="w-full rounded-none border-b border-border"> <TabsList className="w-full rounded-none border-b border-border">
<TabsTrigger value="onboarding">Onboarding</TabsTrigger> <TabsTrigger value="onboarding">Onboarding</TabsTrigger>
<TabsTrigger value="profile">Profile</TabsTrigger> <TabsTrigger value="profile">Profile</TabsTrigger>
<TabsTrigger value="avatars">Avatars</TabsTrigger>
<TabsTrigger value="uefn">UEFN</TabsTrigger> <TabsTrigger value="uefn">UEFN</TabsTrigger>
<TabsTrigger value="preview">Preview</TabsTrigger> <TabsTrigger value="preview">Preview</TabsTrigger>
<TabsTrigger value="translation">Translation</TabsTrigger> <TabsTrigger value="translation">Translation</TabsTrigger>
@ -32,6 +34,7 @@ export function ExtraTabs({ user }: { user: any }) {
</TabsList> </TabsList>
<TabsContent value="onboarding"><OnboardingDialog open={true} onClose={() => {}} /></TabsContent> <TabsContent value="onboarding"><OnboardingDialog open={true} onClose={() => {}} /></TabsContent>
<TabsContent value="profile"><UserProfile user={user} /></TabsContent> <TabsContent value="profile"><UserProfile user={user} /></TabsContent>
<TabsContent value="avatars" className="flex-1"><AvatarPanel /></TabsContent>
<TabsContent value="uefn"><UEFNPanel /></TabsContent> <TabsContent value="uefn"><UEFNPanel /></TabsContent>
<TabsContent value="preview"><GamePreviewPanel /></TabsContent> <TabsContent value="preview"><GamePreviewPanel /></TabsContent>
<TabsContent value="translation"><TranslationPanel /></TabsContent> <TabsContent value="translation"><TranslationPanel /></TabsContent>

241
src/lib/avatar/types.ts Normal file
View file

@ -0,0 +1,241 @@
import type { PlatformId } from '../platforms';
/**
* Avatar file format types
*/
export type AvatarFormat = 'vrm' | 'fbx' | 'glb' | 'gltf' | 'obj';
/**
* Avatar rig types
*/
export type RigType = 'humanoid' | 'r15' | 'r6' | 'custom' | 'unrigged';
/**
* Auto-rigging service providers
*/
export type RiggingService = 'accurig' | 'tripo' | 'deepmotion' | 'custom' | 'manual';
/**
* Export job status
*/
export type ExportStatus = 'idle' | 'validating' | 'processing' | 'completed' | 'failed';
/**
* Avatar project metadata
*/
export interface AvatarProject {
id: string;
name: string;
description?: string;
createdAt: string;
updatedAt: string;
// File information
sourceFormat: AvatarFormat;
sourceFileUrl?: string;
sourceFileSize: number; // bytes
thumbnailUrl?: string;
// Model statistics
polygonCount: number;
vertexCount: number;
textureCount: number;
textureSize: number; // total MB
// Rig information
rigType: RigType;
isRigged: boolean;
boneCount?: number;
riggingService?: RiggingService;
// Platform compatibility
supportedPlatforms: PlatformId[];
exportedPlatforms: PlatformId[];
// Tags and categorization
tags: string[];
category?: 'humanoid' | 'stylized' | 'realistic' | 'creature' | 'vehicle' | 'prop';
style?: 'anime' | 'realistic' | 'lowpoly' | 'voxel' | 'custom';
}
/**
* Platform-specific export configuration
*/
export interface PlatformExportConfig {
platform: PlatformId;
enabled: boolean;
// Optimization settings
targetPolygonCount?: number;
targetTextureSize?: number;
generateLODs?: boolean;
lodLevels?: number;
// Platform-specific settings
roblox?: {
rigType: 'r15' | 'r6';
includeLayeredClothing: boolean;
includeAccessories: boolean;
};
vrchat?: {
targetPerformance: 'excellent' | 'good' | 'medium' | 'poor';
questCompatible: boolean;
questPolyLimit: number;
includeVisemes: boolean;
includeEyeTracking: boolean;
};
spatial?: {
avatarType: 'universal' | 'world';
embedTextures: boolean;
};
recroom?: {
itemType: 'full-avatar' | 'accessory';
beanCompatible: boolean;
};
}
/**
* Export validation result
*/
export interface ValidationResult {
platform: PlatformId;
isValid: boolean;
warnings: ValidationMessage[];
errors: ValidationMessage[];
// Performance metrics
performanceRank?: 'excellent' | 'good' | 'medium' | 'poor';
estimatedFileSize?: number; // MB
// Platform-specific checks
checks: {
polygonCount: boolean;
textureSize: boolean;
materialCount: boolean;
rigStructure: boolean;
boneCount: boolean;
};
}
export interface ValidationMessage {
type: 'error' | 'warning' | 'info';
message: string;
fix?: string; // Suggested fix
}
/**
* Export job
*/
export interface ExportJob {
id: string;
avatarId: string;
platform: PlatformId;
status: ExportStatus;
progress: number; // 0-100
startedAt: string;
completedAt?: string;
// Results
outputFileUrl?: string;
outputFileSize?: number;
validationResult?: ValidationResult;
// Error handling
error?: string;
retryCount: number;
}
/**
* Avatar template for starting new projects
*/
export interface AvatarTemplate {
id: string;
name: string;
description: string;
thumbnailUrl: string;
modelUrl: string;
category: 'humanoid' | 'stylized' | 'realistic' | 'creature';
style: 'anime' | 'realistic' | 'lowpoly' | 'voxel';
polygonCount: number;
isRigged: boolean;
rigType: RigType;
supportedPlatforms: PlatformId[];
isPremium: boolean;
price?: number;
}
/**
* Auto-rigging request
*/
export interface RiggingRequest {
avatarId: string;
service: RiggingService;
sourceFileUrl: string;
options: {
quality: 'fast' | 'balanced' | 'high';
rigType: 'humanoid' | 'quadruped' | 'custom';
preserveBlendShapes?: boolean;
};
}
/**
* Auto-rigging result
*/
export interface RiggingResult {
success: boolean;
riggedFileUrl?: string;
rigInfo?: {
boneCount: number;
rootBone: string;
hasFingers: boolean;
hasFootIK: boolean;
hasFacialRig: boolean;
};
processingTime: number; // seconds
error?: string;
}
/**
* File upload progress
*/
export interface UploadProgress {
fileName: string;
fileSize: number;
uploadedBytes: number;
progress: number; // 0-100
status: 'uploading' | 'processing' | 'completed' | 'failed';
}
/**
* Onboarding step
*/
export interface OnboardingStep {
id: string;
title: string;
description: string;
completed: boolean;
}
/**
* User preferences for avatar studio
*/
export interface AvatarPreferences {
defaultExportFormat: AvatarFormat;
autoOptimize: boolean;
defaultRiggingService: RiggingService;
// Platform defaults
defaultPlatforms: PlatformId[];
// UI preferences
showTutorials: boolean;
gridSnap: boolean;
wireframeMode: boolean;
}

View file

@ -0,0 +1,422 @@
import type { AvatarProject, ValidationResult, ValidationMessage } from './types';
import type { PlatformId } from '../platforms';
/**
* Platform-specific requirements and limits
*/
const PLATFORM_LIMITS = {
roblox: {
maxPolygons: 50000,
maxTextureSize: 1024,
maxTextures: 10,
maxBones: 75,
requiresRig: true,
},
vrchat: {
pc: {
excellent: { maxPolygons: 32000, maxMaterials: 4, maxTextureMemory: 10 },
good: { maxPolygons: 70000, maxMaterials: 8, maxTextureMemory: 40 },
medium: { maxPolygons: 100000, maxMaterials: 16, maxTextureMemory: 75 },
poor: { maxPolygons: 150000, maxMaterials: 32, maxTextureMemory: 150 },
},
quest: {
excellent: { maxPolygons: 7500, maxMaterials: 1, maxTextureMemory: 10 },
good: { maxPolygons: 10000, maxMaterials: 2, maxTextureMemory: 20 },
medium: { maxPolygons: 15000, maxMaterials: 4, maxTextureMemory: 30 },
poor: { maxPolygons: 20000, maxMaterials: 8, maxTextureMemory: 40 },
},
requiresRig: true,
requiresVisemes: false,
},
spatial: {
maxPolygons: 100000,
maxTextureSize: 2048,
maxTextures: 16,
requiresRig: true,
requiresHumanoidRig: true,
},
recroom: {
maxPolygons: 15000,
maxTextureSize: 1024,
maxTextures: 5,
requiresRig: false,
},
uefn: {
maxPolygons: 100000,
maxTextureSize: 2048,
maxTextures: 20,
requiresRig: false,
},
core: {
maxPolygons: 50000,
maxTextureSize: 1024,
maxTextures: 10,
requiresRig: false,
},
} as const;
/**
* Validate avatar for Roblox platform
*/
function validateRoblox(avatar: AvatarProject): ValidationResult {
const errors: ValidationMessage[] = [];
const warnings: ValidationMessage[] = [];
const limits = PLATFORM_LIMITS.roblox;
// Check polygon count
if (avatar.polygonCount > limits.maxPolygons) {
errors.push({
type: 'error',
message: `Polygon count (${avatar.polygonCount.toLocaleString()}) exceeds Roblox recommended limit (${limits.maxPolygons.toLocaleString()})`,
fix: 'Use polygon reduction tool to optimize the model',
});
} else if (avatar.polygonCount > limits.maxPolygons * 0.8) {
warnings.push({
type: 'warning',
message: `Polygon count is high (${avatar.polygonCount.toLocaleString()}). Consider optimizing for better performance.`,
fix: 'Reduce polygon count to improve performance',
});
}
// Check texture size
if (avatar.textureSize > limits.maxTextures * 1) {
warnings.push({
type: 'warning',
message: `Total texture size (${avatar.textureSize.toFixed(1)} MB) is high`,
fix: 'Compress textures or reduce texture resolution',
});
}
// Check rig
if (!avatar.isRigged) {
errors.push({
type: 'error',
message: 'Roblox requires a rigged model (R15 or R6)',
fix: 'Use the Auto-Rig feature to add a skeleton',
});
} else if (avatar.rigType !== 'r15' && avatar.rigType !== 'r6') {
warnings.push({
type: 'warning',
message: `Rig type (${avatar.rigType}) may need conversion to R15 or R6`,
fix: 'Auto-conversion will be applied during export',
});
}
// Check bone count
if (avatar.boneCount && avatar.boneCount > limits.maxBones) {
warnings.push({
type: 'warning',
message: `Bone count (${avatar.boneCount}) exceeds typical Roblox limit (${limits.maxBones})`,
fix: 'Simplify rig structure',
});
}
const isValid = errors.length === 0;
return {
platform: 'roblox',
isValid,
errors,
warnings,
performanceRank: getPerformanceRank(avatar.polygonCount, limits.maxPolygons),
checks: {
polygonCount: avatar.polygonCount <= limits.maxPolygons,
textureSize: true, // Roblox is flexible with texture sizes
materialCount: true,
rigStructure: avatar.isRigged,
boneCount: !avatar.boneCount || avatar.boneCount <= limits.maxBones,
},
};
}
/**
* Validate avatar for VRChat platform
*/
function validateVRChat(
avatar: AvatarProject,
targetPlatform: 'pc' | 'quest' = 'pc'
): ValidationResult {
const errors: ValidationMessage[] = [];
const warnings: ValidationMessage[] = [];
const limits = PLATFORM_LIMITS.vrchat;
// Determine performance rank based on polygon count
let performanceRank: 'excellent' | 'good' | 'medium' | 'poor' = 'poor';
const platformLimits = limits[targetPlatform];
if (avatar.polygonCount <= platformLimits.excellent.maxPolygons) {
performanceRank = 'excellent';
} else if (avatar.polygonCount <= platformLimits.good.maxPolygons) {
performanceRank = 'good';
} else if (avatar.polygonCount <= platformLimits.medium.maxPolygons) {
performanceRank = 'medium';
}
// Check against limits for the determined rank
const rankLimits = platformLimits[performanceRank];
if (avatar.polygonCount > platformLimits.poor.maxPolygons) {
errors.push({
type: 'error',
message: `Polygon count (${avatar.polygonCount.toLocaleString()}) exceeds VRChat ${targetPlatform.toUpperCase()} maximum (${platformLimits.poor.maxPolygons.toLocaleString()})`,
fix: 'Use polygon reduction to meet platform limits',
});
} else if (performanceRank === 'poor') {
warnings.push({
type: 'warning',
message: `Avatar will be rated as "Poor" performance in VRChat ${targetPlatform.toUpperCase()}`,
fix: `Reduce polygons to ${platformLimits.medium.maxPolygons.toLocaleString()} for Medium rank`,
});
}
// Check texture memory
if (avatar.textureSize > rankLimits.maxTextureMemory) {
warnings.push({
type: 'warning',
message: `Texture memory (${avatar.textureSize.toFixed(1)} MB) may affect performance rank`,
fix: 'Compress or downsize textures',
});
}
// Check rig
if (!avatar.isRigged) {
errors.push({
type: 'error',
message: 'VRChat requires a humanoid rig',
fix: 'Use Auto-Rig to add Avatar 3.0 compatible skeleton',
});
}
const isValid = errors.length === 0;
return {
platform: 'vrchat',
isValid,
errors,
warnings,
performanceRank,
checks: {
polygonCount: avatar.polygonCount <= platformLimits.poor.maxPolygons,
textureSize: avatar.textureSize <= rankLimits.maxTextureMemory,
materialCount: true, // Will check material count in full export
rigStructure: avatar.isRigged && avatar.rigType === 'humanoid',
boneCount: true,
},
};
}
/**
* Validate avatar for Spatial platform
*/
function validateSpatial(avatar: AvatarProject): ValidationResult {
const errors: ValidationMessage[] = [];
const warnings: ValidationMessage[] = [];
const limits = PLATFORM_LIMITS.spatial;
// Check polygon count
if (avatar.polygonCount > limits.maxPolygons) {
errors.push({
type: 'error',
message: `Polygon count (${avatar.polygonCount.toLocaleString()}) exceeds Spatial limit (${limits.maxPolygons.toLocaleString()})`,
fix: 'Reduce polygon count using optimization tool',
});
}
// Check texture count and size
if (avatar.textureCount > limits.maxTextures) {
warnings.push({
type: 'warning',
message: `Texture count (${avatar.textureCount}) exceeds recommended limit (${limits.maxTextures})`,
fix: 'Combine textures or reduce texture usage',
});
}
// Check rig
if (!avatar.isRigged) {
errors.push({
type: 'error',
message: 'Spatial requires a humanoid rig',
fix: 'Use Auto-Rig to add a skeleton',
});
} else if (avatar.rigType !== 'humanoid') {
warnings.push({
type: 'warning',
message: 'Spatial works best with Unity Mecanim humanoid rigs',
fix: 'Rig will be converted to humanoid format during export',
});
}
const isValid = errors.length === 0;
return {
platform: 'spatial',
isValid,
errors,
warnings,
performanceRank: getPerformanceRank(avatar.polygonCount, limits.maxPolygons),
checks: {
polygonCount: avatar.polygonCount <= limits.maxPolygons,
textureSize: true,
materialCount: true,
rigStructure: avatar.isRigged,
boneCount: true,
},
};
}
/**
* Validate avatar for Rec Room platform
*/
function validateRecRoom(avatar: AvatarProject): ValidationResult {
const errors: ValidationMessage[] = [];
const warnings: ValidationMessage[] = [];
const limits = PLATFORM_LIMITS.recroom;
// Check polygon count
if (avatar.polygonCount > limits.maxPolygons) {
errors.push({
type: 'error',
message: `Polygon count (${avatar.polygonCount.toLocaleString()}) exceeds Rec Room limit (${limits.maxPolygons.toLocaleString()})`,
fix: 'Reduce polygon count significantly',
});
}
// Check textures
if (avatar.textureCount > limits.maxTextures) {
warnings.push({
type: 'warning',
message: `Texture count (${avatar.textureCount}) exceeds recommended limit (${limits.maxTextures})`,
fix: 'Combine or reduce textures',
});
}
const isValid = errors.length === 0;
return {
platform: 'recroom',
isValid,
errors,
warnings,
performanceRank: getPerformanceRank(avatar.polygonCount, limits.maxPolygons),
checks: {
polygonCount: avatar.polygonCount <= limits.maxPolygons,
textureSize: true,
materialCount: true,
rigStructure: true,
boneCount: true,
},
};
}
/**
* Determine performance rank based on polygon count vs limit
*/
function getPerformanceRank(
polygonCount: number,
maxPolygons: number
): 'excellent' | 'good' | 'medium' | 'poor' {
const ratio = polygonCount / maxPolygons;
if (ratio <= 0.4) return 'excellent';
if (ratio <= 0.7) return 'good';
if (ratio <= 0.9) return 'medium';
return 'poor';
}
/**
* Main validation function - validates avatar for specified platform
*/
export function validateAvatarForPlatform(
avatar: AvatarProject,
platformId: PlatformId,
options: { questMode?: boolean } = {}
): ValidationResult {
switch (platformId) {
case 'roblox':
return validateRoblox(avatar);
case 'vrchat':
return validateVRChat(avatar, options.questMode ? 'quest' : 'pc');
case 'spatial':
return validateSpatial(avatar);
case 'recroom':
return validateRecRoom(avatar);
case 'uefn':
// UEFN validation - more lenient
return {
platform: 'uefn',
isValid: true,
errors: [],
warnings: [],
performanceRank: 'good',
checks: {
polygonCount: true,
textureSize: true,
materialCount: true,
rigStructure: true,
boneCount: true,
},
};
case 'core':
// Core platform validation
return {
platform: 'core',
isValid: true,
errors: [],
warnings: [],
performanceRank: 'good',
checks: {
polygonCount: true,
textureSize: true,
materialCount: true,
rigStructure: true,
boneCount: true,
},
};
default:
return {
platform: platformId,
isValid: false,
errors: [{ type: 'error', message: 'Unknown platform' }],
warnings: [],
checks: {
polygonCount: false,
textureSize: false,
materialCount: false,
rigStructure: false,
boneCount: false,
},
};
}
}
/**
* Validate avatar for multiple platforms at once
*/
export function validateAvatarForPlatforms(
avatar: AvatarProject,
platformIds: PlatformId[]
): ValidationResult[] {
return platformIds.map((platformId) => validateAvatarForPlatform(avatar, platformId));
}
/**
* Get a summary of validation results
*/
export function getValidationSummary(results: ValidationResult[]): {
totalPlatforms: number;
validPlatforms: number;
totalErrors: number;
totalWarnings: number;
needsOptimization: boolean;
} {
const totalErrors = results.reduce((sum, r) => sum + r.errors.length, 0);
const totalWarnings = results.reduce((sum, r) => sum + r.warnings.length, 0);
const validPlatforms = results.filter((r) => r.isValid).length;
return {
totalPlatforms: results.length,
validPlatforms,
totalErrors,
totalWarnings,
needsOptimization: totalErrors > 0 || totalWarnings > 2,
};
}

255
src/store/avatar-store.ts Normal file
View file

@ -0,0 +1,255 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import type {
AvatarProject,
AvatarTemplate,
ExportJob,
PlatformExportConfig,
ValidationResult,
UploadProgress,
OnboardingStep,
AvatarPreferences,
} from '../lib/avatar/types';
import type { PlatformId } from '../lib/platforms';
interface AvatarStore {
// Current project state
currentProject: AvatarProject | null;
setCurrentProject: (project: AvatarProject | null) => void;
// Project library
projects: AvatarProject[];
addProject: (project: AvatarProject) => void;
updateProject: (id: string, updates: Partial<AvatarProject>) => void;
deleteProject: (id: string) => void;
// Export configuration
exportConfigs: Record<string, PlatformExportConfig>; // avatarId -> platform configs
updateExportConfig: (avatarId: string, config: PlatformExportConfig) => void;
getExportConfig: (avatarId: string, platform: PlatformId) => PlatformExportConfig | undefined;
// Export jobs
exportJobs: ExportJob[];
addExportJob: (job: ExportJob) => void;
updateExportJob: (id: string, updates: Partial<ExportJob>) => void;
getActiveJobs: () => ExportJob[];
// Validation results
validationResults: Record<string, ValidationResult[]>; // avatarId -> results
setValidationResults: (avatarId: string, results: ValidationResult[]) => void;
// Upload state
uploadProgress: UploadProgress | null;
setUploadProgress: (progress: UploadProgress | null) => void;
// Templates
templates: AvatarTemplate[];
setTemplates: (templates: AvatarTemplate[]) => void;
// UI state
isAvatarPanelOpen: boolean;
setAvatarPanelOpen: (open: boolean) => void;
selectedPlatforms: PlatformId[];
setSelectedPlatforms: (platforms: PlatformId[]) => void;
show3DViewer: boolean;
setShow3DViewer: (show: boolean) => void;
// Onboarding
onboardingSteps: OnboardingStep[];
setOnboardingSteps: (steps: OnboardingStep[]) => void;
completeOnboardingStep: (stepId: string) => void;
isOnboardingComplete: () => boolean;
// Preferences
preferences: AvatarPreferences;
updatePreferences: (updates: Partial<AvatarPreferences>) => void;
// Actions
resetAvatarStore: () => void;
}
const defaultPreferences: AvatarPreferences = {
defaultExportFormat: 'glb',
autoOptimize: true,
defaultRiggingService: 'accurig',
defaultPlatforms: ['roblox', 'vrchat', 'spatial'],
showTutorials: true,
gridSnap: false,
wireframeMode: false,
};
const defaultOnboardingSteps: OnboardingStep[] = [
{
id: 'welcome',
title: 'Welcome to Avatar Studio',
description: 'Learn how to create cross-platform avatars',
completed: false,
},
{
id: 'import',
title: 'Import Your First Avatar',
description: 'Upload a VRM, GLB, or FBX file',
completed: false,
},
{
id: 'rig',
title: 'Auto-Rig Your Avatar',
description: 'Add a skeleton to your model automatically',
completed: false,
},
{
id: 'platforms',
title: 'Select Target Platforms',
description: 'Choose which platforms to export to',
completed: false,
},
{
id: 'export',
title: 'Export Your Avatar',
description: 'Generate platform-specific files',
completed: false,
},
];
export const useAvatarStore = create<AvatarStore>()(
persist(
(set, get) => ({
// Initial state
currentProject: null,
projects: [],
exportConfigs: {},
exportJobs: [],
validationResults: {},
uploadProgress: null,
templates: [],
isAvatarPanelOpen: false,
selectedPlatforms: ['roblox', 'vrchat', 'spatial'],
show3DViewer: true,
onboardingSteps: defaultOnboardingSteps,
preferences: defaultPreferences,
// Project management
setCurrentProject: (project) => set({ currentProject: project }),
addProject: (project) =>
set((state) => ({
projects: [...state.projects, project],
currentProject: project,
})),
updateProject: (id, updates) =>
set((state) => ({
projects: state.projects.map((p) =>
p.id === id ? { ...p, ...updates, updatedAt: new Date().toISOString() } : p
),
currentProject:
state.currentProject?.id === id
? { ...state.currentProject, ...updates, updatedAt: new Date().toISOString() }
: state.currentProject,
})),
deleteProject: (id) =>
set((state) => ({
projects: state.projects.filter((p) => p.id !== id),
currentProject: state.currentProject?.id === id ? null : state.currentProject,
})),
// Export configuration
updateExportConfig: (avatarId, config) =>
set((state) => {
const avatarConfigs = state.exportConfigs[avatarId] || {};
return {
exportConfigs: {
...state.exportConfigs,
[avatarId]: {
...avatarConfigs,
[config.platform]: config,
},
},
};
}),
getExportConfig: (avatarId, platform) => {
const configs = get().exportConfigs[avatarId];
return configs?.[platform];
},
// Export jobs
addExportJob: (job) =>
set((state) => ({
exportJobs: [...state.exportJobs, job],
})),
updateExportJob: (id, updates) =>
set((state) => ({
exportJobs: state.exportJobs.map((j) => (j.id === id ? { ...j, ...updates } : j)),
})),
getActiveJobs: () =>
get().exportJobs.filter((j) => j.status === 'validating' || j.status === 'processing'),
// Validation
setValidationResults: (avatarId, results) =>
set((state) => ({
validationResults: {
...state.validationResults,
[avatarId]: results,
},
})),
// Upload
setUploadProgress: (progress) => set({ uploadProgress: progress }),
// Templates
setTemplates: (templates) => set({ templates }),
// UI state
setAvatarPanelOpen: (open) => set({ isAvatarPanelOpen: open }),
setSelectedPlatforms: (platforms) => set({ selectedPlatforms: platforms }),
setShow3DViewer: (show) => set({ show3DViewer: show }),
// Onboarding
setOnboardingSteps: (steps) => set({ onboardingSteps: steps }),
completeOnboardingStep: (stepId) =>
set((state) => ({
onboardingSteps: state.onboardingSteps.map((step) =>
step.id === stepId ? { ...step, completed: true } : step
),
})),
isOnboardingComplete: () => get().onboardingSteps.every((step) => step.completed),
// Preferences
updatePreferences: (updates) =>
set((state) => ({
preferences: { ...state.preferences, ...updates },
})),
// Reset
resetAvatarStore: () =>
set({
currentProject: null,
projects: [],
exportConfigs: {},
exportJobs: [],
validationResults: {},
uploadProgress: null,
selectedPlatforms: ['roblox', 'vrchat', 'spatial'],
onboardingSteps: defaultOnboardingSteps,
preferences: defaultPreferences,
}),
}),
{
name: 'aethex-avatar-storage',
partialize: (state) => ({
projects: state.projects,
exportConfigs: state.exportConfigs,
preferences: state.preferences,
onboardingSteps: state.onboardingSteps,
}),
}
)
);