first commit

This commit is contained in:
unknown 2026-01-26 00:57:06 +08:00
commit 91bcddce97
16 changed files with 5300 additions and 0 deletions

View File

@ -0,0 +1,12 @@
{
"permissions": {
"allow": [
"Bash(npm install)",
"Bash(ls:*)",
"Bash(cat:*)",
"Bash(npx tsc:*)",
"Bash(npm run dev:*)",
"Bash(timeout:*)"
]
}
}

24
.gitignore vendored Normal file
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?

1301
App.tsx Normal file

File diff suppressed because it is too large Load Diff

20
README.md Normal file
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/1LXOH4knEUKDpTxsYy6K3z5oYuLSsYvtO
## 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`

View File

@ -0,0 +1,175 @@
import React, { useEffect, useState, useRef } from 'react';
import { Play, Pause, Download, RefreshCw, Film, FileImage, FileArchive } from 'lucide-react';
import { downloadApng, downloadGif, downloadZip } from '../utils/imageUtils';
interface AnimationPreviewProps {
frames: string[];
fps: number;
isLoading: boolean;
}
export const AnimationPreview: React.FC<AnimationPreviewProps> = ({ frames, fps, isLoading }) => {
const [currentFrameIndex, setCurrentFrameIndex] = useState(0);
const [isPlaying, setIsPlaying] = useState(true);
const [isDownloading, setIsDownloading] = useState(false);
const timerRef = useRef<number | null>(null);
useEffect(() => {
if (frames.length === 0 || !isPlaying) {
if (timerRef.current) window.clearInterval(timerRef.current);
return;
}
const interval = 1000 / fps;
timerRef.current = window.setInterval(() => {
setCurrentFrameIndex((prev) => (prev + 1) % frames.length);
}, interval);
return () => {
if (timerRef.current) window.clearInterval(timerRef.current);
};
}, [frames, fps, isPlaying]);
const handleDownload = async (type: 'APNG' | 'GIF' | 'ZIP') => {
if (frames.length === 0) return;
setIsDownloading(true);
try {
const timestamp = new Date().getTime();
const filename = `toonmotion_${timestamp}`;
if (type === 'APNG') {
await downloadApng(frames, fps, filename);
} else if (type === 'GIF') {
await downloadGif(frames, fps, filename);
} else if (type === 'ZIP') {
await downloadZip(frames, filename);
}
} catch (e) {
console.error(e);
alert("下载失败,请重试");
} finally {
setIsDownloading(false);
}
};
if (isLoading) {
return (
<div className="w-full h-[500px] bg-gray-50 rounded-2xl flex flex-col items-center justify-center border border-gray-200 shadow-inner">
<div className="relative">
<div className="animate-spin rounded-full h-16 w-16 border-4 border-gray-200 border-t-lime-500"></div>
<div className="absolute inset-0 flex items-center justify-center">
<div className="h-8 w-8 bg-white rounded-full"></div>
</div>
</div>
<p className="text-gray-600 font-medium mt-6 animate-pulse">AI ...</p>
<p className="text-xs text-gray-400 mt-2">Gemini 2.5 </p>
</div>
);
}
if (frames.length === 0) {
return (
<div className="w-full h-[500px] bg-gray-50 rounded-2xl flex flex-col items-center justify-center border-2 border-dashed border-gray-200 text-gray-400">
<div className="bg-gray-100 p-5 rounded-full mb-4">
<Film className="w-10 h-10 text-gray-300" />
</div>
<p className="font-medium"></p>
<p className="text-xs mt-1"></p>
</div>
);
}
return (
<div className="bg-white rounded-2xl border border-gray-200 shadow-lg overflow-hidden">
<div className="p-4 border-b border-gray-100 flex justify-between items-center bg-gray-50/50">
<h3 className="font-bold text-gray-800 flex items-center gap-2">
<Film size={18} className="text-lime-500"/>
</h3>
<span className="text-xs font-mono bg-gray-200 px-2 py-1 rounded text-gray-600 font-bold">
{frames.length}
</span>
</div>
{/* Canvas Area */}
<div className="h-[360px] flex items-center justify-center bg-[url('https://www.transparenttextures.com/patterns/cubes.png')] bg-gray-100 relative overflow-hidden group border-b border-gray-100">
<img
src={frames[currentFrameIndex]}
alt={`Frame ${currentFrameIndex}`}
className="h-full w-full object-contain rendering-pixelated transition-transform duration-300 group-hover:scale-105"
style={{ imageRendering: 'pixelated' }}
/>
</div>
{/* Control Bar */}
<div className="px-6 py-3 flex items-center justify-between bg-white border-b border-gray-100">
<div className="flex items-center gap-4">
<button
onClick={() => setIsPlaying(!isPlaying)}
className={`w-10 h-10 rounded-full flex items-center justify-center transition-all ${isPlaying ? 'bg-gray-100 text-gray-600 hover:bg-gray-200' : 'bg-lime-400 text-black hover:bg-lime-500 shadow-md shadow-lime-200'}`}
title={isPlaying ? "暂停" : "播放"}
>
{isPlaying ? <Pause size={20} fill="currentColor" /> : <Play size={20} fill="currentColor" className="ml-1"/>}
</button>
<div className="flex flex-col">
<span className="text-xs font-bold text-gray-700 uppercase tracking-wider"></span>
<div className="flex items-center gap-2">
<div className="w-24 h-1.5 bg-gray-100 rounded-full overflow-hidden">
<div
className="h-full bg-lime-500 transition-all duration-200"
style={{ width: `${((currentFrameIndex + 1) / frames.length) * 100}%` }}
></div>
</div>
<span className="text-[10px] font-mono text-gray-400">
{currentFrameIndex + 1}/{frames.length}
</span>
</div>
</div>
</div>
<button
onClick={() => setCurrentFrameIndex(0)}
className="p-2 text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-lg transition-all"
title="重置预览"
>
<RefreshCw size={16} />
</button>
</div>
{/* Footer Actions - Download Buttons */}
<div className="p-5 bg-gray-50/50">
<div className="grid grid-cols-3 gap-3">
<button
onClick={() => handleDownload('APNG')}
disabled={isDownloading}
className="flex flex-col items-center justify-center p-3 rounded-xl border border-gray-200 hover:border-lime-400 hover:bg-lime-50 transition-all group bg-white"
>
<Download size={20} className="text-gray-600 group-hover:text-lime-600 mb-1"/>
<span className="font-bold text-gray-700 text-sm"> APNG</span>
<span className="text-[10px] text-gray-400"></span>
</button>
<button
onClick={() => handleDownload('GIF')}
disabled={isDownloading}
className="flex flex-col items-center justify-center p-3 rounded-xl border border-gray-200 hover:border-purple-400 hover:bg-purple-50 transition-all group bg-white"
>
<FileImage size={20} className="text-gray-600 group-hover:text-purple-600 mb-1"/>
<span className="font-bold text-gray-700 text-sm"> GIF</span>
<span className="text-[10px] text-gray-400"></span>
</button>
<button
onClick={() => handleDownload('ZIP')}
disabled={isDownloading}
className="flex flex-col items-center justify-center p-3 rounded-xl border border-gray-200 hover:border-blue-400 hover:bg-blue-50 transition-all group bg-white"
>
<FileArchive size={20} className="text-gray-600 group-hover:text-blue-600 mb-1"/>
<span className="font-bold text-gray-700 text-sm"></span>
<span className="text-[10px] text-gray-400">PNG ZIP包</span>
</button>
</div>
</div>
</div>
);
};

71
components/UploadZone.tsx Normal file
View File

@ -0,0 +1,71 @@
import React, { useState, useRef } from 'react';
import { Upload, Image as ImageIcon, X } from 'lucide-react';
import { fileToBase64 } from '../utils/imageUtils';
interface UploadZoneProps {
onImageSelected: (base64: string) => void;
selectedImage: string | null;
onClear: () => void;
}
export const UploadZone: React.FC<UploadZoneProps> = ({ onImageSelected, selectedImage, onClear }) => {
const [isDragging, setIsDragging] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files[0]) {
const base64 = await fileToBase64(e.target.files[0]);
onImageSelected(base64);
}
};
const handleDrop = async (e: React.DragEvent) => {
e.preventDefault();
setIsDragging(false);
if (e.dataTransfer.files && e.dataTransfer.files[0]) {
const base64 = await fileToBase64(e.dataTransfer.files[0]);
onImageSelected(base64);
}
};
if (selectedImage) {
return (
<div className="relative w-full h-64 bg-gray-50 rounded-xl overflow-hidden border border-gray-200 group shadow-inner">
<div className="absolute inset-0 bg-[url('https://www.transparenttextures.com/patterns/cubes.png')] opacity-30"></div>
<img src={selectedImage} alt="Original Character" className="w-full h-full object-contain relative z-10" />
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center z-20">
<button
onClick={onClear}
className="bg-white/90 text-red-600 px-4 py-2 rounded-full font-medium flex items-center gap-2 hover:bg-white shadow-lg transform hover:scale-105 transition-all"
>
<X size={18} />
</button>
</div>
</div>
);
}
return (
<div
onClick={() => fileInputRef.current?.click()}
onDragOver={(e) => { e.preventDefault(); setIsDragging(true); }}
onDragLeave={() => setIsDragging(false)}
onDrop={handleDrop}
className={`w-full h-64 border-2 border-dashed rounded-xl flex flex-col items-center justify-center cursor-pointer transition-all duration-200 group
${isDragging ? 'border-lime-500 bg-lime-50' : 'border-gray-300 hover:border-lime-400 hover:bg-gray-50'}`}
>
<input
type="file"
ref={fileInputRef}
className="hidden"
accept="image/png, image/jpeg, image/webp"
onChange={handleFileChange}
/>
<div className="bg-white p-4 rounded-full shadow-sm mb-4 group-hover:shadow-md transition-all group-hover:scale-110 duration-300">
<Upload className={`w-8 h-8 ${isDragging ? 'text-lime-600' : 'text-gray-400 group-hover:text-lime-500'}`} />
</div>
<p className="text-gray-700 font-semibold group-hover:text-lime-600 transition-colors"></p>
<p className="text-gray-400 text-xs mt-2"> PNG, JPG, WEBP</p>
</div>
);
};

36
index.html Normal file
View File

@ -0,0 +1,36 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ToonMotion</title>
<script src="https://cdn.tailwindcss.com"></script>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
body { font-family: 'Inter', sans-serif; }
/* Custom scrollbar for a cleaner look */
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: #94a3b8; }
</style>
<script type="importmap">
{
"imports": {
"react-dom/": "https://aistudiocdn.com/react-dom@^19.2.0/",
"lucide-react": "https://aistudiocdn.com/lucide-react@^0.554.0",
"react/": "https://aistudiocdn.com/react@^19.2.0/",
"react": "https://aistudiocdn.com/react@^19.2.0",
"@google/genai": "https://aistudiocdn.com/@google/genai@^1.30.0",
"jszip": "https://esm.run/jszip",
"gifenc": "https://esm.run/gifenc",
"upng-js": "https://esm.run/upng-js"
}
}
</script>
</head>
<body class="bg-gray-50 text-gray-900 antialiased selection:bg-lime-200">
<div id="root"></div>
<script type="module" src="/index.tsx"></script>
</body>
</html>

