add demo ui

This commit is contained in:
yiling ji committed 2026-02-07 21:05:33 +08:00
1 parent 2c6bf14d69
commit 4615a075b1
16 files changed
+1534

No files matched your search

+24
View File
@@ -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?
+173
View File
@@ -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>
);
}
+20
View File
@@ -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`
+89
View File
@@ -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>
);
};
+277
View File
@@ -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>
);
};
+71
View File
@@ -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>
);
};
+197
View File
@@ -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>
);
};
+74
View File
@@ -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>
);
};
+64
View File
@@ -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>
+15
View File
@@ -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>
);
+5
View File
@@ -0,0 +1,5 @@
{
"name": "Forgery",
"description": "Extract character assets and forge new characters using Gemini models.",
"requestFramePermissions": []
}
+22
View File
@@ -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"
}
}
+398
View File
@@ -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;
};
+29
View File
@@ -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
}
}
+53
View File
@@ -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;
}
+23
View File
@@ -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, '.'),
}
}
};
});