add demo ui
This commit is contained in:
1 parent
2c6bf14d69
commit
4615a075b1
16 files changed
+1534
No files matched your search
@@ -0,0 +1,24 @@
|
||||
# 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?
|
||||
@@ -0,0 +1,173 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { ExtractionStatus, StashItem, CroppedAsset } from './types';
|
||||
import { LoadingOverlay } from './components/LoadingOverlay';
|
||||
import { Extractor } from './components/Extractor';
|
||||
import { Stash } from './components/Stash';
|
||||
import { Modifier } from './components/Modifier';
|
||||
|
||||
export default function App() {
|
||||
const [view, setView] = useState<'extractor' | 'stash' | 'modifier'>('extractor');
|
||||
const [status, setStatus] = useState<ExtractionStatus>('idle');
|
||||
const [loadingCurrent, setLoadingCurrent] = useState(0);
|
||||
const [loadingTotal, setLoadingTotal] = useState(0);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [stash, setStash] = useState<StashItem[]>([]);
|
||||
const [apiKeySelected, setApiKeySelected] = useState<boolean>(false);
|
||||
const [notification, setNotification] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
checkApiKey();
|
||||
loadStash();
|
||||
}, []);
|
||||
|
||||
const checkApiKey = async () => {
|
||||
const aistudio = (window as any).aistudio;
|
||||
if (aistudio?.hasSelectedApiKey) {
|
||||
const hasKey = await aistudio.hasSelectedApiKey();
|
||||
setApiKeySelected(hasKey);
|
||||
} else {
|
||||
setApiKeySelected(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectKey = async () => {
|
||||
const aistudio = (window as any).aistudio;
|
||||
if (aistudio?.openSelectKey) {
|
||||
await aistudio.openSelectKey();
|
||||
setApiKeySelected(true);
|
||||
}
|
||||
};
|
||||
|
||||
const loadStash = () => {
|
||||
try {
|
||||
// Changed key to reflect new app name
|
||||
const saved = localStorage.getItem('forgery_stash');
|
||||
if (saved) setStash(JSON.parse(saved));
|
||||
} catch (e) {
|
||||
console.error("Failed to load stash", e);
|
||||
}
|
||||
};
|
||||
|
||||
const saveStash = (items: StashItem[]) => {
|
||||
setStash(items);
|
||||
localStorage.setItem('forgery_stash', JSON.stringify(items));
|
||||
};
|
||||
|
||||
const handleAddToStash = (assets: CroppedAsset[]) => {
|
||||
const newItems: StashItem[] = assets.map(asset => ({
|
||||
id: Math.random().toString(36).substr(2, 9),
|
||||
item_name: asset.item_name,
|
||||
imageUrl: asset.refinedImageUrl || asset.imageUrl,
|
||||
createdAt: Date.now()
|
||||
}));
|
||||
saveStash([...newItems, ...stash]);
|
||||
showNotification("Loot successfully stashed!");
|
||||
};
|
||||
|
||||
const showNotification = (msg: string) => {
|
||||
setNotification(msg);
|
||||
setTimeout(() => setNotification(null), 3000);
|
||||
};
|
||||
|
||||
const handleStatusChange = (newStatus: ExtractionStatus, current?: number, total?: number) => {
|
||||
setStatus(newStatus);
|
||||
if (current !== undefined) setLoadingCurrent(current);
|
||||
if (total !== undefined) setLoadingTotal(total);
|
||||
};
|
||||
|
||||
const NavButton = ({ target, label, color, icon }: any) => (
|
||||
<button
|
||||
onClick={() => setView(target)}
|
||||
className={`px-4 py-2 rounded-lg font-bold text-sm uppercase tracking-wider flex items-center gap-2 transition-all ${
|
||||
view === target
|
||||
? `${color} text-white shadow-md transform -translate-y-0.5`
|
||||
: 'bg-white text-stone-500 hover:bg-stone-50'
|
||||
}`}
|
||||
>
|
||||
<span>{icon}</span> {label}
|
||||
{target === 'stash' && stash.length > 0 && (
|
||||
<span className="bg-stone-800 text-white text-[10px] px-1.5 py-0.5 rounded-full ml-1">{stash.length}</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen text-stone-800 font-sans p-4 md:p-8">
|
||||
{/* Notifications */}
|
||||
{notification && (
|
||||
<div className="fixed bottom-8 right-8 z-50 bg-stone-800 text-white px-6 py-4 rounded-xl shadow-2xl flex items-center gap-3 animate-fade-in-up border border-stone-700">
|
||||
<div className="bg-green-500 rounded-full p-1"><svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" /></svg></div>
|
||||
<span className="font-bold tracking-wide">{notification}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Global Error */}
|
||||
{error && (
|
||||
<div className="fixed top-8 left-1/2 -translate-x-1/2 z-50 bg-red-100 border-2 border-red-500 text-red-800 px-6 py-4 rounded-xl shadow-xl flex items-center gap-4 animate-fade-in-up">
|
||||
<span className="font-bold">{error}</span>
|
||||
<button onClick={() => setError(null)} className="text-red-500 hover:text-red-900 font-black">X</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Loading */}
|
||||
{(status !== 'idle' && status !== 'complete' && status !== 'error') && (
|
||||
<LoadingOverlay status={status} current={loadingCurrent} total={loadingTotal} />
|
||||
)}
|
||||
|
||||
<header className="max-w-7xl mx-auto mb-10 flex flex-col md:flex-row justify-between items-center gap-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-12 h-12 bg-stone-800 rounded-xl shadow-lg flex items-center justify-center text-amber-500">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-7 w-7" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" /></svg>
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-3xl font-black text-stone-800 uppercase tracking-tighter">Forgery</h1>
|
||||
<p className="text-stone-400 text-xs font-bold tracking-[0.2em] uppercase">Digital Asset Synthesis</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 bg-stone-200/50 p-2 rounded-xl backdrop-blur-sm">
|
||||
<NavButton target="extractor" label="Extractor" color="bg-amber-500" icon="⛏" />
|
||||
<NavButton target="stash" label="Stash" color="bg-cyan-600" icon="❖" />
|
||||
<NavButton target="modifier" label="Smithy" color="bg-purple-600" icon="⚒" />
|
||||
|
||||
{!apiKeySelected && (window as any).aistudio && (
|
||||
<button onClick={handleSelectKey} className="ml-2 px-3 py-1 bg-red-500 text-white text-xs font-bold rounded hover:bg-red-600 uppercase tracking-wide">
|
||||
Key Required
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="max-w-7xl mx-auto">
|
||||
{view === 'extractor' && (
|
||||
<Extractor
|
||||
onStatusChange={handleStatusChange}
|
||||
onError={setError}
|
||||
onAssetsExtracted={() => {}}
|
||||
apiKeyValid={apiKeySelected}
|
||||
onConnectKey={handleSelectKey}
|
||||
onSaveToStash={handleAddToStash}
|
||||
/>
|
||||
)}
|
||||
{view === 'stash' && (
|
||||
<Stash
|
||||
items={stash}
|
||||
onRemove={(id) => saveStash(stash.filter(i => i.id !== id))}
|
||||
onClear={() => saveStash([])}
|
||||
onNavigateToExtractor={() => setView('extractor')}
|
||||
/>
|
||||
)}
|
||||
{view === 'modifier' && (
|
||||
<Modifier
|
||||
stash={stash}
|
||||
onStatusChange={handleStatusChange}
|
||||
onError={setError}
|
||||
apiKeyValid={apiKeySelected}
|
||||
onConnectKey={handleSelectKey}
|
||||
onNavigateToExtractor={() => setView('extractor')}
|
||||
/>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<div align="center">
|
||||
<img width="1200" height="475" alt="GHBanner" src="https://github.com/user-attachments/assets/0aa67016-6eaf-458a-adb2-6e31a0763ed6" />
|
||||
</div>
|
||||
|
||||
# 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/19Xb6Drl-US_jUxV3S8ruwCrcP6C8ZUHh
|
||||
|
||||
## 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`
|
||||
@@ -0,0 +1,89 @@
|
||||
import React from 'react';
|
||||
import { Asset, HEX_COLORS } from '../types';
|
||||
|
||||
interface AssetListProps {
|
||||
assets: Asset[];
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
selectedModel: string;
|
||||
onModelChange: (model: string) => void;
|
||||
}
|
||||
|
||||
export const AssetList: React.FC<AssetListProps> = ({ assets, onConfirm, onCancel, selectedModel, onModelChange }) => {
|
||||
return (
|
||||
<div className="bg-slate-800 rounded-xl p-6 shadow-2xl border border-slate-700 max-w-4xl mx-auto w-full animate-fade-in-up">
|
||||
<div className="flex justify-between items-end mb-6">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-white">Identified Assets</h2>
|
||||
<p className="text-slate-400 mt-1">Review the items detected for extraction.</p>
|
||||
</div>
|
||||
<span className="bg-cyan-900/50 text-cyan-300 px-3 py-1 rounded-full text-sm font-medium border border-cyan-700/50">
|
||||
{assets.length} Items Found
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-8 max-h-[400px] overflow-y-auto pr-2 custom-scrollbar">
|
||||
{assets.map((asset, idx) => (
|
||||
<div key={idx} className="bg-slate-900/50 p-4 rounded-lg border border-slate-700 hover:border-slate-600 transition-colors flex gap-4">
|
||||
<div
|
||||
className="w-12 h-12 rounded-full flex-shrink-0 shadow-inner"
|
||||
style={{ backgroundColor: HEX_COLORS[asset.background_color] || asset.background_color }}
|
||||
title={`Background: ${asset.background_color}`}
|
||||
/>
|
||||
<div>
|
||||
<h3 className="font-semibold text-slate-100">{asset.item_name}</h3>
|
||||
<p className="text-sm text-slate-400 mt-1 leading-relaxed">{asset.description}</p>
|
||||
<div className="mt-2 text-xs font-mono text-slate-500">
|
||||
Target Key: {asset.background_color}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Model Selection */}
|
||||
<div className="mb-6 bg-slate-900/30 p-4 rounded-lg border border-slate-700/50">
|
||||
<h3 className="text-sm font-semibold text-slate-300 mb-3 uppercase tracking-wider">Extraction Model</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<label className={`relative p-3 rounded-lg border cursor-pointer transition-all ${selectedModel === 'gemini-3-pro-image-preview' ? 'bg-cyan-900/20 border-cyan-500/70 shadow-[0_0_15px_-3px_rgba(6,182,212,0.3)]' : 'bg-slate-800 border-slate-700 hover:border-slate-600'}`}>
|
||||
<div className="flex items-center gap-3 mb-1">
|
||||
<div className={`w-4 h-4 rounded-full border flex items-center justify-center ${selectedModel === 'gemini-3-pro-image-preview' ? 'border-cyan-500' : 'border-slate-500'}`}>
|
||||
{selectedModel === 'gemini-3-pro-image-preview' && <div className="w-2 h-2 rounded-full bg-cyan-500" />}
|
||||
</div>
|
||||
<input type="radio" name="model" value="gemini-3-pro-image-preview" checked={selectedModel === 'gemini-3-pro-image-preview'} onChange={(e) => onModelChange(e.target.value)} className="hidden" />
|
||||
<span className={`font-bold ${selectedModel === 'gemini-3-pro-image-preview' ? 'text-cyan-400' : 'text-slate-300'}`}>Gemini 3 Pro</span>
|
||||
<span className="text-[10px] font-bold bg-cyan-500/20 text-cyan-300 px-1.5 py-0.5 rounded ml-auto">RECOMMENDED</span>
|
||||
</div>
|
||||
<p className="text-xs text-slate-400 pl-7">Best quality, 2K resolution support, precise geometry.</p>
|
||||
</label>
|
||||
|
||||
<label className={`relative p-3 rounded-lg border cursor-pointer transition-all ${selectedModel === 'gemini-2.5-flash-image' ? 'bg-amber-900/20 border-amber-500/70 shadow-[0_0_15px_-3px_rgba(245,158,11,0.3)]' : 'bg-slate-800 border-slate-700 hover:border-slate-600'}`}>
|
||||
<div className="flex items-center gap-3 mb-1">
|
||||
<div className={`w-4 h-4 rounded-full border flex items-center justify-center ${selectedModel === 'gemini-2.5-flash-image' ? 'border-amber-500' : 'border-slate-500'}`}>
|
||||
{selectedModel === 'gemini-2.5-flash-image' && <div className="w-2 h-2 rounded-full bg-amber-500" />}
|
||||
</div>
|
||||
<input type="radio" name="model" value="gemini-2.5-flash-image" checked={selectedModel === 'gemini-2.5-flash-image'} onChange={(e) => onModelChange(e.target.value)} className="hidden" />
|
||||
<span className={`font-bold ${selectedModel === 'gemini-2.5-flash-image' ? 'text-amber-400' : 'text-slate-300'}`}>Gemini 2.5 Flash</span>
|
||||
</div>
|
||||
<p className="text-xs text-slate-400 pl-7">Fast generation, standard resolution, experimental for extraction.</p>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 pt-4 border-t border-slate-700">
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="px-6 py-2 rounded-lg text-slate-300 hover:bg-slate-700 hover:text-white transition-colors font-medium"
|
||||
>
|
||||
Discard
|
||||
</button>
|
||||
<button
|
||||
onClick={onConfirm}
|
||||
className="px-6 py-2 rounded-lg bg-gradient-to-r from-cyan-600 to-blue-600 hover:from-cyan-500 hover:to-blue-500 text-white font-bold shadow-lg shadow-cyan-900/20 transform hover:-translate-y-0.5 transition-all"
|
||||
>
|
||||
Generate Texture Sheet
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,277 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { analyzeImageForAssets, generateTextureSheet, detectAssetBoundingBoxes, cropAssetsFromSheet, refineAssetImage } from '../services/geminiService';
|
||||
import { AnalysisResponse, CroppedAsset, ExtractionStatus } from '../types';
|
||||
|
||||
interface ExtractorProps {
|
||||
onStatusChange: (status: ExtractionStatus, index?: number, total?: number) => void;
|
||||
onError: (msg: string | null) => void;
|
||||
onAssetsExtracted: (assets: CroppedAsset[]) => void;
|
||||
apiKeyValid: boolean;
|
||||
onConnectKey: () => void;
|
||||
onSaveToStash: (assets: CroppedAsset[]) => void;
|
||||
}
|
||||
|
||||
export const Extractor: React.FC<ExtractorProps> = ({
|
||||
onStatusChange,
|
||||
onError,
|
||||
onAssetsExtracted,
|
||||
apiKeyValid,
|
||||
onConnectKey,
|
||||
onSaveToStash
|
||||
}) => {
|
||||
const [files, setFiles] = useState<File[]>([]);
|
||||
const [previews, setPreviews] = useState<string[]>([]);
|
||||
const [results, setResults] = useState<CroppedAsset[]>([]);
|
||||
const [selectedSourceIdx, setSelectedSourceIdx] = useState<number | null>(null);
|
||||
|
||||
// Handle Paste Events
|
||||
useEffect(() => {
|
||||
const handlePaste = (e: ClipboardEvent) => {
|
||||
if (e.clipboardData && e.clipboardData.items) {
|
||||
const items = e.clipboardData.items;
|
||||
const newFiles: File[] = [];
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
if (items[i].type.indexOf("image") !== -1) {
|
||||
const blob = items[i].getAsFile();
|
||||
if (blob) {
|
||||
newFiles.push(blob);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (newFiles.length > 0) {
|
||||
addFiles(newFiles);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('paste', handlePaste);
|
||||
return () => {
|
||||
window.removeEventListener('paste', handlePaste);
|
||||
};
|
||||
}, []); // Empty dependency array means this runs once on mount and cleans up on unmount
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.files) addFiles(Array.from(e.target.files));
|
||||
};
|
||||
|
||||
const addFiles = (newFiles: File[]) => {
|
||||
setFiles(prev => [...prev, ...newFiles]);
|
||||
setPreviews(prev => [...prev, ...newFiles.map(f => URL.createObjectURL(f))]);
|
||||
setResults([]); // Clear results on new upload
|
||||
onError(null);
|
||||
};
|
||||
|
||||
const removeFile = (idx: number) => {
|
||||
const newFiles = [...files];
|
||||
const newPreviews = [...previews];
|
||||
URL.revokeObjectURL(newPreviews[idx]);
|
||||
newFiles.splice(idx, 1);
|
||||
newPreviews.splice(idx, 1);
|
||||
setFiles(newFiles);
|
||||
setPreviews(newPreviews);
|
||||
};
|
||||
|
||||
const handleExtract = async () => {
|
||||
if (files.length === 0) return;
|
||||
if (!apiKeyValid) {
|
||||
onConnectKey();
|
||||
return;
|
||||
}
|
||||
|
||||
setResults([]);
|
||||
onError(null);
|
||||
const allAssets: CroppedAsset[] = [];
|
||||
|
||||
try {
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
onStatusChange('analyzing', i + 1, files.length);
|
||||
|
||||
// 1. Analyze
|
||||
let analysis: AnalysisResponse;
|
||||
try {
|
||||
analysis = await analyzeImageForAssets(files[i]);
|
||||
} catch (e) {
|
||||
console.error(`Analysis failed for ${files[i].name}`, e);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (analysis.assets.length === 0) continue;
|
||||
|
||||
// 2. Generate Sheet
|
||||
onStatusChange('generating', i + 1, files.length);
|
||||
const sheetUrl = await generateTextureSheet(files[i], analysis.assets);
|
||||
|
||||
// 3. Slice
|
||||
onStatusChange('splitting', i + 1, files.length);
|
||||
const base64 = sheetUrl.split(',')[1];
|
||||
const boxes = await detectAssetBoundingBoxes(base64);
|
||||
const cropped = await cropAssetsFromSheet(sheetUrl, boxes.assets);
|
||||
|
||||
// 4. Refine
|
||||
onStatusChange('refining', i + 1, files.length);
|
||||
const refined = await Promise.all(cropped.map(async (a) => {
|
||||
try {
|
||||
const refinedUrl = await refineAssetImage(a.imageUrl);
|
||||
return { ...a, refinedImageUrl: refinedUrl, sourceIndex: i };
|
||||
} catch {
|
||||
return { ...a, sourceIndex: i };
|
||||
}
|
||||
}));
|
||||
|
||||
allAssets.push(...refined);
|
||||
}
|
||||
|
||||
setResults(allAssets);
|
||||
onAssetsExtracted(allAssets);
|
||||
onStatusChange('complete');
|
||||
if (allAssets.length > 0) setSelectedSourceIdx(0);
|
||||
|
||||
} catch (e: any) {
|
||||
console.error(e);
|
||||
onError(e.message || "Extraction failed");
|
||||
onStatusChange('error');
|
||||
}
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setFiles([]);
|
||||
previews.forEach(p => URL.revokeObjectURL(p));
|
||||
setPreviews([]);
|
||||
setResults([]);
|
||||
onStatusChange('idle');
|
||||
};
|
||||
|
||||
if (results.length > 0) {
|
||||
const filtered = selectedSourceIdx !== null
|
||||
? results.filter(r => r.sourceIndex === selectedSourceIdx)
|
||||
: results;
|
||||
|
||||
return (
|
||||
<div className="animate-fade-in-up space-y-8">
|
||||
{/* Source Selection Strip */}
|
||||
<div className="bg-white p-4 rounded-xl border border-stone-200 shadow-sm">
|
||||
<h3 className="text-xs font-bold text-stone-400 uppercase tracking-widest mb-3">Source References</h3>
|
||||
<div className="flex gap-3 overflow-x-auto pb-2 custom-scrollbar">
|
||||
{previews.map((src, idx) => (
|
||||
<button
|
||||
key={idx}
|
||||
onClick={() => setSelectedSourceIdx(idx === selectedSourceIdx ? null : idx)}
|
||||
className={`relative w-16 h-20 flex-shrink-0 rounded-lg overflow-hidden border-2 transition-all ${idx === selectedSourceIdx ? 'border-amber-500 shadow-md scale-105' : 'border-stone-200 opacity-60 hover:opacity-100'}`}
|
||||
>
|
||||
<img src={src} className="w-full h-full object-cover" />
|
||||
{idx === selectedSourceIdx && <div className="absolute inset-0 bg-amber-500/10" />}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Results Grid */}
|
||||
<div className="bg-white p-6 rounded-xl border border-stone-200 shadow-lg">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<div>
|
||||
<h2 className="text-2xl font-black text-stone-800 flex items-center gap-2">
|
||||
<span className="text-amber-500">✦</span> LOOT ACQUIRED
|
||||
</h2>
|
||||
<p className="text-stone-500 text-sm font-medium">{filtered.length} items found</p>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={() => onSaveToStash(results)}
|
||||
className="px-5 py-2.5 bg-cyan-600 hover:bg-cyan-500 text-white text-sm font-bold uppercase tracking-wider rounded-lg shadow-lg hover:-translate-y-0.5 transition-all flex items-center gap-2"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 5a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 21V5z" /></svg>
|
||||
Stash All
|
||||
</button>
|
||||
<button onClick={handleReset} className="px-5 py-2.5 text-stone-500 font-bold uppercase text-sm hover:bg-stone-100 rounded-lg transition-colors">
|
||||
Reset
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
|
||||
{filtered.map((item, i) => (
|
||||
<div key={i} className="group relative bg-stone-50 rounded-lg border-2 border-stone-200 hover:border-amber-400 hover:shadow-lg transition-all duration-300">
|
||||
<div className="aspect-square p-4 flex items-center justify-center relative overflow-hidden rounded-t-lg">
|
||||
{/* Checkerboard bg */}
|
||||
<div className="absolute inset-0 opacity-5" style={{backgroundImage: 'radial-gradient(#000 1px, transparent 1px)', backgroundSize: '8px 8px'}}></div>
|
||||
<img src={item.refinedImageUrl || item.imageUrl} className="max-w-full max-h-full object-contain drop-shadow-md group-hover:scale-110 transition-transform" />
|
||||
|
||||
{/* Download Overlay */}
|
||||
<a
|
||||
href={item.refinedImageUrl || item.imageUrl}
|
||||
download={`${item.item_name}.png`}
|
||||
className="absolute inset-0 bg-stone-900/40 opacity-0 group-hover:opacity-100 flex items-center justify-center transition-opacity backdrop-blur-[1px]"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-8 w-8 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" /></svg>
|
||||
</a>
|
||||
</div>
|
||||
<div className="p-3 bg-white border-t border-stone-200 rounded-b-lg">
|
||||
<div className="text-xs font-bold text-stone-700 truncate">{item.item_name}</div>
|
||||
<div className="w-6 h-1 bg-amber-400 rounded-full mt-2"></div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Initial State
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto animate-fade-in-up">
|
||||
{files.length === 0 ? (
|
||||
<div className="border-4 border-dashed border-stone-300 bg-white rounded-3xl p-12 text-center hover:border-cyan-400 hover:bg-cyan-50/30 transition-all group">
|
||||
<div className="w-20 h-20 bg-cyan-100 text-cyan-600 rounded-full flex items-center justify-center mx-auto mb-6 group-hover:scale-110 transition-transform shadow-inner">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-10 w-10" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" /></svg>
|
||||
</div>
|
||||
<h2 className="text-2xl font-black text-stone-800 mb-2">UPLOAD SOURCES</h2>
|
||||
<p className="text-stone-500 mb-4 max-w-sm mx-auto">Drop character images to identify and extract equipment sprites.</p>
|
||||
<div className="inline-block bg-cyan-50 border border-cyan-100 text-cyan-700 text-xs font-mono py-1 px-3 rounded mb-8">
|
||||
Paste (Ctrl+V) Supported
|
||||
</div>
|
||||
<div className="block">
|
||||
<label className="inline-block cursor-pointer">
|
||||
<input type="file" multiple accept="image/*" onChange={handleFileChange} className="hidden" />
|
||||
<span className="px-8 py-4 bg-stone-900 text-white font-bold uppercase tracking-wider rounded-xl shadow-xl hover:bg-stone-800 transition-colors">Select Images</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-white rounded-2xl shadow-xl border border-stone-200 p-6">
|
||||
<div className="flex justify-between items-center mb-6 pb-6 border-b border-stone-100">
|
||||
<div>
|
||||
<h3 className="font-bold text-stone-700 uppercase tracking-wider">Staging Area ({files.length})</h3>
|
||||
<span className="text-[10px] text-stone-400 font-mono">Drag, Drop, or Paste more</span>
|
||||
</div>
|
||||
<label className="text-cyan-600 font-bold text-sm cursor-pointer hover:text-cyan-700 uppercase tracking-wide">
|
||||
+ Add More
|
||||
<input type="file" multiple accept="image/*" onChange={handleFileChange} className="hidden" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-4 gap-4 mb-8">
|
||||
{previews.map((src, i) => (
|
||||
<div key={i} className="relative group aspect-square rounded-lg overflow-hidden border border-stone-200">
|
||||
<img src={src} className="w-full h-full object-cover" />
|
||||
<button onClick={() => removeFile(i)} className="absolute top-1 right-1 bg-red-500 text-white p-1 rounded-md opacity-0 group-hover:opacity-100 transition-opacity shadow-sm">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-3 w-3" viewBox="0 0 20 20" fill="currentColor"><path fillRule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clipRule="evenodd" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleExtract}
|
||||
className="w-full py-4 bg-gradient-to-r from-amber-500 to-orange-600 text-white font-black uppercase tracking-widest text-lg rounded-xl shadow-lg hover:shadow-xl hover:-translate-y-1 transition-all flex items-center justify-center gap-3"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6 animate-pulse" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" /></svg>
|
||||
Begin Extraction
|
||||
</button>
|
||||
<p className="text-center text-xs text-stone-400 mt-4 font-mono">POWERED BY GEMINI 2.5 FLASH</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,71 @@
|
||||
import React from 'react';
|
||||
|
||||
interface LoadingOverlayProps {
|
||||
status: string;
|
||||
current?: number;
|
||||
total?: number;
|
||||
}
|
||||
|
||||
export const LoadingOverlay: React.FC<LoadingOverlayProps> = ({ status, current, total }) => {
|
||||
let title = 'PROCESSING';
|
||||
let description = 'Please wait...';
|
||||
|
||||
switch (status) {
|
||||
case 'analyzing':
|
||||
title = 'SCANNING ASSETS';
|
||||
description = 'Identifying equipment geometry...';
|
||||
break;
|
||||
case 'generating':
|
||||
title = 'FORGING TEXTURES';
|
||||
description = 'Creating ghost mannequin sprites...';
|
||||
break;
|
||||
case 'splitting':
|
||||
title = 'CUTTING SPRITES';
|
||||
description = 'Slicing texture sheet boundaries...';
|
||||
break;
|
||||
case 'refining':
|
||||
title = 'POLISHING';
|
||||
description = 'Refining edges and normalizing...';
|
||||
break;
|
||||
case 'modifying':
|
||||
title = 'SYNTHESIZING';
|
||||
description = 'Equipping character with new gear...';
|
||||
break;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-stone-900/60 backdrop-blur-md flex flex-col items-center justify-center z-50">
|
||||
<div className="bg-white p-8 rounded-2xl shadow-2xl border-4 border-stone-200 max-w-sm w-full text-center relative overflow-hidden">
|
||||
{/* Top Decoration */}
|
||||
<div className="absolute top-0 left-0 w-full h-2 bg-gradient-to-r from-amber-400 via-orange-500 to-amber-400"></div>
|
||||
|
||||
<div className="relative w-20 h-20 mx-auto mb-6">
|
||||
<div className="absolute inset-0 border-4 border-t-amber-500 border-r-stone-200 border-b-stone-200 border-l-stone-200 rounded-full animate-spin"></div>
|
||||
<div className="absolute inset-3 border-4 border-t-cyan-500 border-r-transparent border-b-transparent border-l-transparent rounded-full animate-spin-reverse"></div>
|
||||
</div>
|
||||
|
||||
<h2 className="text-xl font-black text-stone-800 mb-2 uppercase tracking-widest font-mono">
|
||||
{title}
|
||||
</h2>
|
||||
<p className="text-stone-500 text-sm font-medium">
|
||||
{description}
|
||||
</p>
|
||||
|
||||
{total && total > 1 && (
|
||||
<div className="mt-6">
|
||||
<div className="flex justify-between text-xs font-bold text-stone-400 mb-1 uppercase">
|
||||
<span>Progress</span>
|
||||
<span>{current} / {total}</span>
|
||||
</div>
|
||||
<div className="h-2 bg-stone-100 rounded-full overflow-hidden border border-stone-200">
|
||||
<div
|
||||
className="h-full bg-cyan-500 transition-all duration-300"
|
||||
style={{ width: `${(current! / total) * 100}%` }}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,197 @@
|
||||
import React, { useState } from 'react';
|
||||
import { generateModifiedCharacter } from '../services/geminiService';
|
||||
import { StashItem, ExtractionStatus } from '../types';
|
||||
|
||||
interface ModifierProps {
|
||||
stash: StashItem[];
|
||||
onStatusChange: (status: ExtractionStatus) => void;
|
||||
onError: (msg: string | null) => void;
|
||||
apiKeyValid: boolean;
|
||||
onConnectKey: () => void;
|
||||
onNavigateToExtractor: () => void;
|
||||
}
|
||||
|
||||
export const Modifier: React.FC<ModifierProps> = ({
|
||||
stash,
|
||||
onStatusChange,
|
||||
onError,
|
||||
apiKeyValid,
|
||||
onConnectKey,
|
||||
onNavigateToExtractor
|
||||
}) => {
|
||||
const [baseFile, setBaseFile] = useState<File | null>(null);
|
||||
const [basePreview, setBasePreview] = useState<string | null>(null);
|
||||
const [selectedItemIds, setSelectedItemIds] = useState<Set<string>>(new Set());
|
||||
const [resultUrl, setResultUrl] = useState<string | null>(null);
|
||||
const [selectedModel, setSelectedModel] = useState<string>('gemini-3-pro-image-preview');
|
||||
|
||||
const handleBaseChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.files && e.target.files[0]) {
|
||||
const f = e.target.files[0];
|
||||
setBaseFile(f);
|
||||
setBasePreview(URL.createObjectURL(f));
|
||||
setResultUrl(null);
|
||||
onError(null);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleItem = (id: string) => {
|
||||
const next = new Set(selectedItemIds);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
setSelectedItemIds(next);
|
||||
};
|
||||
|
||||
const handleForge = async () => {
|
||||
if (!baseFile || selectedItemIds.size === 0) return;
|
||||
if (!apiKeyValid) {
|
||||
onConnectKey();
|
||||
return;
|
||||
}
|
||||
|
||||
onStatusChange('modifying');
|
||||
setResultUrl(null);
|
||||
onError(null);
|
||||
|
||||
try {
|
||||
const items = stash.filter(i => selectedItemIds.has(i.id));
|
||||
const url = await generateModifiedCharacter(baseFile, items, selectedModel);
|
||||
setResultUrl(url);
|
||||
onStatusChange('idle');
|
||||
} catch (e: any) {
|
||||
console.error(e);
|
||||
onError(e.message || "Modification failed");
|
||||
onStatusChange('error');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="animate-fade-in-up pb-20">
|
||||
<div className="mb-8">
|
||||
<h2 className="text-3xl font-black text-stone-800 flex items-center gap-3">
|
||||
<span className="text-purple-600">⚒</span> SMITHY
|
||||
</h2>
|
||||
<p className="text-stone-500 font-medium">Equip your characters with extracted assets.</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||
{/* LEFT: Base Character */}
|
||||
<div className="bg-white rounded-xl border border-stone-200 shadow-lg p-5 flex flex-col h-fit">
|
||||
<h3 className="text-xs font-bold text-stone-400 uppercase tracking-widest mb-4">Base Character</h3>
|
||||
|
||||
<div className="aspect-[3/4] bg-stone-100 rounded-lg border-2 border-dashed border-stone-300 overflow-hidden relative flex flex-col items-center justify-center hover:border-purple-400 transition-colors">
|
||||
{basePreview ? (
|
||||
<>
|
||||
<img src={basePreview} className="w-full h-full object-contain" />
|
||||
<button
|
||||
onClick={() => { setBaseFile(null); setBasePreview(null); }}
|
||||
className="absolute top-2 right-2 bg-white/90 text-stone-600 hover:text-red-500 p-1 rounded-full shadow-sm"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" viewBox="0 0 20 20" fill="currentColor"><path fillRule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clipRule="evenodd" /></svg>
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<label className="cursor-pointer flex flex-col items-center p-6 text-center w-full h-full justify-center">
|
||||
<input type="file" accept="image/*" onChange={handleBaseChange} className="hidden" />
|
||||
<div className="w-12 h-12 bg-purple-100 text-purple-600 rounded-full flex items-center justify-center mb-3">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" /></svg>
|
||||
</div>
|
||||
<span className="text-sm font-bold text-stone-600 uppercase">Upload Character</span>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Model Selector */}
|
||||
<div className="mt-6 bg-stone-50 p-4 rounded-lg border border-stone-200">
|
||||
<h3 className="text-xs font-bold text-stone-400 uppercase tracking-widest mb-3">Forge Model</h3>
|
||||
<div className="space-y-2">
|
||||
<label className={`flex items-center gap-3 p-3 rounded-lg border cursor-pointer transition-all ${selectedModel === 'gemini-3-pro-image-preview' ? 'bg-white border-purple-500 shadow-sm' : 'border-stone-200 hover:bg-white'}`}>
|
||||
<input type="radio" name="model" value="gemini-3-pro-image-preview" checked={selectedModel === 'gemini-3-pro-image-preview'} onChange={(e) => setSelectedModel(e.target.value)} className="w-4 h-4 text-purple-600 accent-purple-600" />
|
||||
<div>
|
||||
<div className="font-bold text-sm text-stone-800 flex items-center gap-2">
|
||||
Banana Pro
|
||||
<span className="text-[10px] bg-purple-100 text-purple-600 px-1.5 rounded">High Quality</span>
|
||||
</div>
|
||||
<div className="text-[10px] text-stone-500">2K Resolution, Better coherence</div>
|
||||
</div>
|
||||
</label>
|
||||
<label className={`flex items-center gap-3 p-3 rounded-lg border cursor-pointer transition-all ${selectedModel === 'gemini-2.5-flash-image' ? 'bg-white border-amber-500 shadow-sm' : 'border-stone-200 hover:bg-white'}`}>
|
||||
<input type="radio" name="model" value="gemini-2.5-flash-image" checked={selectedModel === 'gemini-2.5-flash-image'} onChange={(e) => setSelectedModel(e.target.value)} className="w-4 h-4 text-amber-600 accent-amber-600" />
|
||||
<div>
|
||||
<div className="font-bold text-sm text-stone-800">Banana Flash</div>
|
||||
<div className="text-[10px] text-stone-500">Faster, Standard Resolution</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* CENTER: Stash Selector */}
|
||||
<div className="lg:col-span-2 flex flex-col h-[650px] bg-white rounded-xl border border-stone-200 shadow-lg overflow-hidden">
|
||||
<div className="p-4 border-b border-stone-100 flex justify-between items-center bg-stone-50/50">
|
||||
<h3 className="text-xs font-bold text-stone-400 uppercase tracking-widest">Available Equipment</h3>
|
||||
<span className="text-xs font-bold bg-stone-200 text-stone-600 px-2 py-0.5 rounded-full">{selectedItemIds.size} Selected</span>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-4 custom-scrollbar bg-stone-50/30">
|
||||
{stash.length === 0 ? (
|
||||
<div className="h-full flex flex-col items-center justify-center text-stone-400">
|
||||
<p>No equipment in stash.</p>
|
||||
<button onClick={onNavigateToExtractor} className="text-cyan-600 text-sm font-bold uppercase mt-2 hover:underline">Go to Extractor</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 gap-3">
|
||||
{stash.map(item => {
|
||||
const isSelected = selectedItemIds.has(item.id);
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
onClick={() => toggleItem(item.id)}
|
||||
className={`cursor-pointer rounded-lg border-2 p-2 transition-all hover:shadow-md ${isSelected ? 'border-purple-500 bg-purple-50' : 'border-stone-200 bg-white hover:border-stone-300'}`}
|
||||
>
|
||||
<div className="aspect-square bg-stone-100 rounded overflow-hidden mb-2">
|
||||
<img src={item.imageUrl} className="w-full h-full object-contain" />
|
||||
</div>
|
||||
<p className={`text-[10px] font-bold truncate text-center ${isSelected ? 'text-purple-700' : 'text-stone-500'}`}>{item.item_name}</p>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="p-4 border-t border-stone-200 bg-white flex justify-end">
|
||||
<button
|
||||
onClick={handleForge}
|
||||
disabled={!baseFile || selectedItemIds.size === 0}
|
||||
className={`px-8 py-3 rounded-lg font-bold uppercase tracking-wider shadow-lg flex items-center gap-2 transition-all ${
|
||||
!baseFile || selectedItemIds.size === 0
|
||||
? 'bg-stone-200 text-stone-400 cursor-not-allowed'
|
||||
: 'bg-gradient-to-r from-purple-600 to-indigo-600 hover:from-purple-500 hover:to-indigo-500 text-white hover:-translate-y-1'
|
||||
}`}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor"><path fillRule="evenodd" d="M11.3 1.046A1 1 0 0112 2v5h4a1 1 0 01.82 1.573l-7 10A1 1 0 018 18v-5H4a1 1 0 01-.82-1.573l7-10a1 1 0 011.12-.38z" clipRule="evenodd" /></svg>
|
||||
Forge
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* RESULT */}
|
||||
{resultUrl && (
|
||||
<div className="mt-12 text-center animate-fade-in-up">
|
||||
<h2 className="text-2xl font-black text-stone-800 mb-6 uppercase">Masterpiece Created</h2>
|
||||
<div className="inline-block p-4 bg-white rounded-2xl shadow-2xl border-2 border-purple-200 relative group">
|
||||
<img src={resultUrl} className="max-w-md w-full rounded-lg" />
|
||||
<div className="absolute inset-0 bg-stone-900/40 opacity-0 group-hover:opacity-100 transition-opacity rounded-2xl flex items-center justify-center">
|
||||
<a href={resultUrl} download="forged_character.png" className="bg-purple-600 text-white px-6 py-3 rounded-lg font-bold uppercase tracking-wider shadow-xl hover:scale-105 transition-transform flex items-center gap-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" /></svg>
|
||||
Download
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,74 @@
|
||||
import React from 'react';
|
||||
import { StashItem } from '../types';
|
||||
|
||||
interface StashProps {
|
||||
items: StashItem[];
|
||||
onRemove: (id: string) => void;
|
||||
onClear: () => void;
|
||||
onNavigateToExtractor: () => void;
|
||||
}
|
||||
|
||||
export const Stash: React.FC<StashProps> = ({ items, onRemove, onClear, onNavigateToExtractor }) => {
|
||||
return (
|
||||
<div className="animate-fade-in-up">
|
||||
<div className="flex justify-between items-end mb-8">
|
||||
<div>
|
||||
<h2 className="text-3xl font-black text-stone-800 flex items-center gap-3">
|
||||
<span className="text-cyan-500">❖</span> INVENTORY
|
||||
</h2>
|
||||
<p className="text-stone-500 font-medium">Manage your collection of extracted assets.</p>
|
||||
</div>
|
||||
{items.length > 0 && (
|
||||
<button
|
||||
onClick={onClear}
|
||||
className="text-red-500 hover:text-red-700 font-bold uppercase text-xs tracking-wider border border-red-200 px-3 py-1.5 rounded bg-red-50 hover:bg-red-100 transition-colors"
|
||||
>
|
||||
Clear All
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{items.length === 0 ? (
|
||||
<div className="bg-white rounded-2xl p-16 text-center border-4 border-dashed border-stone-200">
|
||||
<div className="w-16 h-16 bg-stone-100 rounded-full flex items-center justify-center mx-auto mb-4 text-stone-400">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4" /></svg>
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-stone-700">Inventory Empty</h3>
|
||||
<p className="text-stone-400 mt-2 mb-6">Extract assets to populate your stash.</p>
|
||||
<button onClick={onNavigateToExtractor} className="text-cyan-600 font-bold uppercase hover:underline text-sm">Go to Extractor</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-4">
|
||||
{items.map((item) => (
|
||||
<div key={item.id} className="group relative bg-white rounded-lg border-2 border-stone-200 shadow-sm hover:border-cyan-400 hover:shadow-lg transition-all duration-300">
|
||||
<div className="aspect-square p-3 flex items-center justify-center relative bg-stone-50 rounded-t-md overflow-hidden">
|
||||
<div className="absolute inset-0 opacity-5" style={{backgroundImage: 'radial-gradient(#000 1px, transparent 1px)', backgroundSize: '8px 8px'}}></div>
|
||||
<img src={item.imageUrl} className="max-w-full max-h-full object-contain group-hover:scale-110 transition-transform" />
|
||||
|
||||
<div className="absolute inset-0 bg-stone-900/60 opacity-0 group-hover:opacity-100 transition-opacity flex flex-col gap-2 items-center justify-center p-2 backdrop-blur-[1px]">
|
||||
<a
|
||||
href={item.imageUrl}
|
||||
download={`${item.item_name}.png`}
|
||||
className="w-full py-1.5 bg-cyan-600 hover:bg-cyan-500 text-white rounded text-[10px] font-bold uppercase tracking-widest text-center shadow-md"
|
||||
>
|
||||
Download
|
||||
</a>
|
||||
<button
|
||||
onClick={() => onRemove(item.id)}
|
||||
className="w-full py-1.5 bg-red-500 hover:bg-red-400 text-white rounded text-[10px] font-bold uppercase tracking-widest text-center shadow-md"
|
||||
>
|
||||
Discard
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-3 border-t border-stone-100">
|
||||
<h3 className="text-xs font-bold text-stone-700 truncate" title={item.item_name}>{item.item_name}</h3>
|
||||
<p className="text-[10px] text-stone-400 mt-1">{new Date(item.createdAt).toLocaleDateString()}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,64 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Forgery</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<style>
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;700&display=swap');
|
||||
|
||||
body {
|
||||
font-family: 'Inter', sans-serif;
|
||||
background-color: #f3f4f6; /* Gray 100 */
|
||||
background-image:
|
||||
radial-gradient(#e5e7eb 1px, transparent 1px),
|
||||
radial-gradient(#e5e7eb 1px, transparent 1px);
|
||||
background-size: 20px 20px;
|
||||
background-position: 0 0, 10px 10px;
|
||||
color: #1f2937; /* Gray 800 */
|
||||
}
|
||||
|
||||
/* Custom Scrollbar for 'Game UI' feel */
|
||||
.custom-scrollbar::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-track {
|
||||
background: #e5e7eb;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: #9ca3af;
|
||||
border-radius: 4px;
|
||||
border: 2px solid #e5e7eb;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background: #6b7280;
|
||||
}
|
||||
|
||||
@keyframes fade-in-up {
|
||||
from { opacity: 0; transform: translateY(10px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
.animate-fade-in-up {
|
||||
animation: fade-in-up 0.4s ease-out forwards;
|
||||
}
|
||||
</style>
|
||||
<script type="importmap">
|
||||
{
|
||||
"imports": {
|
||||
"react/": "https://esm.sh/react@^19.2.4/",
|
||||
"react": "https://esm.sh/react@^19.2.4",
|
||||
"@google/genai": "https://esm.sh/@google/genai@^1.40.0",
|
||||
"react-dom/": "https://esm.sh/react-dom@^19.2.4/"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<link rel="stylesheet" href="/index.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/index.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,15 @@
|
||||
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(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"name": "Forgery",
|
||||
"description": "Extract character assets and forge new characters using Gemini models.",
|
||||
"requestFramePermissions": []
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "forgery",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^19.2.4",
|
||||
"@google/genai": "^1.40.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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,398 @@
|
||||
import { GoogleGenAI, Type } from "@google/genai";
|
||||
import { AnalysisResponse, Asset, BoundingBoxResponse, CroppedAsset, DetectedAssetBox, HEX_COLORS, StashItem } from "../types";
|
||||
|
||||
// Helper to convert File to Base64
|
||||
const fileToGenericBase64 = async (file: File): Promise<string> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
const result = reader.result as string;
|
||||
// Remove data URL prefix (e.g., "data:image/png;base64,")
|
||||
const base64 = result.split(',')[1];
|
||||
resolve(base64);
|
||||
};
|
||||
reader.onerror = (error) => reject(error);
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
};
|
||||
|
||||
export const analyzeImageForAssets = async (file: File): Promise<AnalysisResponse> => {
|
||||
// Use a smart model for analysis (Gemini 3 Pro or Flash) to get accurate JSON
|
||||
const ai = new GoogleGenAI({ apiKey: process.env.API_KEY });
|
||||
const base64Data = await fileToGenericBase64(file);
|
||||
|
||||
const prompt = `
|
||||
Task: Character Asset Identification for Extraction
|
||||
|
||||
Instructions:
|
||||
1. Analyze the character in the image and identify all distinct, extractable equipment and clothing items.
|
||||
2. Filter for items that are clearly visible: tops, bottoms, headwear, footwear, specialized armor (arm guards, leg guards), and accessories (scarves, belts, capes).
|
||||
3. For each identified item, assign one of the following high-contrast background colors for future segmentation: Red, Yellow, Green, White, Blue, Black, Magenta, Cyan, Orange, Gray. Avoid repeating colors if possible.
|
||||
4. Limit to a maximum of 9 most prominent assets.
|
||||
|
||||
Output Format (Strict JSON):
|
||||
Return ONLY a JSON object with a list of assets.
|
||||
`;
|
||||
|
||||
const response = await ai.models.generateContent({
|
||||
model: 'gemini-3-pro-preview',
|
||||
contents: {
|
||||
parts: [
|
||||
{ inlineData: { mimeType: file.type, data: base64Data } },
|
||||
{ text: prompt }
|
||||
]
|
||||
},
|
||||
config: {
|
||||
responseMimeType: "application/json",
|
||||
responseSchema: {
|
||||
type: Type.OBJECT,
|
||||
properties: {
|
||||
assets: {
|
||||
type: Type.ARRAY,
|
||||
items: {
|
||||
type: Type.OBJECT,
|
||||
properties: {
|
||||
item_name: { type: Type.STRING },
|
||||
background_color: { type: Type.STRING },
|
||||
description: { type: Type.STRING },
|
||||
},
|
||||
required: ['item_name', 'background_color', 'description'],
|
||||
},
|
||||
},
|
||||
},
|
||||
required: ['assets']
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const text = response.text;
|
||||
if (!text) throw new Error("No response from analysis model");
|
||||
|
||||
let cleanText = text.trim();
|
||||
if (cleanText.startsWith('```')) {
|
||||
cleanText = cleanText.replace(/^```(json)?/, '').replace(/```$/, '').trim();
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(cleanText);
|
||||
if (!parsed.assets) parsed.assets = [];
|
||||
|
||||
return parsed as AnalysisResponse;
|
||||
};
|
||||
|
||||
export const generateTextureSheet = async (originalFile: File, assets: Asset[]): Promise<string> => {
|
||||
// Use Gemini 2.5 Flash Image ("Banana") for generation as requested
|
||||
const ai = new GoogleGenAI({ apiKey: process.env.API_KEY });
|
||||
const base64Data = await fileToGenericBase64(originalFile);
|
||||
|
||||
// Construct the mapping string dynamically
|
||||
const mappingString = assets.map(asset => {
|
||||
const colorName = asset.background_color;
|
||||
// Fallback to black if hex not found
|
||||
const hex = HEX_COLORS[colorName] || '#000000';
|
||||
return `- ${asset.item_name}: ${colorName} Background (${hex}). ${asset.description}`;
|
||||
}).join('\n');
|
||||
|
||||
const prompt = `
|
||||
Task: Character Asset Extraction & Grid Generation
|
||||
|
||||
Instructions:
|
||||
Analyze the character in the provided image and extract the following ${assets.length} specific assets into a new, single image organized in a grid layout (e.g., 2x3, 3x3 depending on count).
|
||||
|
||||
Core Constraints:
|
||||
1. Isolated Assets: Each item must be placed within its own distinct square tile. No overlapping.
|
||||
2. Ghost Mannequin Style: Extract ONLY the items. Remove the character's body entirely. Clothing and armor must appear as empty, 3D shells as if worn by an invisible person.
|
||||
3. Solid Chroma Key Backgrounds: Each tile must use the specific solid background color assigned below. Ensure zero shadows, highlights, or gradients on the background to facilitate clean digital segmentation.
|
||||
4. Output Format: Generate a square texture sheet. Ignore the original image aspect ratio for the final output canvas.
|
||||
|
||||
Item & Background Mapping:
|
||||
${mappingString}
|
||||
|
||||
Output Requirement: High-resolution texture sheet, flat lay presentation, sharp edges, and uniform lighting across all assets.
|
||||
`;
|
||||
|
||||
const response = await ai.models.generateContent({
|
||||
model: 'gemini-2.5-flash-image',
|
||||
contents: {
|
||||
parts: [
|
||||
{ inlineData: { mimeType: originalFile.type, data: base64Data } },
|
||||
{ text: prompt }
|
||||
]
|
||||
},
|
||||
config: {
|
||||
imageConfig: {
|
||||
aspectRatio: '1:1',
|
||||
// imageSize not supported for gemini-2.5-flash-image
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Extract image
|
||||
let imageUrl = '';
|
||||
if (response.candidates && response.candidates[0].content.parts) {
|
||||
for (const part of response.candidates[0].content.parts) {
|
||||
if (part.inlineData) {
|
||||
imageUrl = `data:${part.inlineData.mimeType};base64,${part.inlineData.data}`;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!imageUrl) {
|
||||
throw new Error("No image generated.");
|
||||
}
|
||||
|
||||
return imageUrl;
|
||||
};
|
||||
|
||||
export const detectAssetBoundingBoxes = async (textureSheetBase64: string): Promise<BoundingBoxResponse> => {
|
||||
const ai = new GoogleGenAI({ apiKey: process.env.API_KEY });
|
||||
|
||||
const prompt = `
|
||||
Task: Spatial Asset Detection & Normalized Grid Mapping
|
||||
|
||||
Instructions:
|
||||
1. Analyze the provided image consisting of character assets arranged in a grid.
|
||||
2. Identify the bounding box for each distinct asset container (the colored square background tiles).
|
||||
3. Return the coordinates for each box using a normalized scale of 0.0 to 1.0.
|
||||
- [ymin, xmin, ymax, xmax]
|
||||
- (0, 0) is the top-left corner; (1.0, 1.0) is the bottom-right corner.
|
||||
4. Ensure the box coordinates strictly encompass the outer boundary of each colored tile.
|
||||
|
||||
Output Format (Strict JSON):
|
||||
Return ONLY a JSON object. Provide the grid dimensions and a list of detected assets with their coordinates.
|
||||
`;
|
||||
|
||||
const response = await ai.models.generateContent({
|
||||
model: 'gemini-3-pro-preview',
|
||||
contents: {
|
||||
parts: [
|
||||
{ inlineData: { mimeType: 'image/png', data: textureSheetBase64 } },
|
||||
{ text: prompt }
|
||||
]
|
||||
},
|
||||
config: {
|
||||
responseMimeType: "application/json",
|
||||
responseSchema: {
|
||||
type: Type.OBJECT,
|
||||
properties: {
|
||||
grid_dimensions: {
|
||||
type: Type.OBJECT,
|
||||
properties: {
|
||||
rows: { type: Type.INTEGER },
|
||||
cols: { type: Type.INTEGER }
|
||||
}
|
||||
},
|
||||
assets: {
|
||||
type: Type.ARRAY,
|
||||
items: {
|
||||
type: Type.OBJECT,
|
||||
properties: {
|
||||
item_name: { type: Type.STRING },
|
||||
coordinates: {
|
||||
type: Type.ARRAY,
|
||||
items: { type: Type.NUMBER },
|
||||
description: "[ymin, xmin, ymax, xmax]"
|
||||
},
|
||||
bg_color_detected: { type: Type.STRING }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
required: ['assets', 'grid_dimensions']
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const text = response.text;
|
||||
if (!text) throw new Error("No bounding box response from model");
|
||||
|
||||
let cleanText = text.trim();
|
||||
if (cleanText.startsWith('```')) {
|
||||
cleanText = cleanText.replace(/^```(json)?/, '').replace(/```$/, '').trim();
|
||||
}
|
||||
|
||||
let parsed: any;
|
||||
try {
|
||||
parsed = JSON.parse(cleanText);
|
||||
} catch (e) {
|
||||
console.error("Failed to parse JSON:", cleanText);
|
||||
throw new Error("Invalid JSON response from detection model");
|
||||
}
|
||||
|
||||
// Ensure assets array exists
|
||||
if (!parsed.assets || !Array.isArray(parsed.assets)) {
|
||||
parsed.assets = [];
|
||||
}
|
||||
|
||||
return parsed as BoundingBoxResponse;
|
||||
};
|
||||
|
||||
export const cropAssetsFromSheet = async (sheetDataUrl: string, boxes: DetectedAssetBox[]): Promise<Omit<CroppedAsset, 'sourceIndex'>[]> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.crossOrigin = "anonymous";
|
||||
img.onload = () => {
|
||||
try {
|
||||
const cropped: Omit<CroppedAsset, 'sourceIndex'>[] = [];
|
||||
|
||||
if (boxes && Array.isArray(boxes)) {
|
||||
boxes.forEach(box => {
|
||||
if (!box.coordinates || box.coordinates.length !== 4) return;
|
||||
|
||||
const [ymin, xmin, ymax, xmax] = box.coordinates;
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
if (!ctx) return;
|
||||
|
||||
// Convert normalized coords to pixels
|
||||
const srcX = xmin * img.width;
|
||||
const srcY = ymin * img.height;
|
||||
const srcW = (xmax - xmin) * img.width;
|
||||
const srcH = (ymax - ymin) * img.height;
|
||||
|
||||
canvas.width = srcW;
|
||||
canvas.height = srcH;
|
||||
|
||||
ctx.drawImage(img, srcX, srcY, srcW, srcH, 0, 0, srcW, srcH);
|
||||
|
||||
cropped.push({
|
||||
item_name: box.item_name || "Unknown Asset",
|
||||
imageUrl: canvas.toDataURL('image/png')
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
resolve(cropped);
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
}
|
||||
};
|
||||
img.onerror = (e) => reject(e);
|
||||
img.src = sheetDataUrl;
|
||||
});
|
||||
};
|
||||
|
||||
export const refineAssetImage = async (base64Data: string): Promise<string> => {
|
||||
const ai = new GoogleGenAI({ apiKey: process.env.API_KEY });
|
||||
|
||||
const prompt = `
|
||||
Task: Asset Refinement
|
||||
|
||||
Instructions:
|
||||
1. Process the provided asset image.
|
||||
2. Remove the existing colored background and replace it with a pure white background (#FFFFFF).
|
||||
3. Upscale the image and enhance details for high-quality game asset presentation.
|
||||
4. Ensure the object is centered and fully visible.
|
||||
5. Return only the image.
|
||||
`;
|
||||
|
||||
// Remove header if present
|
||||
const cleanBase64 = base64Data.includes(',') ? base64Data.split(',')[1] : base64Data;
|
||||
|
||||
const response = await ai.models.generateContent({
|
||||
model: 'gemini-2.5-flash-image',
|
||||
contents: {
|
||||
parts: [
|
||||
{ inlineData: { mimeType: 'image/png', data: cleanBase64 } },
|
||||
{ text: prompt }
|
||||
]
|
||||
}
|
||||
});
|
||||
|
||||
// Extract image
|
||||
let imageUrl = '';
|
||||
if (response.candidates && response.candidates[0].content.parts) {
|
||||
for (const part of response.candidates[0].content.parts) {
|
||||
if (part.inlineData) {
|
||||
imageUrl = `data:${part.inlineData.mimeType};base64,${part.inlineData.data}`;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!imageUrl) {
|
||||
throw new Error("No refined image generated.");
|
||||
}
|
||||
|
||||
return imageUrl;
|
||||
};
|
||||
|
||||
export const generateModifiedCharacter = async (
|
||||
characterFile: File,
|
||||
assets: StashItem[],
|
||||
model: string = 'gemini-3-pro-image-preview'
|
||||
): Promise<string> => {
|
||||
const ai = new GoogleGenAI({ apiKey: process.env.API_KEY });
|
||||
|
||||
// Base Character
|
||||
const charBase64 = await fileToGenericBase64(characterFile);
|
||||
|
||||
const parts: any[] = [];
|
||||
|
||||
parts.push({
|
||||
inlineData: { mimeType: characterFile.type, data: charBase64 }
|
||||
});
|
||||
parts.push({ text: "Base Character Reference" });
|
||||
|
||||
// Assets
|
||||
for (const asset of assets) {
|
||||
// imageUrl is likely a data URL "data:image/png;base64,....."
|
||||
const matches = asset.imageUrl.match(/^data:(.+);base64,(.+)$/);
|
||||
if (matches && matches.length === 3) {
|
||||
const mimeType = matches[1];
|
||||
const data = matches[2];
|
||||
parts.push({
|
||||
inlineData: { mimeType, data }
|
||||
});
|
||||
parts.push({ text: `Equipment to equip: ${asset.item_name}` });
|
||||
}
|
||||
}
|
||||
|
||||
const prompt = `
|
||||
Task: Character Synthesis and Equipment Modification.
|
||||
|
||||
1. Reference the 'Base Character' for pose, body type, facial features, and general art style.
|
||||
2. Reference the 'Equipment to equip' images.
|
||||
3. Generate a new image of the Base Character wearing the provided items.
|
||||
4. The items should replace existing clothing/armor in that slot (e.g. new helmet replaces old hat, armor replaces shirt).
|
||||
5. Seamlessly blend the items into the character's style.
|
||||
6. Maintain the exact pose and angle of the Base Character.
|
||||
7. High quality, detailed, game art style.
|
||||
`;
|
||||
|
||||
parts.push({ text: prompt });
|
||||
|
||||
const imageConfig: any = {
|
||||
aspectRatio: '1:1',
|
||||
};
|
||||
|
||||
if (model === 'gemini-3-pro-image-preview') {
|
||||
imageConfig.imageSize = '2K';
|
||||
}
|
||||
|
||||
const response = await ai.models.generateContent({
|
||||
model: model,
|
||||
contents: { parts },
|
||||
config: {
|
||||
imageConfig: imageConfig
|
||||
}
|
||||
});
|
||||
|
||||
// Extract image
|
||||
let imageUrl = '';
|
||||
if (response.candidates && response.candidates[0].content.parts) {
|
||||
for (const part of response.candidates[0].content.parts) {
|
||||
if (part.inlineData) {
|
||||
imageUrl = `data:${part.inlineData.mimeType};base64,${part.inlineData.data}`;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!imageUrl) {
|
||||
throw new Error("No image generated.");
|
||||
}
|
||||
|
||||
return imageUrl;
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
export interface Asset {
|
||||
item_name: string;
|
||||
background_color: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface AnalysisResponse {
|
||||
assets: Asset[];
|
||||
}
|
||||
|
||||
export type ExtractionStatus = 'idle' | 'analyzing' | 'reviewing' | 'generating' | 'sheet_ready' | 'splitting' | 'refining' | 'complete' | 'error' | 'modifying';
|
||||
|
||||
export interface ColorMap {
|
||||
[key: string]: string;
|
||||
}
|
||||
|
||||
export const HEX_COLORS: ColorMap = {
|
||||
Red: '#FF0000',
|
||||
Yellow: '#FFFF00',
|
||||
Green: '#00FF00',
|
||||
White: '#FFFFFF',
|
||||
Blue: '#0000FF',
|
||||
Black: '#000000',
|
||||
Magenta: '#FF00FF',
|
||||
Cyan: '#00FFFF',
|
||||
Orange: '#FFA500',
|
||||
Gray: '#808080',
|
||||
};
|
||||
|
||||
export interface DetectedAssetBox {
|
||||
item_name: string;
|
||||
coordinates: [number, number, number, number]; // ymin, xmin, ymax, xmax
|
||||
bg_color_detected: string;
|
||||
}
|
||||
|
||||
export interface BoundingBoxResponse {
|
||||
grid_dimensions: { rows: number; cols: number };
|
||||
assets: DetectedAssetBox[];
|
||||
}
|
||||
|
||||
export interface CroppedAsset {
|
||||
item_name: string;
|
||||
imageUrl: string;
|
||||
refinedImageUrl?: string;
|
||||
sourceIndex: number;
|
||||
}
|
||||
|
||||
export interface StashItem {
|
||||
id: string;
|
||||
item_name: string;
|
||||
imageUrl: string;
|
||||
createdAt: number;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
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, '.'),
|
||||
}
|
||||
}
|
||||
};
|
||||
});
|
||||
Reference in new issue
Block a user