15
index.tsx Normal file
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
metadata.json Normal file
View File

@ -0,0 +1,5 @@
{
"name": "ToonMotion",
"description": "动图生产",
"requestFramePermissions": []
}

2507
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

26
package.json Normal file
View File

@ -0,0 +1,26 @@
{
"name": "toonmotion",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"react-dom": "^19.2.0",
"lucide-react": "^0.554.0",
"react": "^19.2.0",
"@google/genai": "^1.30.0",
"jszip": "latest",
"gifenc": "latest",
"upng-js": "latest"
},
"devDependencies": {
"@types/node": "^22.14.0",
"@vitejs/plugin-react": "^5.0.0",
"typescript": "~5.8.2",
"vite": "^6.2.0"
}
}

675
services/geminiService.ts Normal file
View File

@ -0,0 +1,675 @@
import { GoogleGenAI } from "@google/genai";
const apiKey = process.env.API_KEY;
const baseUrl = process.env.BASE_URL;
const ai = new GoogleGenAI({
apiKey: apiKey || '',
...(baseUrl && { httpOptions: { baseUrl } })
});
/**
* Helper to extract MIME type and base64 data from a data URL.
*/
const extractBase64Data = (base64String: string): { mimeType: string; data: string } => {
// Regex to capture mime type and data
const matches = base64String.match(/^data:([a-zA-Z0-9]+\/[a-zA-Z0-9-.+]+);base64,(.+)$/);
if (matches && matches.length === 3) {
return {
mimeType: matches[1],
data: matches[2]
};
}
// Fallback for strings that might just be the base64 data or simple split
const split = base64String.split(',');
return {
mimeType: 'image/png', // Default fallback
data: split.length > 1 ? split[1] : split[0]
};
};
/**
* Determines the best aspect ratio for the requested grid layout.
*
* 2x2 -> 1:1 (Square)
* 2x3 -> 16:9 (Wide) - 4:3 is okay too, but 16:9 gives more width for separation
* 2x4 -> 16:9 (Wide)
* 3x3 -> 1:1 (Square)
*/
const getBestAspectRatio = (rows: number, cols: number): string => {
const ratio = cols / rows;
if (ratio >= 1.8) return "16:9"; // e.g. 2x4 = 2.0
if (ratio >= 1.3) return "4:3"; // e.g. 2x3 = 1.5 (16:9 is also fine here, but 4:3 works)
if (ratio <= 0.7) return "3:4";
if (ratio <= 0.5) return "9:16";
return "1:1"; // Default for 2x2, 3x3
};
/**
* Generate a sprite sheet using the original image and the action prompt.
* If no image is provided, generates from text description only.
*/
export const generateSpriteSheet = async (
originalBase64: string | null,
action: string,
rows: number = 2,
cols: number = 3,
characterDescription?: string
): Promise<string> => {
if (!apiKey) throw new Error("API Key not found");
const totalFrames = rows * cols;
const aspectRatio = getBestAspectRatio(rows, cols);
// Build prompt based on whether we have a reference image
const hasImage = originalBase64 !== null;
const prompt = hasImage
? `
Generate a clean 2D Sprite Sheet.
INPUT ACTION: ${action}
LAYOUT CONFIGURATION:
- GRID: ${rows} rows by ${cols} columns.
- TOTAL SPRITES: ${totalFrames}
- STYLE: Flat illustration, white background.
CRITICAL RULES:
1. **STRICT GRID**: You MUST draw exactly ${rows} rows and ${cols} columns.
2. **NO NUMBERS**: Do NOT include any numbers, text, arrows, or guide lines.
3. **ISOLATION**: Draw each character SMALLER than the grid cell. Leave clear white space around every character.
4. **NO OVERLAP**: Characters must NOT touch the imaginary grid lines.
5. **CONSISTENCY**: Keep the character size and proportions identical in every frame.
Output ONLY the image on a solid white background.
`
: `
Generate a clean 2D Sprite Sheet from scratch.
CHARACTER DESCRIPTION: ${characterDescription || '一个可爱的卡通角色'}
INPUT ACTION: ${action}
LAYOUT CONFIGURATION:
- GRID: ${rows} rows by ${cols} columns.
- TOTAL SPRITES: ${totalFrames}
- STYLE: Flat illustration, white background, 2D cartoon style.
CRITICAL RULES:
1. **STRICT GRID**: You MUST draw exactly ${rows} rows and ${cols} columns.
2. **NO NUMBERS**: Do NOT include any numbers, text, arrows, or guide lines.
3. **ISOLATION**: Draw each character SMALLER than the grid cell. Leave clear white space around every character.
4. **NO OVERLAP**: Characters must NOT touch the imaginary grid lines.
5. **CONSISTENCY**: Keep the character size and proportions identical in every frame.
6. **ANIMATION FLOW**: Each frame should show a smooth progression of the action.
Output ONLY the image on a solid white background.
`;
// Build content parts
const parts: any[] = [];
if (hasImage) {
const { mimeType, data } = extractBase64Data(originalBase64);
parts.push({
inlineData: {
mimeType: mimeType,
data: data
}
});
}
parts.push({ text: prompt });
try {
const response = await ai.models.generateContent({
model: 'gemini-2.5-flash-image',
contents: {
parts: parts,
},
config: {
temperature: 0.2,
imageConfig: {
aspectRatio: aspectRatio
}
}
});
// Extract the image from the response
for (const part of response.candidates?.[0]?.content?.parts || []) {
if (part.inlineData) {
return `data:image/png;base64,${part.inlineData.data}`;
}
}
const textPart = response.candidates?.[0]?.content?.parts?.find(p => p.text);
if (textPart && textPart.text) {
console.warn("Model returned text instead of image:", textPart.text);
throw new Error("生成失败:模型拒绝了请求 (可能是安全策略)");
}
throw new Error("生成失败:未返回图像数据");
} catch (error: any) {
console.error("Generation failed", error);
throw new Error(error.message || "生成失败,请重试");
}
};
/**
* Generate a character portrait/illustration from text description.
*/
export const generateCharacterPortrait = async (
characterDescription: string,
style: string = '2D卡通风格',
aspectRatio: '1:1' | '3:4' | '9:16' = '3:4'
): Promise<string> => {
if (!apiKey) throw new Error("API Key not found");
const prompt = `
Generate a single character portrait illustration.
CHARACTER DESCRIPTION: ${characterDescription}
ART STYLE: ${style}
REQUIREMENTS:
1. Full body or upper body character portrait
2. Clean, professional illustration style
3. Transparent or solid color background
4. High quality, detailed artwork
5. The character should be centered in the image
6. No text, watermarks, or extra elements
Output a single, high-quality character illustration.
`;
try {
const response = await ai.models.generateContent({
model: 'gemini-2.5-flash-image',
contents: {
parts: [{ text: prompt }],
},
config: {
temperature: 0.7,
imageConfig: {
aspectRatio: aspectRatio
}
}
});
for (const part of response.candidates?.[0]?.content?.parts || []) {
if (part.inlineData) {
return `data:image/png;base64,${part.inlineData.data}`;
}
}
const textPart = response.candidates?.[0]?.content?.parts?.find(p => p.text);
if (textPart && textPart.text) {
console.warn("Model returned text instead of image:", textPart.text);
throw new Error("生成失败:模型拒绝了请求 (可能是安全策略)");
}
throw new Error("生成失败:未返回图像数据");
} catch (error: any) {
console.error("Portrait generation failed", error);
throw new Error(error.message || "立绘生成失败,请重试");
}
};
/**
* Generate a 4-direction sprite sheet (4 rows x 4 cols).
* Each row represents a direction: Down, Left, Right, Up
* Each row has 4 animation frames for walking/running.
*/
export const generateDirectionalSpriteSheet = async (
originalBase64: string | null,
action: string = '行走',
characterDescription?: string
): Promise<string> => {
if (!apiKey) throw new Error("API Key not found");
const hasImage = originalBase64 !== null;
const prompt = hasImage
? `
Generate a 4-direction character sprite sheet for game development.
INPUT ACTION: ${action}
LAYOUT CONFIGURATION:
- GRID: 4 rows by 4 columns (16 sprites total)
- Row 1: Downward walking animation - Character facing down the screen, 4 consecutive frames
- Row 2: Leftward walking animation - Character facing left of the screen, 4 consecutive frames
- Row 3: Rightward walking animation - Character facing right of the screen, 4 consecutive frames
- Row 4: Upward walking animation - Character facing up the screen, 4 consecutive frames
- STYLE: Flat illustration, white background, pixel art or 2D game style.
CRITICAL RULES:
1. **STRICT GRID**: You MUST draw exactly 4 rows and 4 columns.
2. **DIRECTION ORDER**: Down, Left, Right, Up (top to bottom).
3. **NO NUMBERS/TEXT**: Do NOT include any numbers, text, arrows, or guide lines.
4. **ISOLATION**: Draw each character SMALLER than the grid cell. Leave clear white space around every character.
5. **NO OVERLAP**: Characters must NOT touch the imaginary grid lines.
6. **CONSISTENCY**: Keep the character size and proportions identical in every frame.
7. **ANIMATION FLOW**: Each row shows smooth walking/running animation in that direction.
Output ONLY the image on a solid white background.
`
: `
Generate a 4-direction character sprite sheet for game development from scratch.
CHARACTER DESCRIPTION: ${characterDescription || '一个可爱的卡通角色'}
INPUT ACTION: ${action}
LAYOUT CONFIGURATION:
- GRID: 4 rows by 4 columns (16 sprites total)
- Row 1: Downward walking animation - Character facing down the screen, 4 consecutive frames
- Row 2: Leftward walking animation - Character facing left of the screen, 4 consecutive frames
- Row 3: Rightward walking animation - Character facing right of the screen, 4 consecutive frames
- Row 4: Upward walking animation - Character facing up the screen, 4 consecutive frames
- STYLE: Flat illustration, white background, 2D cartoon/game style.
CRITICAL RULES:
1. **STRICT GRID**: You MUST draw exactly 4 rows and 4 columns.
2. **DIRECTION ORDER**: Down, Left, Right, Up (top to bottom).
3. **NO NUMBERS/TEXT**: Do NOT include any numbers, text, arrows, or guide lines.
4. **ISOLATION**: Draw each character SMALLER than the grid cell. Leave clear white space around every character.
5. **NO OVERLAP**: Characters must NOT touch the imaginary grid lines.
6. **CONSISTENCY**: Keep the character size and proportions identical in every frame.
7. **ANIMATION FLOW**: Each row shows smooth walking/running animation in that direction.
Output ONLY the image on a solid white background.
`;
const parts: any[] = [];
if (hasImage) {
const { mimeType, data } = extractBase64Data(originalBase64);
parts.push({
inlineData: {
mimeType: mimeType,
data: data
}
});
}
parts.push({ text: prompt });
try {
const response = await ai.models.generateContent({
model: 'gemini-2.5-flash-image',
contents: {
parts: parts,
},
config: {
temperature: 0.2,
imageConfig: {
aspectRatio: "1:1"
}
}
});
for (const part of response.candidates?.[0]?.content?.parts || []) {
if (part.inlineData) {
return `data:image/png;base64,${part.inlineData.data}`;
}
}
const textPart = response.candidates?.[0]?.content?.parts?.find(p => p.text);
if (textPart && textPart.text) {
console.warn("Model returned text instead of image:", textPart.text);
throw new Error("生成失败:模型拒绝了请求 (可能是安全策略)");
}
throw new Error("生成失败:未返回图像数据");
} catch (error: any) {
console.error("Directional sprite sheet generation failed", error);
throw new Error(error.message || "序列帧生成失败,请重试");
}
};
/**
* Map types for game map generation
*/
export type MapType = 'rpg' | 'tilemap' | 'platformer' | 'strategy' | 'world';
/**
* Generate game maps of various types
*/
export const generateGameMap = async (
mapType: MapType,
description: string,
style: string = '像素风格'
): Promise<string> => {
if (!apiKey) throw new Error("API Key not found");
const mapPrompts: Record<MapType, string> = {
rpg: `
Generate a top-down RPG game map scene.
SCENE DESCRIPTION: ${description}
ART STYLE: ${style}
REQUIREMENTS:
- Top-down/bird's eye view perspective
- Include terrain features (grass, paths, water, trees, buildings)
- Game-ready art style with clear boundaries
- Rich details but not cluttered
- Suitable for character movement
- No UI elements, text, or markers
Output a single cohesive map image.
`,
tilemap: `
Generate a tilemap sprite sheet for game development.
THEME: ${description}
ART STYLE: ${style}
LAYOUT: 8x8 grid of tiles (64 tiles total)
TILE CATEGORIES TO INCLUDE:
- Ground tiles (grass, dirt, sand, stone)
- Water tiles (with edges/transitions)
- Path/road tiles (straight, corners, intersections)
- Decoration tiles (flowers, rocks, bushes)
- Building tiles (walls, roofs, doors, windows)
- Tree/forest tiles
- Transition tiles between different terrains
REQUIREMENTS:
- Each tile must be clearly separated
- Tiles should be seamlessly tileable
- Consistent art style across all tiles
- White or transparent gaps between tiles
- No text, numbers, or labels
Output ONLY the tilemap sprite sheet.
`,
platformer: `
Generate a side-scrolling platformer game level background.
SCENE DESCRIPTION: ${description}
ART STYLE: ${style}
REQUIREMENTS:
- Side view perspective
- Multiple parallax layers suggested (foreground, midground, background)
- Include platforms, obstacles, and environmental elements
- Clear ground/floor area for character to walk on
- Atmospheric and immersive
- Game-ready, not too detailed to distract from gameplay
- No characters, UI, or text
Output a wide panoramic scene image.
`,
strategy: `
Generate a grid-based strategy/tactics game battle map.
SCENE DESCRIPTION: ${description}
ART STYLE: ${style}
REQUIREMENTS:
- Clear grid pattern (hexagonal or square)
- Various terrain types with different tactical value
- Include obstacles, cover positions, high ground
- Bird's eye view perspective
- Balanced layout suitable for tactical combat
- Clear visual distinction between terrain types
- No units, characters, or UI elements
Output a tactical battle map image.
`,
world: `
Generate a fantasy world map / continent overview.
DESCRIPTION: ${description}
ART STYLE: ${style}
REQUIREMENTS:
- Cartographic/illustrated map style
- Include continents, islands, oceans
- Show mountain ranges, forests, deserts, rivers
- Mark major cities/locations with simple icons (no text)
- Compass rose or decorative border optional
- Fantasy/medieval cartography aesthetic
- Parchment or clean background
- No modern elements
Output a complete world map image.
`
};
const prompt = mapPrompts[mapType];
// Choose aspect ratio based on map type
const aspectRatios: Record<MapType, string> = {
rpg: '1:1',
tilemap: '1:1',
platformer: '16:9',
strategy: '1:1',
world: '4:3'
};
try {
const response = await ai.models.generateContent({
model: 'gemini-2.5-flash-image',
contents: {
parts: [{ text: prompt }],
},
config: {
temperature: 0.7,
imageConfig: {
aspectRatio: aspectRatios[mapType]
}
}
});
for (const part of response.candidates?.[0]?.content?.parts || []) {
if (part.inlineData) {
return `data:image/png;base64,${part.inlineData.data}`;
}
}
const textPart = response.candidates?.[0]?.content?.parts?.find(p => p.text);
if (textPart && textPart.text) {
console.warn("Model returned text instead of image:", textPart.text);
throw new Error("生成失败:模型拒绝了请求 (可能是安全策略)");
}
throw new Error("生成失败:未返回图像数据");
} catch (error: any) {
console.error("Map generation failed", error);
throw new Error(error.message || "地图生成失败,请重试");
}
};
/**
* Item types for game item generation
*/
export type ItemType = 'weapon' | 'armor' | 'potion' | 'material' | 'treasure' | 'accessory';
/**
* Generate game items (weapons, armor, potions, etc.)
*/
export const generateGameItem = async (
itemType: ItemType,
description: string,
style: string = '像素风格',
quantity: 'single' | 'set' = 'single'
): Promise<string> => {
if (!apiKey) throw new Error("API Key not found");
const itemPrompts: Record<ItemType, string> = {
weapon: `
Generate game weapon sprite${quantity === 'set' ? 's (6 variations in a 2x3 grid)' : ''}.
WEAPON DESCRIPTION: ${description}
ART STYLE: ${style}
${quantity === 'set' ? `
LAYOUT: 2 rows x 3 columns grid (6 weapons total)
IMPORTANT: All 6 weapons must be the SAME TYPE as described above.
Generate 6 variations/upgrades of "${description}" - different colors, materials, enchantments, or quality levels.
Example: If description is "fire sword", generate 6 different fire swords with varying flame effects.
` : ''}
REQUIREMENTS:
- Clean isolated weapon on transparent/white background
- Game-ready icon style
- Clear silhouette and details
- Consistent lighting (top-left light source)
- No hands, characters, or extra elements
- Professional game asset quality
Output ${quantity === 'set' ? 'a grid of 6 similar weapons with variations' : 'a single weapon'} image.
`,
armor: `
Generate game armor/equipment sprite${quantity === 'set' ? 's (6 variations in a 2x3 grid)' : ''}.
ARMOR DESCRIPTION: ${description}
ART STYLE: ${style}
${quantity === 'set' ? `
LAYOUT: 2 rows x 3 columns grid (6 pieces total)
IMPORTANT: All 6 pieces must be the SAME TYPE as described above.
Generate 6 variations/upgrades of "${description}" - different colors, materials, or quality levels.
Example: If description is "knight helmet", generate 6 different knight helmets with varying designs.
` : ''}
REQUIREMENTS:
- Clean isolated equipment on transparent/white background
- Game-ready icon style
- Clear details and textures
- Consistent lighting
- No characters wearing them
- Professional game asset quality
Output ${quantity === 'set' ? 'a grid of 6 similar armor pieces with variations' : 'a single armor piece'} image.
`,
potion: `
Generate game potion/consumable sprite${quantity === 'set' ? 's (6 variations in a 2x3 grid)' : ''}.
ITEM DESCRIPTION: ${description}
ART STYLE: ${style}
${quantity === 'set' ? `
LAYOUT: 2 rows x 3 columns grid (6 items total)
IMPORTANT: All 6 items must be the SAME TYPE as described above.
Generate 6 variations of "${description}" - different sizes, fill levels, or potency indicators.
Example: If description is "health potion", generate 6 health potions (small/medium/large, different fill levels).
` : ''}
REQUIREMENTS:
- Clean isolated items on transparent/white background
- Game-ready icon style
- Glowing/magical effects where appropriate
- Clear bottle/container shapes
- Professional game asset quality
Output ${quantity === 'set' ? 'a grid of 6 similar consumables with variations' : 'a single consumable'} image.
`,
material: `
Generate game crafting material sprite${quantity === 'set' ? 's (6 variations in a 2x3 grid)' : ''}.
MATERIAL DESCRIPTION: ${description}
ART STYLE: ${style}
${quantity === 'set' ? `
LAYOUT: 2 rows x 3 columns grid (6 materials total)
IMPORTANT: All 6 materials must be the SAME TYPE as described above.
Generate 6 variations of "${description}" - different quantities, quality levels, or processing stages.
Example: If description is "iron ore", generate 6 iron ores (raw/refined, different sizes, different purities).
` : ''}
REQUIREMENTS:
- Clean isolated materials on transparent/white background
- Game-ready icon style
- Natural textures and details
- Clear identification of material type
- Professional game asset quality
Output ${quantity === 'set' ? 'a grid of 6 similar materials with variations' : 'a single material'} image.
`,
treasure: `
Generate game treasure/container sprite${quantity === 'set' ? 's (6 variations in a 2x3 grid)' : ''}.
TREASURE DESCRIPTION: ${description}
ART STYLE: ${style}
${quantity === 'set' ? `
LAYOUT: 2 rows x 3 columns grid (6 items total)
IMPORTANT: All 6 items must be the SAME TYPE as described above.
Generate 6 variations of "${description}" - different states, sizes, or value levels.
Example: If description is "treasure chest", generate 6 treasure chests (closed/open, wooden/golden, small/large).
` : ''}
REQUIREMENTS:
- Clean isolated items on transparent/white background
- Game-ready icon style
- Rich, valuable appearance
- Gold/metallic sheen where appropriate
- Professional game asset quality
Output ${quantity === 'set' ? 'a grid of 6 similar treasures with variations' : 'a single treasure'} image.
`,
accessory: `
Generate game accessory sprite${quantity === 'set' ? 's (6 variations in a 2x3 grid)' : ''}.
ACCESSORY DESCRIPTION: ${description}
ART STYLE: ${style}
${quantity === 'set' ? `
LAYOUT: 2 rows x 3 columns grid (6 items total)
IMPORTANT: All 6 items must be the SAME TYPE as described above.
Generate 6 variations of "${description}" - different colors, gem types, or enchantment effects.
Example: If description is "magic ring", generate 6 magic rings with different gem colors and effects.
` : ''}
REQUIREMENTS:
- Clean isolated accessories on transparent/white background
- Game-ready icon style
- Magical glow effects where appropriate
- Fine details on jewelry
- Professional game asset quality
Output ${quantity === 'set' ? 'a grid of 6 similar accessories with variations' : 'a single accessory'} image.
`
};
const prompt = itemPrompts[itemType];
try {
const response = await ai.models.generateContent({
model: 'gemini-2.5-flash-image',
contents: {
parts: [{ text: prompt }],
},
config: {
temperature: 0.7,
imageConfig: {
aspectRatio: quantity === 'set' ? '4:3' : '1:1'
}
}
});
for (const part of response.candidates?.[0]?.content?.parts || []) {
if (part.inlineData) {
return `data:image/png;base64,${part.inlineData.data}`;
}
}
const textPart = response.candidates?.[0]?.content?.parts?.find(p => p.text);
if (textPart && textPart.text) {
console.warn("Model returned text instead of image:", textPart.text);
throw new Error("生成失败:模型拒绝了请求 (可能是安全策略)");
}
throw new Error("生成失败:未返回图像数据");
} catch (error: any) {
console.error("Item generation failed", error);
throw new Error(error.message || "道具生成失败,请重试");
}
};

