first commit
This commit is contained in:
commit
91bcddce97
|
|
@ -0,0 +1,12 @@
|
||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(npm install)",
|
||||||
|
"Bash(ls:*)",
|
||||||
|
"Bash(cat:*)",
|
||||||
|
"Bash(npx tsc:*)",
|
||||||
|
"Bash(npm run dev:*)",
|
||||||
|
"Bash(timeout:*)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
|
|
@ -0,0 +1,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`
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
import React from 'react';
|
||||||
|
import ReactDOM from 'react-dom/client';
|
||||||
|
import App from './App';
|
||||||
|
|
||||||
|
const rootElement = document.getElementById('root');
|
||||||
|
if (!rootElement) {
|
||||||
|
throw new Error("Could not find root element to mount to");
|
||||||
|
}
|
||||||
|
|
||||||
|
const root = ReactDOM.createRoot(rootElement);
|
||||||
|
root.render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>
|
||||||
|
);
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
{
|
||||||
|
"name": "ToonMotion",
|
||||||
|
"description": "动图生产",
|
||||||
|
"requestFramePermissions": []
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 || "道具生成失败,请重试");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"experimentalDecorators": true,
|
||||||
|
"useDefineForClassFields": false,
|
||||||
|
"module": "ESNext",
|
||||||
|
"lib": [
|
||||||
|
"ES2022",
|
||||||
|
"DOM",
|
||||||
|
"DOM.Iterable"
|
||||||
|
],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"types": [
|
||||||
|
"node"
|
||||||
|
],
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"isolatedModules": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"allowJs": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"paths": {
|
||||||
|
"@/*": [
|
||||||
|
"./*"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"noEmit": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,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;
|
||||||
|
}
|
||||||
|
|
@ -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`);
|
||||||
|
};
|
||||||
|
|
@ -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, '.'),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue