diff --git a/README.md b/README.md index dc9a5d3..040e054 100644 --- a/README.md +++ b/README.md @@ -6,28 +6,28 @@ [![build-badge](https://img.shields.io/badge/build-wip-yellow)](https://github.com/yourname/forgery) [![license-mit](https://img.shields.io/badge/license-MIT-blue)](./LICENSE) -Forge characters, not chaos. `forgery` helps you extract equipments (props, clothing, accessories) from character images, save them as reusable items in a DB, and then generate new character *looks* by applying selected equipments + pose/expression controls using AI image generation. +Forge characters, not chaos. `forgery` helps you extract equipments (props, clothing, accessories) from character images, save them as reusable items in a DB, and then generate new character _looks_ by applying selected equipments + pose/expression controls using AI image generation. --- ## Why Forgery? -* **Modular Assets**: Turn screenshots, cosplay photos, or movie stills into modular assets. -* **Rapid Prototyping**: Quickly prototype many character looks by mixing and matching equipments. -* **Fine-Grained Control**: Add expression & pose control to generated looks for richer results. -* **Local-First & Private**: All data, including your image library and generated results, is stored locally on your machine. -* **Flexible AI Integration**: Seamlessly switch between Google Gemini and OpenAI models for different stages of the pipeline. +- **Modular Assets**: Turn screenshots, cosplay photos, or movie stills into modular assets. +- **Rapid Prototyping**: Quickly prototype many character looks by mixing and matching equipments. +- **Fine-Grained Control**: Add expression & pose control to generated looks for richer results. +- **Local-First & Private**: All data, including your image library and generated results, is stored locally on your machine. +- **Flexible AI Integration**: Seamlessly switch between Google Gemini and OpenAI models for different stages of the pipeline. --- ## Highlights -* 🔍 **Equipment extraction** — detect & crop equipment regions, normalize and store metadata. -* 🧩 **Equipment library** — structured DB with visual preview, masks, tags, anchors, and compatibility hints. -* 🖼️ **Apply equipments** — generate a new image of a character wearing selected equipments. -* 🎭 **Pose & expression controls** — specify expression and pose priors to steer generation. -* 🔌 **Model-agnostic** — plug in your favorite segmentation or image-generation models (OpenAI, Gemini supported out-of-the-box). -* ♻️ **Open-source** — clear data model & contribution guidelines. +- 🔍 **Equipment extraction** — detect & crop equipment regions, normalize and store metadata. +- 🧩 **Equipment library** — structured DB with visual preview, masks, tags, anchors, and compatibility hints. +- 🖼️ **Apply equipments** — generate a new image of a character wearing selected equipments. +- 🎭 **Pose & expression controls** — specify expression and pose priors to steer generation. +- 🔌 **Model-agnostic** — plug in your favorite segmentation or image-generation models (OpenAI, Gemini supported out-of-the-box). +- ♻️ **Open-source** — clear data model & contribution guidelines. --- @@ -43,11 +43,11 @@ Forge characters, not chaos. `forgery` helps you extract equipments (props, clot ## Core Concepts -* **Equipment** — any wearable or attachable asset extracted from images (hat, glasses, sword, jacket). Stored with mask, anchor points, metadata. -* **Character** — a user-created profile representing a base person/portrait used as the generation canvas. -* **Look** — a generated image (or variant) that applies a set of equipments to a character, optionally altered by pose/expression. -* **Extraction** — computer vision process that detects equipment regions and outputs masks, crops, and suggested anchors. -* **Application/Generation** — image generation pipeline that composes character + selected equipments + conditioning (pose/expression) into a final render. +- **Equipment** — any wearable or attachable asset extracted from images (hat, glasses, sword, jacket). Stored with mask, anchor points, metadata. +- **Character** — a user-created profile representing a base person/portrait used as the generation canvas. +- **Look** — a generated image (or variant) that applies a set of equipments to a character, optionally altered by pose/expression. +- **Extraction** — computer vision process that detects equipment regions and outputs masks, crops, and suggested anchors. +- **Application/Generation** — image generation pipeline that composes character + selected equipments + conditioning (pose/expression) into a final render. --- @@ -55,9 +55,9 @@ Forge characters, not chaos. `forgery` helps you extract equipments (props, clot ### Requirements -* [Bun](https://bun.sh) (v1.0.0 or later) -* **OR** [Docker](https://www.docker.com/) -* API Keys for OpenAI or Google Gemini (for AI generation) +- [Bun](https://bun.sh) (v1.0.0 or later) +- **OR** [Docker](https://www.docker.com/) +- API Keys for OpenAI or Google Gemini (for AI generation) ### Local Quickstart @@ -95,17 +95,17 @@ Forgery requires API keys for AI generation services (Google Gemini and/or OpenA A modular pipeline built on a modern stack: 1. **Frontend** (React + Shadcn UI + Tailwind) - * Upload UI, equipment library browser, look composer (Fitting Room). + - Upload UI, equipment library browser, look composer (Fitting Room). 2. **API Server** (Hono on Bun) - * REST API for extraction, equipment CRUD, character CRUD, generation. + - REST API for extraction, equipment CRUD, character CRUD, generation. 3. **Extraction Service** - * Orchestrates AI models (OpenAI/Gemini) to analyze images and generate masks. + - Orchestrates AI models (OpenAI/Gemini) to analyze images and generate masks. 4. **Asset Store** - * Local file system (`data/files`) for storing optimized WebP images. + - Local file system (`data/files`) for storing optimized WebP images. 5. **Generation Service** - * Composes prompts and conditioning for image generation models. + - Composes prompts and conditioning for image generation models. 6. **DB** (SQLite + Prisma) - * Stores metadata, relationships, settings, and generation history. + - Stores metadata, relationships, settings, and generation history. --- @@ -113,9 +113,9 @@ A modular pipeline built on a modern stack: Love forks, fixes, and wild experiments. -* Run tests: `bun test` -* Lint code: `bun run lint` -* Create clear PRs with reproducible steps and small commits. +- Run tests: `bun test` +- Lint code: `bun run lint` +- Create clear PRs with reproducible steps and small commits. --- diff --git a/image-refine-vibe/.gitignore b/image-refine-vibe/.gitignore deleted file mode 100644 index a547bf3..0000000 --- a/image-refine-vibe/.gitignore +++ /dev/null @@ -1,24 +0,0 @@ -# Logs -logs -*.log -npm-debug.log* -yarn-debug.log* -yarn-error.log* -pnpm-debug.log* -lerna-debug.log* - -node_modules -dist -dist-ssr -*.local - -# Editor directories and files -.vscode/* -!.vscode/extensions.json -.idea -.DS_Store -*.suo -*.ntvs* -*.njsproj -*.sln -*.sw? diff --git a/image-refine-vibe/App.tsx b/image-refine-vibe/App.tsx deleted file mode 100644 index e761a67..0000000 --- a/image-refine-vibe/App.tsx +++ /dev/null @@ -1,122 +0,0 @@ -import React, { useState, useCallback } from 'react'; -import { UploadStage } from './components/UploadStage'; -import { AnalyzeStage } from './components/AnalyzeStage'; -import { SplitStage } from './components/SplitStage'; -import { RefineStage } from './components/RefineStage'; -import { sliceImage } from './utils/imageProcessor'; -import { AppStage, ImageSegment } from './types'; - -const App: React.FC = () => { - const [stage, setStage] = useState('upload'); - const [uploadedImage, setUploadedImage] = useState(null); - const [segments, setSegments] = useState([]); - - // 1. Handle Upload - const handleImageUpload = useCallback((imageDataUrl: string) => { - setUploadedImage(imageDataUrl); - setStage('analyze'); - }, []); - - // 2. Handle Analysis Completion - const handleAnalysisComplete = useCallback(async () => { - if (!uploadedImage) return; - - try { - // Slice image into 3x3 grid - const slicedImages = await sliceImage(uploadedImage, 3, 3); - - const newSegments: ImageSegment[] = slicedImages.map((src, index) => ({ - id: `seg-${index}`, - src, - selected: true, // Default all selected - status: 'pending' - })); - - setSegments(newSegments); - setStage('split'); - } catch (error) { - console.error("Failed to slice image", error); - // Fallback or error handling could go here - } - }, [uploadedImage]); - - // 3. Handle Split Confirmation - const handleSplitConfirm = useCallback((selectedIds: string[]) => { - // Filter out unselected items for the refine view - const selectedSegments = segments.filter(seg => selectedIds.includes(seg.id)); - setSegments(selectedSegments); - setStage('refine'); - }, [segments]); - - // 4. Reset - const handleReset = useCallback(() => { - setUploadedImage(null); - setSegments([]); - setStage('upload'); - }, []); - - return ( -
- - {/* Background Ambience */} -
-
-
-
- -
- {/* Navigation / Header (Minimal) */} -
-
-
- AI -
-

RefineLab

-
- - {/* Progress Steps */} - {stage !== 'upload' && ( -
-
Analyze
-
-
Split
-
-
Refine
-
- )} -
- - {/* Content Area */} -
- {stage === 'upload' && ( - - )} - - {stage === 'analyze' && uploadedImage && ( - - )} - - {stage === 'split' && ( - - )} - - {stage === 'refine' && ( - - )} -
-
-
- ); -}; - -export default App; \ No newline at end of file diff --git a/image-refine-vibe/README.md b/image-refine-vibe/README.md deleted file mode 100644 index 9da58b0..0000000 --- a/image-refine-vibe/README.md +++ /dev/null @@ -1,20 +0,0 @@ -
-GHBanner -
- -# Run and deploy your AI Studio app - -This contains everything you need to run your app locally. - -View your app in AI Studio: https://ai.studio/apps/drive/1P0JLZhnyDHgV03_9rLIzEixF3AY0-NQU - -## Run Locally - -**Prerequisites:** Node.js - - -1. Install dependencies: - `npm install` -2. Set the `GEMINI_API_KEY` in [.env.local](.env.local) to your Gemini API key -3. Run the app: - `npm run dev` diff --git a/image-refine-vibe/components/AnalyzeStage.tsx b/image-refine-vibe/components/AnalyzeStage.tsx deleted file mode 100644 index ae4d1e2..0000000 --- a/image-refine-vibe/components/AnalyzeStage.tsx +++ /dev/null @@ -1,70 +0,0 @@ -import React, { useEffect } from 'react'; -import { Scan, BrainCircuit } from 'lucide-react'; - -interface AnalyzeStageProps { - imageSrc: string; - onAnalysisComplete: () => void; -} - -export const AnalyzeStage: React.FC = ({ imageSrc, onAnalysisComplete }) => { - useEffect(() => { - const timer = setTimeout(() => { - onAnalysisComplete(); - }, 5000); - - return () => clearTimeout(timer); - }, [onAnalysisComplete]); - - return ( -
- {/* Background Frosted Glass Layer */} -
- - {/* Main Container */} -
- - {/* Image Container with Scanning Effect */} -
- Analyzing - - {/* Scan Line Animation */} -
- - {/* Grid Overlay Effect (Subtle) */} -
- - {/* Corner Brackets UI */} -
-
-
-
-
- - {/* Status Text */} -
- - - Analyzing Structure... - -
-
-
-
-
-
- -
-
- ); -}; \ No newline at end of file diff --git a/image-refine-vibe/components/RefineStage.tsx b/image-refine-vibe/components/RefineStage.tsx deleted file mode 100644 index e237f4e..0000000 --- a/image-refine-vibe/components/RefineStage.tsx +++ /dev/null @@ -1,131 +0,0 @@ -import React, { useEffect, useState, useRef } from 'react'; -import { ImageSegment } from '../types'; -import { Loader2, CheckCircle2, Sparkles, RefreshCw } from 'lucide-react'; - -interface RefineStageProps { - initialSegments: ImageSegment[]; - onDone: () => void; -} - -export const RefineStage: React.FC = ({ initialSegments, onDone }) => { - const [segments, setSegments] = useState(initialSegments); - const [processingIndex, setProcessingIndex] = useState(0); - const [isAllDone, setIsAllDone] = useState(false); - const processingRef = useRef(false); - - useEffect(() => { - // We only process segments that were selected - const selectedSegments = segments.filter(s => s.selected); - - if (processingIndex >= selectedSegments.length) { - setIsAllDone(true); - return; - } - - if (processingRef.current) return; - processingRef.current = true; - - const currentSegmentId = selectedSegments[processingIndex].id; - - // Set current to processing - setSegments(prev => prev.map(s => - s.id === currentSegmentId ? { ...s, status: 'processing' } : s - )); - - const processTimer = setTimeout(() => { - // Mark as completed and move next - setSegments(prev => prev.map(s => - s.id === currentSegmentId ? { ...s, status: 'completed' } : s - )); - - setProcessingIndex(prev => prev + 1); - processingRef.current = false; - }, 2000); - - return () => clearTimeout(processTimer); - }, [processingIndex, segments]); - - return ( -
- {/* Header */} -
-
-

- - Refining Images -

-

- {isAllDone - ? "All selected segments have been processed successfully." - : `Processing item ${processingIndex + 1} of ${segments.length}...`} -

-
- {isAllDone && ( - - )} -
- - {/* Grid */} -
-
- {segments.map((seg) => ( -
- segment - - {/* Overlay states */} -
- {seg.status === 'pending' && ( -
- Pending -
- )} - - {seg.status === 'processing' && ( -
-
-
-
- -
-
- Refining... -
- )} - - {seg.status === 'completed' && ( -
- {/* Clean view for completed, just a subtle indicator */} -
- -
-
- )} -
- - {/* Scanning effect for processing item only */} - {seg.status === 'processing' && ( -
- )} -
- ))} -
-
-
- ); -}; \ No newline at end of file diff --git a/image-refine-vibe/components/SplitStage.tsx b/image-refine-vibe/components/SplitStage.tsx deleted file mode 100644 index 4b5000a..0000000 --- a/image-refine-vibe/components/SplitStage.tsx +++ /dev/null @@ -1,110 +0,0 @@ -import React, { useState } from 'react'; -import { ImageSegment } from '../types'; -import { Check, Grid2X2, ArrowRight } from 'lucide-react'; - -interface SplitStageProps { - segments: ImageSegment[]; - onConfirm: (selectedIds: string[]) => void; - onBack: () => void; -} - -export const SplitStage: React.FC = ({ segments, onConfirm, onBack }) => { - const [localSegments, setLocalSegments] = useState(segments); - - const toggleSelection = (id: string) => { - setLocalSegments(prev => - prev.map(seg => seg.id === id ? { ...seg, selected: !seg.selected } : seg) - ); - }; - - const selectedCount = localSegments.filter(s => s.selected).length; - - const handleConfirm = () => { - if (selectedCount === 0) return; - const selectedIds = localSegments.filter(s => s.selected).map(s => s.id); - onConfirm(selectedIds); - }; - - return ( -
- {/* Header */} -
-
-

- - Select Segments -

-

- Unselect any areas you wish to exclude from processing. -

-
-
- {selectedCount} - Selected -
-
- - {/* Grid */} -
-
- {localSegments.map((seg) => ( -
toggleSelection(seg.id)} - className={` - group relative aspect-square rounded-xl overflow-hidden cursor-pointer transition-all duration-300 - border-2 - ${seg.selected - ? 'border-sky-500 shadow-lg shadow-sky-500/20 scale-[1.01]' - : 'border-slate-700 opacity-60 hover:opacity-100 hover:border-slate-500'} - `} - > - segment - - {/* Selection Overlay */} -
- - {/* Checkbox Indicator */} -
- -
-
- ))} -
-
- - {/* Footer Actions */} -
-
- - -
-
-
- ); -}; \ No newline at end of file diff --git a/image-refine-vibe/components/UploadStage.tsx b/image-refine-vibe/components/UploadStage.tsx deleted file mode 100644 index e9dbc27..0000000 --- a/image-refine-vibe/components/UploadStage.tsx +++ /dev/null @@ -1,95 +0,0 @@ -import React, { useCallback, useState } from 'react'; -import { Upload, Image as ImageIcon } from 'lucide-react'; -import { readFileAsDataURL } from '../utils/imageProcessor'; - -interface UploadStageProps { - onImageUpload: (imageDataUrl: string) => void; -} - -export const UploadStage: React.FC = ({ onImageUpload }) => { - const [isDragging, setIsDragging] = useState(false); - - const handleDragOver = useCallback((e: React.DragEvent) => { - e.preventDefault(); - setIsDragging(true); - }, []); - - const handleDragLeave = useCallback((e: React.DragEvent) => { - e.preventDefault(); - setIsDragging(false); - }, []); - - const handleDrop = useCallback(async (e: React.DragEvent) => { - e.preventDefault(); - setIsDragging(false); - - if (e.dataTransfer.files && e.dataTransfer.files[0]) { - const file = e.dataTransfer.files[0]; - if (file.type.startsWith('image/')) { - try { - const dataUrl = await readFileAsDataURL(file); - onImageUpload(dataUrl); - } catch (error) { - console.error("Error reading file:", error); - } - } - } - }, [onImageUpload]); - - const handleFileChange = useCallback(async (e: React.ChangeEvent) => { - if (e.target.files && e.target.files[0]) { - try { - const dataUrl = await readFileAsDataURL(e.target.files[0]); - onImageUpload(dataUrl); - } catch (error) { - console.error("Error reading file:", error); - } - } - }, [onImageUpload]); - - return ( -
-
-
-
- -
-
-

- Upload an Image -

-

- Drag and drop your image here, or click to browse files -

-
- -
-
-

- Supports JPG, PNG, WEBP up to 10MB -

-
- ); -}; \ No newline at end of file diff --git a/image-refine-vibe/index.html b/image-refine-vibe/index.html deleted file mode 100644 index 0c10507..0000000 --- a/image-refine-vibe/index.html +++ /dev/null @@ -1,66 +0,0 @@ - - - - - - Image Refine Tool - - - - - - -
- - - \ No newline at end of file diff --git a/image-refine-vibe/index.tsx b/image-refine-vibe/index.tsx deleted file mode 100644 index 6ca5361..0000000 --- a/image-refine-vibe/index.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import React from 'react'; -import ReactDOM from 'react-dom/client'; -import App from './App'; - -const rootElement = document.getElementById('root'); -if (!rootElement) { - throw new Error("Could not find root element to mount to"); -} - -const root = ReactDOM.createRoot(rootElement); -root.render( - - - -); \ No newline at end of file diff --git a/image-refine-vibe/metadata.json b/image-refine-vibe/metadata.json deleted file mode 100644 index 9763a92..0000000 --- a/image-refine-vibe/metadata.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "name": "Image Split & Refine Tool", - "description": "A sophisticated image processing tool that analyzes, splits, and refines images with a modern frosted glass UI.", - "requestFramePermissions": [] -} \ No newline at end of file diff --git a/image-refine-vibe/package.json b/image-refine-vibe/package.json deleted file mode 100644 index fe85e3d..0000000 --- a/image-refine-vibe/package.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "name": "image-split-&-refine-tool", - "private": true, - "version": "0.0.0", - "type": "module", - "scripts": { - "dev": "vite", - "build": "vite build", - "preview": "vite preview" - }, - "dependencies": { - "react": "^19.2.4", - "lucide-react": "^0.564.0", - "react-dom": "^19.2.4" - }, - "devDependencies": { - "@types/node": "^22.14.0", - "@vitejs/plugin-react": "^5.0.0", - "typescript": "~5.8.2", - "vite": "^6.2.0" - } -} diff --git a/image-refine-vibe/tsconfig.json b/image-refine-vibe/tsconfig.json deleted file mode 100644 index 2c6eed5..0000000 --- a/image-refine-vibe/tsconfig.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "experimentalDecorators": true, - "useDefineForClassFields": false, - "module": "ESNext", - "lib": [ - "ES2022", - "DOM", - "DOM.Iterable" - ], - "skipLibCheck": true, - "types": [ - "node" - ], - "moduleResolution": "bundler", - "isolatedModules": true, - "moduleDetection": "force", - "allowJs": true, - "jsx": "react-jsx", - "paths": { - "@/*": [ - "./*" - ] - }, - "allowImportingTsExtensions": true, - "noEmit": true - } -} \ No newline at end of file diff --git a/image-refine-vibe/types.ts b/image-refine-vibe/types.ts deleted file mode 100644 index 82de554..0000000 --- a/image-refine-vibe/types.ts +++ /dev/null @@ -1,13 +0,0 @@ -export type AppStage = 'upload' | 'analyze' | 'split' | 'refine' | 'completed'; - -export interface ImageSegment { - id: string; - src: string; - selected: boolean; - status: 'pending' | 'processing' | 'completed'; -} - -export interface ProcessingState { - isProcessing: boolean; - currentId: string | null; -} \ No newline at end of file diff --git a/image-refine-vibe/utils/imageProcessor.ts b/image-refine-vibe/utils/imageProcessor.ts deleted file mode 100644 index 41a0d9b..0000000 --- a/image-refine-vibe/utils/imageProcessor.ts +++ /dev/null @@ -1,72 +0,0 @@ -/** - * Slices an image into a grid of smaller images. - * @param imageSrc The source image data URL. - * @param rows Number of rows. - * @param cols Number of columns. - * @returns A promise resolving to an array of data URLs for the segments. - */ -export const sliceImage = (imageSrc: string, rows: number = 3, cols: number = 3): Promise => { - return new Promise((resolve, reject) => { - const img = new Image(); - img.crossOrigin = "Anonymous"; - img.onload = () => { - const segments: string[] = []; - const segmentWidth = Math.floor(img.width / cols); - const segmentHeight = Math.floor(img.height / rows); - - const canvas = document.createElement('canvas'); - const ctx = canvas.getContext('2d'); - - if (!ctx) { - reject(new Error("Could not get canvas context")); - return; - } - - canvas.width = segmentWidth; - canvas.height = segmentHeight; - - for (let y = 0; y < rows; y++) { - for (let x = 0; x < cols; x++) { - // Clear canvas for next segment - ctx.clearRect(0, 0, segmentWidth, segmentHeight); - - // Draw the specific slice - ctx.drawImage( - img, - x * segmentWidth, - y * segmentHeight, - segmentWidth, - segmentHeight, - 0, - 0, - segmentWidth, - segmentHeight - ); - - segments.push(canvas.toDataURL('image/png')); - } - } - resolve(segments); - }; - img.onerror = (err) => reject(err); - img.src = imageSrc; - }); -}; - -/** - * Reads a file input as a Data URL. - */ -export const readFileAsDataURL = (file: File): Promise => { - return new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.onload = (e) => { - if (e.target?.result) { - resolve(e.target.result as string); - } else { - reject(new Error("Failed to read file")); - } - }; - reader.onerror = reject; - reader.readAsDataURL(file); - }); -}; \ No newline at end of file diff --git a/image-refine-vibe/vite.config.ts b/image-refine-vibe/vite.config.ts deleted file mode 100644 index ee5fb8d..0000000 --- a/image-refine-vibe/vite.config.ts +++ /dev/null @@ -1,23 +0,0 @@ -import path from 'path'; -import { defineConfig, loadEnv } from 'vite'; -import react from '@vitejs/plugin-react'; - -export default defineConfig(({ mode }) => { - const env = loadEnv(mode, '.', ''); - return { - server: { - port: 3000, - host: '0.0.0.0', - }, - plugins: [react()], - define: { - 'process.env.API_KEY': JSON.stringify(env.GEMINI_API_KEY), - 'process.env.GEMINI_API_KEY': JSON.stringify(env.GEMINI_API_KEY) - }, - resolve: { - alias: { - '@': path.resolve(__dirname, '.'), - } - } - }; -}); diff --git a/src/service/equipment.test.ts b/src/service/equipment.test.ts index 6dcec77..173dcbe 100644 --- a/src/service/equipment.test.ts +++ b/src/service/equipment.test.ts @@ -92,7 +92,9 @@ describe('EquipmentService', () => { await equipmentService.deleteEquipment('1') expect(mockPrisma.equipment.findUnique).toHaveBeenCalledWith({ where: { id: '1' } }) - expect(mockPrisma.generationEquipment.deleteMany).toHaveBeenCalledWith({ where: { equipmentId: '1' } }) + expect(mockPrisma.generationEquipment.deleteMany).toHaveBeenCalledWith({ + where: { equipmentId: '1' }, + }) expect(mockPrisma.equipment.delete).toHaveBeenCalledWith({ where: { id: '1' } }) expect(mockAssetService.deleteAsset).toHaveBeenCalledWith('asset-1') }) diff --git a/src/ui/components/ExtractorDialog.tsx b/src/ui/components/ExtractorDialog.tsx index 6bfbebc..82d0c5c 100644 --- a/src/ui/components/ExtractorDialog.tsx +++ b/src/ui/components/ExtractorDialog.tsx @@ -1,9 +1,12 @@ -import { AlertCircle, Check, Loader2 } from 'lucide-react' -import React, { useEffect, useState } from 'react' +import React, { useEffect, useRef, useState } from 'react' +import { toast } from 'sonner' import { client } from '../client' -import { CandidateAsset, ExtractedAsset, ExtractionStatus } from '../types' -import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from './ui/dialog' -import { cn } from '../lib/utils' +import { CandidateAsset, ExtractedAsset } from '../types' +import { AnalyzeStage } from './extractor/AnalyzeStage' +import { RefineStage } from './extractor/RefineStage' +import { SelectionStage } from './extractor/SelectionStage' +import { UploadStage } from './extractor/UploadStage' +import { Dialog, DialogContent, DialogTitle } from './ui/dialog' interface ExtractorDialogProps { open: boolean @@ -11,81 +14,68 @@ interface ExtractorDialogProps { onSuccess: (assets: ExtractedAsset[]) => void } -interface GridDimensions { - rows: number - cols: number -} +type Stage = 'upload' | 'analyze' | 'selection' | 'refine' export const ExtractorDialog: React.FC = ({ open, onOpenChange, onSuccess, }) => { + const [stage, setStage] = useState('upload') const [file, setFile] = useState(null) const [preview, setPreview] = useState(null) - const [status, setStatus] = useState('idle') const [statusMessage, setStatusMessage] = useState('') - const [error, setError] = useState(null) - // Intermediate candidates + // Data const [candidates, setCandidates] = useState([]) const [selectedIndices, setSelectedIndices] = useState([]) - - // Final results const [results, setResults] = useState([]) + const [isRefineComplete, setIsRefineComplete] = useState(false) - const [textureSheet, setTextureSheet] = useState(null) - const [gridDimensions, setGridDimensions] = useState(null) + // Refs for cleanup + const analyzeController = useRef(null) + const refineController = useRef(null) - const processFile = (f: File) => { - setFile(f) - setPreview(URL.createObjectURL(f)) - setResults([]) + const resetState = () => { + setStage('upload') + setFile(null) + if (preview) URL.revokeObjectURL(preview) + setPreview(null) + setStatusMessage('') setCandidates([]) setSelectedIndices([]) - setError(null) - setStatus('idle') - setTextureSheet(null) - setGridDimensions(null) - } - - const handleFileChange = (e: React.ChangeEvent) => { - if (e.target.files && e.target.files[0]) { - processFile(e.target.files[0]) - } + setResults([]) + setIsRefineComplete(false) + analyzeController.current?.abort() + refineController.current?.abort() } + // Handle dialog close useEffect(() => { - const handlePaste = (e: ClipboardEvent) => { - if (!open) return - if (e.clipboardData && e.clipboardData.files.length > 0) { - const f = e.clipboardData.files[0] - if (f.type.startsWith('image/')) { - processFile(f) - } - } + if (!open) { + // Small delay to allow animation to finish if needed, or just reset immediately + // resetting immediately might flash content if closing animation plays + // But for now, we reset on close to ensure fresh state next open + const t = setTimeout(resetState, 300) + return () => clearTimeout(t) } - - window.addEventListener('paste', handlePaste) - return () => window.removeEventListener('paste', handlePaste) }, [open]) - const handleAnalyze = async () => { + const handleImageUpload = (f: File) => { + setFile(f) + setPreview(URL.createObjectURL(f)) + setStage('analyze') + } + + const startAnalysis = async () => { if (!file) return - setCandidates([]) - setResults([]) - setError(null) - setStatus('analyzing') - setStatusMessage('Starting analysis...') - setTextureSheet(null) - setGridDimensions(null) + setStatusMessage('Initializing analysis...') + analyzeController.current = new AbortController() try { const response = await client.extract.analyze.$post({ - form: { - image: file, - }, + form: { image: file }, }) if (!response.ok) throw new Error('Server error') @@ -113,18 +103,10 @@ export const ExtractorDialog: React.FC = ({ const data = JSON.parse(dataMatch[1]) if (event === 'status') { - setStatus(data.status) setStatusMessage(data.message) - } else if (event === 'texture_generated') { - setTextureSheet(data.image) - setGridDimensions(data.grid) } else if (event === 'complete') { - setStatus('selection') - setStatusMessage('Select items to refine') - const assets = data.assets as CandidateAsset[] - setCandidates(assets) - // Select all by default - setSelectedIndices(assets.map((_, i) => i)) + setCandidates(data.assets as CandidateAsset[]) + setStage('selection') } else if (event === 'error') { throw new Error(data.message) } @@ -135,29 +117,27 @@ export const ExtractorDialog: React.FC = ({ } catch (e: unknown) { console.error(e) const err = e instanceof Error ? e : new Error(String(e)) - setError(err.message || 'Analysis failed') - setStatus('error') + toast.error(err.message || 'Analysis failed') + setStage('upload') } } - const handleRefine = async () => { - if (selectedIndices.length === 0) { - setError('Please select at least one item to refine.') - return - } + const handleRefineConfirm = (indices: number[]) => { + setSelectedIndices(indices) + setStage('refine') + handleRefine(indices) + } - const selectedCandidates = candidates.filter((_, i) => selectedIndices.includes(i)) + const handleRefine = async (indices: number[]) => { + const selectedCandidates = candidates.filter((_, i) => indices.includes(i)) - setStatus('refining') - setStatusMessage('Refining selected assets...') setResults([]) - setError(null) + setIsRefineComplete(false) + setStatusMessage('Starting refinement...') try { const response = await client.extract.refine.$post({ - json: { - assets: selectedCandidates, - }, + json: { assets: selectedCandidates }, }) if (!response.ok) throw new Error('Server error') @@ -185,14 +165,11 @@ export const ExtractorDialog: React.FC = ({ const data = JSON.parse(dataMatch[1]) if (event === 'status') { - setStatus(data.status) setStatusMessage(data.message) } else if (event === 'asset_refined') { setResults((prev) => [...prev, data.asset]) } else if (event === 'complete') { - setStatus('complete') - setStatusMessage('Refinement complete!') - setResults(data.assets) + setIsRefineComplete(true) onSuccess(data.assets) } else if (event === 'error') { throw new Error(data.message) @@ -204,395 +181,53 @@ export const ExtractorDialog: React.FC = ({ } catch (e: unknown) { console.error(e) const err = e instanceof Error ? e : new Error(String(e)) - setError(err.message || 'Refinement failed') - setStatus('error') + toast.error(err.message || 'Refinement failed') + // Don't change stage automatically on error here, let user see what happened or retry? + // For now, staying on refine stage allows them to see partial results. + // But we might want a 'retry' button or back. } } - const toggleSelection = (index: number) => { - setSelectedIndices((prev) => - prev.includes(index) ? prev.filter((i) => i !== index) : [...prev, index], - ) - } - - const handleReset = () => { - setFile(null) - if (preview) URL.revokeObjectURL(preview) - setPreview(null) - setCandidates([]) - setResults([]) - setSelectedIndices([]) - setStatus('idle') - setError(null) - setTextureSheet(null) - setGridDimensions(null) - } - return ( - - - - Equipment Extractor - - - Upload a character image to identify and extract equipment sprites. - - + + Extractor -
- {/* Upload Area */} - {!file ? ( -
- -
- - - -
-

Upload Source

-

- Drop character image to identify and extract equipment sprites. -

- - Select Image - -
- ) : ( -
- {/* Staging Area */} -
-
+ {/* Top Navigation / Branding Bar (Optional, matches App feel) */} +
+
+

Extractor

+
+
-
-
-

- - Staging Area -

-
- -
+ {/* Content Area */} +
+ {stage === 'upload' && } -
- {/* Source Image */} -
-
- -
- - Source - -
-
-
+ {stage === 'analyze' && preview && ( + + )} - {/* Generated Texture Sheet (with Grid Overlay) */} - {textureSheet && gridDimensions && ( -
-
- + {stage === 'selection' && ( + setStage('upload')} + /> + )} - {/* Grid Overlay */} -
- {Array.from({ length: gridDimensions.rows * gridDimensions.cols }).map( - (_, i) => ( -
- {i + 1} -
- ), - )} -
- -
- - Generated Sheet - -
-
-
- )} - - {/* Controls & Status */} -
- {status === 'idle' || status === 'error' ? ( -
-

Ready to Process

-

- The image is ready for analysis. The system will identify equipment pieces - and extract them as individual assets. -

- {error && ( -
- -
- Extraction Failed - {error} -
-
- )} - -
- ) : ( -
- {/* Progress Status */} - {status !== 'selection' && ( -
- -

- {statusMessage} -

-

- Processing... -

-
- )} - - {/* Selection Step Actions */} - {status === 'selection' && ( -
-

- Review & Select -

-

- Select the items you want to keep. They will be refined and added to - your collection. -

- -
- )} -
- )} - -
-

- Powered by Gemini 2.0 Flash -

-
-
-
-
- - {/* Selection Grid */} - {status === 'selection' && candidates.length > 0 && ( -
-
-
-

- ✦ Candidates Found -

-

- Select items to refine -

-
-
- - | - -
-
- -
- {candidates.map((item, i) => { - const isSelected = selectedIndices.includes(i) - return ( -
toggleSelection(i)} - className={cn( - 'group relative rounded-lg border-2 cursor-pointer transition-all duration-300', - isSelected - ? 'bg-stone-50 border-amber-500 shadow-md scale-[1.02]' - : 'bg-white border-stone-100 opacity-60 hover:opacity-100 hover:border-stone-300', - )} - > - {/* Selection Checkbox Overlay */} -
- -
- -
- {/* Checkerboard bg */} -
- -
-
-
- {item.name} -
-
- {item.category} -
-
-
- ) - })} -
-
- )} - - {/* Final Results Grid */} - {(results.length > 0 || status === 'complete') && ( -
-
-
-

- ✦ Loot Acquired -

-

- {results.length} items refined & stashed -

-
-
- -
- {results.map((item, i) => ( -
-
- {/* Checkerboard bg */} -
- - - {/* Download Overlay */} - - - - - -
-
-
- {item.name} -
-
- {item.category} -
-
-
-
- ))} -
-
- )} -
+ {stage === 'refine' && ( + selectedIndices.includes(i))} + results={results} + isComplete={isRefineComplete} + onDone={resetState} + /> )}
diff --git a/src/ui/components/extractor/AnalyzeStage.tsx b/src/ui/components/extractor/AnalyzeStage.tsx new file mode 100644 index 0000000..3c1d857 --- /dev/null +++ b/src/ui/components/extractor/AnalyzeStage.tsx @@ -0,0 +1,89 @@ +import { BrainCircuit, Play } from 'lucide-react' +import React from 'react' +import { Button } from '../ui/button' + +interface AnalyzeStageProps { + imageSrc: string + statusMessage: string + isAnalyzing: boolean + onStart: () => void +} + +export const AnalyzeStage: React.FC = ({ + imageSrc, + statusMessage, + isAnalyzing, + onStart, +}) => { + return ( +
+ {/* Background Frosted Glass Layer */} +
+ + {/* Main Container */} +
+ {/* Image Container with Scanning Effect */} +
+ Analyzing + + {/* Scan Line Animation (only when analyzing) */} + {isAnalyzing && ( +
+
+
+ )} + + {/* Grid Overlay Effect (Subtle) */} +
+ + {/* Corner Brackets UI */} +
+
+
+
+
+ + {/* Status Text or Action Button */} +
+ {isAnalyzing ? ( +
+ + {statusMessage} +
+
+
+
+
+
+ ) : ( + + )} +
+
+ +
+ ) +} diff --git a/src/ui/components/extractor/RefineStage.tsx b/src/ui/components/extractor/RefineStage.tsx new file mode 100644 index 0000000..7cb372a --- /dev/null +++ b/src/ui/components/extractor/RefineStage.tsx @@ -0,0 +1,161 @@ +import { CheckCircle2, Loader2, RefreshCw, Sparkles } from 'lucide-react' +import React from 'react' +import { cn } from '../../lib/utils' +import { CandidateAsset, ExtractedAsset } from '../../types' + +interface RefineStageProps { + selectedCandidates: CandidateAsset[] + results: ExtractedAsset[] + isComplete: boolean + onDone: () => void +} + +export const RefineStage: React.FC = ({ + selectedCandidates, + results, + isComplete, + onDone, +}) => { + return ( +
+ {/* Header */} +
+
+

+ + Refining Assets +

+

+ {isComplete + ? 'All selected assets have been refined and saved.' + : `Processing item ${Math.min(results.length + 1, selectedCandidates.length)} of ${selectedCandidates.length}...`} +

+
+ {isComplete && ( + + )} +
+ + {/* Grid */} +
+
+ {selectedCandidates.map((candidate, index) => { + const isFinished = index < results.length + const isProcessing = index === results.length && !isComplete + const isPending = index > results.length + + // The asset to display: result if finished, candidate base64 otherwise + const displayImage = isFinished ? results[index].imageUrl : candidate.base64 + + return ( +
+ {/* Background Checkerboard */} +
+ + {candidate.name} + + {/* Overlay States */} +
+ {isPending && ( +
+ Pending +
+ )} + + {isProcessing && ( +
+
+
+
+ +
+
+ + Refining... + +
+ )} + + {isFinished && ( +
+
+ +
+
+ )} +
+ + {/* Scanning effect for processing item only */} + {isProcessing && ( +
+
+
+ )} + + {/* Info Bar */} +
+

+ {isFinished ? results[index].name : candidate.name} +

+

+ {candidate.category} +

+
+
+ ) + })} +
+
+ +
+ ) +} diff --git a/src/ui/components/extractor/SelectionStage.tsx b/src/ui/components/extractor/SelectionStage.tsx new file mode 100644 index 0000000..dfac9cd --- /dev/null +++ b/src/ui/components/extractor/SelectionStage.tsx @@ -0,0 +1,139 @@ +import { ArrowRight, Check, Grid2X2 } from 'lucide-react' +import React, { useState } from 'react' +import { cn } from '../../lib/utils' +import { CandidateAsset } from '../../types' + +interface SelectionStageProps { + candidates: CandidateAsset[] + onConfirm: (selectedIndices: number[]) => void + onCancel: () => void +} + +export const SelectionStage: React.FC = ({ + candidates, + onConfirm, + onCancel, +}) => { + // Initialize with all selected by default + const [selectedIndices, setSelectedIndices] = useState(candidates.map((_, i) => i)) + + const toggleSelection = (index: number) => { + setSelectedIndices((prev) => + prev.includes(index) ? prev.filter((i) => i !== index) : [...prev, index], + ) + } + + const handleConfirm = () => { + if (selectedIndices.length === 0) return + onConfirm(selectedIndices) + } + + return ( +
+ {/* Header */} +
+
+

+ + Select Candidates +

+

+ Unselect any items you wish to exclude from refinement. +

+
+
+ {selectedIndices.length} + Selected +
+
+ + {/* Grid */} +
+
+ {candidates.map((item, index) => { + const isSelected = selectedIndices.includes(index) + return ( +
toggleSelection(index)} + className={cn( + 'group relative w-[140px] h-[140px] rounded-xl overflow-hidden cursor-pointer transition-all duration-300 border-2 bg-white animate-fade-in-up flex-shrink-0', + isSelected + ? 'border-cyan-500 shadow-lg shadow-cyan-100 scale-[1.02]' + : 'border-slate-200 opacity-80 hover:opacity-100 hover:border-slate-300', + )} + style={{ animationDelay: `${index * 50}ms` }} + > + {/* Checkerboard bg */} +
+ + {item.name} + + {/* Selection Overlay */} +
+ + {/* Checkbox Indicator */} +
+ +
+ + {/* Label */} +
+

{item.name}

+

{item.category}

+
+
+ ) + })} +
+
+ + {/* Footer Actions */} +
+
+ + +
+
+
+ ) +} diff --git a/src/ui/components/extractor/UploadStage.tsx b/src/ui/components/extractor/UploadStage.tsx new file mode 100644 index 0000000..0e7a85e --- /dev/null +++ b/src/ui/components/extractor/UploadStage.tsx @@ -0,0 +1,123 @@ +import { Image as ImageIcon, Upload } from 'lucide-react' +import React, { useCallback, useState } from 'react' +import { cn } from '../../lib/utils' + +interface UploadStageProps { + onImageUpload: (file: File) => void +} + +export const UploadStage: React.FC = ({ onImageUpload }) => { + const [isDragging, setIsDragging] = useState(false) + + const handleDragOver = useCallback((e: React.DragEvent) => { + e.preventDefault() + setIsDragging(true) + }, []) + + const handleDragLeave = useCallback((e: React.DragEvent) => { + e.preventDefault() + setIsDragging(false) + }, []) + + const handleDrop = useCallback( + (e: React.DragEvent) => { + e.preventDefault() + setIsDragging(false) + + if (e.dataTransfer.files && e.dataTransfer.files[0]) { + const file = e.dataTransfer.files[0] + if (file.type.startsWith('image/')) { + onImageUpload(file) + } + } + }, + [onImageUpload], + ) + + const handleFileChange = useCallback( + (e: React.ChangeEvent) => { + if (e.target.files && e.target.files[0]) { + onImageUpload(e.target.files[0]) + } + }, + [onImageUpload], + ) + + React.useEffect(() => { + const handlePaste = (e: ClipboardEvent) => { + if (e.clipboardData && e.clipboardData.files.length > 0) { + const file = e.clipboardData.files[0] + if (file.type.startsWith('image/')) { + onImageUpload(file) + } + } + } + + window.addEventListener('paste', handlePaste) + return () => window.removeEventListener('paste', handlePaste) + }, [onImageUpload]) + + return ( +
+
document.getElementById('file-upload')?.click()} + > +
+
+ +
+
+

+ Upload an Image +

+

+ Drag and drop your image here, or click to browse files +

+
+
+ + + Select File + +
+
+ +
+

Supports JPG, PNG, WEBP up to 10MB

+
+ ) +}