29
tsconfig.json Normal file
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
}
}

22
types.ts Normal file
View File

@ -0,0 +1,22 @@
export interface AnimationFrame {
id: string;
dataUrl: string;
}
export enum AppState {
IDLE = 'IDLE',
READY_TO_GENERATE = 'READY_TO_GENERATE',
GENERATING = 'GENERATING',
COMPLETE = 'COMPLETE',
ERROR = 'ERROR',
}
export interface GenerationConfig {
fps: number;
removeBackground: boolean;
}
export interface CharacterData {
originalImage: string;
description: string;
}

358
utils/imageUtils.ts Normal file
View File

@ -0,0 +1,358 @@
import JSZip from 'jszip';
import { GIFEncoder, quantize, applyPalette } from 'gifenc';
import UPNG from 'upng-js';
/**
* Native implementation to save a blob as a file
*/
const saveAs = (blob: Blob, filename: string) => {
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.style.display = 'none';
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
};
/**
* Converts a File object to a Base64 string.
*/
export const fileToBase64 = (file: File): Promise<string> => {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = () => resolve(reader.result as string);
reader.onerror = (error) => reject(error);
});
};
/**
* Helper to load an image from base64
*/
const loadImage = (src: string): Promise<HTMLImageElement> => {
return new Promise((resolve, reject) => {
const img = new Image();
img.src = src;
img.onload = () => resolve(img);
img.onerror = reject;
});
};
/**
* Finds the bounding box of visible content in a canvas context
*/
const getContentBounds = (ctx: CanvasRenderingContext2D, width: number, height: number) => {
const imageData = ctx.getImageData(0, 0, width, height);
const data = imageData.data;
let minX = width, minY = height, maxX = 0, maxY = 0;
let found = false;
// Threshold for "content".
// We treat anything not fully transparent AND not near-white as content.
const whiteThreshold = 240;
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const i = (y * width + x) * 4;
const alpha = data[i + 3];
const r = data[i];
const g = data[i + 1];
const b = data[i + 2];
// Check if pixel is visible
if (alpha > 20) {
// Check if it's NOT white (background)
if (r < whiteThreshold || g < whiteThreshold || b < whiteThreshold) {
if (x < minX) minX = x;
if (x > maxX) maxX = x;
if (y < minY) minY = y;
if (y > maxY) maxY = y;
found = true;
}
}
}
}
if (!found) return null;
return { x: minX, y: minY, w: maxX - minX + 1, h: maxY - minY + 1 };
};
/**
* Processes a list of raw grid cells:
* 1. Finds the specific character bounds inside each cell (removes inner whitespace).
* 2. Determines the max character size across all frames.
* 3. Creates new frames where the character is centered and maximized.
*/
const alignAndMaximizeFrames = (frames: HTMLCanvasElement[]): string[] => {
// 1. Get bounds for all frames (Crop to character)
const bounds = frames.map(frame => {
const ctx = frame.getContext('2d');
if (!ctx) return null;
return getContentBounds(ctx, frame.width, frame.height);
});
// 2. Find max dimensions to ensure uniform frame size
let maxContentW = 0;
let maxContentH = 0;
bounds.forEach(b => {
if (b) {
maxContentW = Math.max(maxContentW, b.w);
maxContentH = Math.max(maxContentH, b.h);
}
});
if (maxContentW === 0 || maxContentH === 0) return [];
// Add very minimal padding (2px) just to avoid edge clipping
const padding = 2;
const finalW = maxContentW + padding * 2;
const finalH = maxContentH + padding * 2;
const resultBase64: string[] = [];
frames.forEach((frame, i) => {
const bound = bounds[i];
const canvas = document.createElement('canvas');
canvas.width = finalW;
canvas.height = finalH;
const ctx = canvas.getContext('2d');
if (ctx && bound) {
// Draw content from source frame (using bound coordinates) to center of new canvas
const targetX = (finalW - bound.w) / 2;
const targetY = (finalH - bound.h) / 2;
ctx.drawImage(
frame,
bound.x, bound.y, bound.w, bound.h, // Source crop
targetX, targetY, bound.w, bound.h // Destination location
);
resultBase64.push(canvas.toDataURL('image/png'));
} else if (ctx) {
// Empty frame? Keep it transparent
resultBase64.push(canvas.toDataURL('image/png'));
}
});
return resultBase64;
};
/**
* Strict Fixed Grid Slicing.
* 1. Finds the global content box of the entire sprite sheet (trims outer margins).
* 2. Divides that box into strictly equal rows and columns.
* 3. Extracts cells.
* 4. Sends to alignAndMaximizeFrames for final polish.
*/
const performFixedGridSlicing = (canvas: HTMLCanvasElement, rows: number, cols: number, removeBg: boolean): string[] => {
const ctx = canvas.getContext('2d');
if (!ctx) return [];
// 1. Trim the sprite sheet first to remove outer whitespace
const globalBounds = getContentBounds(ctx, canvas.width, canvas.height);
let sourceX = 0, sourceY = 0;
let sourceW = canvas.width, sourceH = canvas.height;
// If we found content, strictly use that area as the "Grid Area"
if (globalBounds) {
sourceX = globalBounds.x;
sourceY = globalBounds.y;
sourceW = globalBounds.w;
sourceH = globalBounds.h;
}
// Calculate cell size based on the content area
const frameWidth = sourceW / cols;
const frameHeight = sourceH / rows;
const rawCanvases: HTMLCanvasElement[] = [];
// Safety Shave: How many pixels to clear from edges to avoid neighbor artifacts
// We keep this small shave to prevent single-pixel bleeding, but rely on Prompt for main separation.
const safetyShave = 4;
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
const cellCanvas = document.createElement('canvas');
// Use ceil to avoid sub-pixel clipping issues
cellCanvas.width = Math.ceil(frameWidth);
cellCanvas.height = Math.ceil(frameHeight);
const cellCtx = cellCanvas.getContext('2d');
if (cellCtx) {
cellCtx.drawImage(
canvas,
sourceX + c * frameWidth, // Precision float coordinate
sourceY + r * frameHeight,
frameWidth,
frameHeight,
0,
0,
cellCanvas.width,
cellCanvas.height
);
// --- SAFETY CLEANING STEP ---
// Shave edges to prevent "feet/head" overlap from neighbors if the AI draws slightly too big
cellCtx.clearRect(0, 0, cellCanvas.width, safetyShave); // Top edge
cellCtx.clearRect(0, cellCanvas.height - safetyShave, cellCanvas.width, safetyShave); // Bottom edge
cellCtx.clearRect(0, 0, safetyShave, cellCanvas.height); // Left edge
cellCtx.clearRect(cellCanvas.width - safetyShave, 0, safetyShave, cellCanvas.height); // Right edge
// NOTE: We removed the specific corner clearing (50x40) for numbers as requested.
// We now rely on the AI Prompt to strictly forbid numbers.
if (removeBg) {
const imageData = cellCtx.getImageData(0, 0, cellCanvas.width, cellCanvas.height);
const data = imageData.data;
const threshold = 230;
for (let i = 0; i < data.length; i += 4) {
// Simple white removal
if (data[i] > threshold && data[i + 1] > threshold && data[i + 2] > threshold) {
data[i + 3] = 0;
}
}
cellCtx.putImageData(imageData, 0, 0);
}
rawCanvases.push(cellCanvas);
}
}
}
// Send raw grid cells to be cropped and centered
return alignAndMaximizeFrames(rawCanvases);
};
/**
* Main Slicing Function
*/
export const sliceSpriteSheet = (
spriteSheetBase64: string,
rows: number,
cols: number,
removeBg: boolean = false
): Promise<string[]> => {
return new Promise((resolve) => {
const img = new Image();
img.crossOrigin = "anonymous"; // Good practice
img.onload = () => {
const canvas = document.createElement('canvas');
canvas.width = img.width;
canvas.height = img.height;
const ctx = canvas.getContext('2d', { willReadFrequently: true });
if (!ctx) { resolve([]); return; }
ctx.drawImage(img, 0, 0);
// Force Fixed Grid Slicing.
// Smart slicing causes "two pics in one frame" errors when rows are close.
// We trust the AI followed the grid prompt.
const fixedFrames = performFixedGridSlicing(canvas, rows, cols, removeBg);
resolve(fixedFrames);
};
img.onerror = () => resolve([]);
img.src = spriteSheetBase64;
});
};
/**
* Download frames as a ZIP file
*/
export const downloadZip = async (frames: string[], filename: string = 'animation') => {
const zip = new JSZip();
const folder = zip.folder("frames");
frames.forEach((frame, index) => {
const base64Data = frame.split(',')[1];
folder?.file(`frame_${index + 1}.png`, base64Data, { base64: true });
});
const content = await zip.generateAsync({ type: "blob" });
saveAs(content, `${filename}.zip`);
};
/**
* Generate and download GIF
*/
export const downloadGif = async (frames: string[], fps: number, filename: string = 'animation') => {
if (frames.length === 0) return;
const firstImg = await loadImage(frames[0]);
const width = firstImg.width;
const height = firstImg.height;
const gif = GIFEncoder();
for (const frame of frames) {
const img = await loadImage(frame);
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
if (!ctx) continue;
ctx.drawImage(img, 0, 0);
const data = ctx.getImageData(0, 0, width, height).data;
const palette = quantize(data, 256);
const index = applyPalette(data, palette);
const delay = 1000 / fps;
gif.writeFrame(index, width, height, {
palette,
delay: delay,
transparent: true,
dispose: -1
});
}
gif.finish();
const blob = new Blob([gif.bytes()], { type: 'image/gif' });
saveAs(blob, `${filename}.gif`);
};
/**
* Generate and download APNG (Best Quality)
*/
export const downloadApng = async (frames: string[], fps: number, filename: string = 'animation') => {
if (frames.length === 0) return;
const buffers: ArrayBuffer[] = [];
let width = 0;
let height = 0;
for (const frame of frames) {
const frameImg = await loadImage(frame);
// Ensure all frames are the same size (they should be from alignAndMaximizeFrames)
// But if not, handle it gracefully by creating a new canvas of max dimensions
if (width === 0) {
width = frameImg.width;
height = frameImg.height;
}
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
if (!ctx) continue;
ctx.drawImage(frameImg, 0, 0, width, height);
const buffer = ctx.getImageData(0, 0, width, height).data.buffer;
buffers.push(buffer);
}
const delay = Math.round(1000 / fps);
const delays = new Array(buffers.length).fill(delay);
const apngBuffer = UPNG.encode(buffers, width, height, 0, delays);
const blob = new Blob([apngBuffer], { type: 'image/png' });
saveAs(blob, `${filename}.png`);
};

24
vite.config.ts Normal file
View File

@ -0,0 +1,24 @@
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),
'process.env.BASE_URL': JSON.stringify(env.BASE_URL)
},
resolve: {
alias: {
'@': path.resolve(__dirname, '.'),
}
}
};
});