You've already forked Nano-Banana-AI-Image-Editor
修复内存溢出问题
This commit is contained in:
79
debug/test_cleanup.js
Normal file
79
debug/test_cleanup.js
Normal file
@@ -0,0 +1,79 @@
|
||||
// 测试清理功能的脚本
|
||||
async function testCleanup() {
|
||||
try {
|
||||
// 打开数据库
|
||||
const request = indexedDB.open('NanoBananaDB', 1);
|
||||
|
||||
request.onsuccess = function(event) {
|
||||
const db = event.target.result;
|
||||
|
||||
// 读取生成记录
|
||||
const transaction = db.transaction(['generations'], 'readonly');
|
||||
const store = transaction.objectStore('generations');
|
||||
const getAllRequest = store.getAll();
|
||||
|
||||
getAllRequest.onsuccess = function(event) {
|
||||
const generations = event.target.result;
|
||||
console.log('生成记录数量:', generations.length);
|
||||
|
||||
// 检查是否有base64数据
|
||||
let base64Count = 0;
|
||||
for (const generation of generations) {
|
||||
for (const asset of generation.sourceAssets || []) {
|
||||
if (asset.url && asset.url.startsWith('data:')) {
|
||||
console.log('发现base64源资产:', asset.url.substring(0, 50) + '...');
|
||||
base64Count++;
|
||||
}
|
||||
}
|
||||
for (const asset of generation.outputAssets || []) {
|
||||
if (asset.url && asset.url.startsWith('data:')) {
|
||||
console.log('发现base64输出资产:', asset.url.substring(0, 50) + '...');
|
||||
base64Count++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log('总共发现base64资产数量:', base64Count);
|
||||
|
||||
// 读取编辑记录
|
||||
const editTransaction = db.transaction(['edits'], 'readonly');
|
||||
const editStore = editTransaction.objectStore('edits');
|
||||
const getAllEditsRequest = editStore.getAll();
|
||||
|
||||
getAllEditsRequest.onsuccess = function(event) {
|
||||
const edits = event.target.result;
|
||||
console.log('编辑记录数量:', edits.length);
|
||||
|
||||
// 检查是否有base64数据
|
||||
let editBase64Count = 0;
|
||||
for (const edit of edits) {
|
||||
if (edit.maskReferenceAsset && edit.maskReferenceAsset.url && edit.maskReferenceAsset.url.startsWith('data:')) {
|
||||
console.log('发现base64遮罩参考资产:', edit.maskReferenceAsset.url.substring(0, 50) + '...');
|
||||
editBase64Count++;
|
||||
}
|
||||
for (const asset of edit.outputAssets || []) {
|
||||
if (asset.url && asset.url.startsWith('data:')) {
|
||||
console.log('发现base64编辑输出资产:', asset.url.substring(0, 50) + '...');
|
||||
editBase64Count++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log('编辑记录中总共发现base64资产数量:', editBase64Count);
|
||||
console.log('清理前总共base64资产数量:', base64Count + editBase64Count);
|
||||
|
||||
db.close();
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
request.onerror = function(event) {
|
||||
console.error('打开数据库失败:', event.target.error);
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('测试过程中出错:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 运行测试
|
||||
testCleanup();
|
||||
24
src/App.tsx
24
src/App.tsx
@@ -24,13 +24,15 @@ function AppContent() {
|
||||
|
||||
const { showPromptPanel, setShowPromptPanel, setShowHistory } = useAppStore();
|
||||
|
||||
// 在挂载时初始化IndexedDB
|
||||
// 在挂载时初始化IndexedDB并清理base64数据
|
||||
useEffect(() => {
|
||||
const init = async () => {
|
||||
try {
|
||||
await indexedDBService.initDB();
|
||||
// 清理已有的base64数据
|
||||
await indexedDBService.cleanupBase64Data();
|
||||
} catch (err) {
|
||||
console.error('初始化IndexedDB失败:', err);
|
||||
console.error('初始化IndexedDB或清理base64数据失败:', err);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -52,6 +54,24 @@ function AppContent() {
|
||||
return () => window.removeEventListener('resize', checkMobile);
|
||||
}, [setShowPromptPanel, setShowHistory]);
|
||||
|
||||
// 定期清理旧的历史记录
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
useAppStore.getState().cleanupOldHistory();
|
||||
}, 30000); // 每30秒清理一次
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
// 定期清理未使用的Blob URL
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
useAppStore.getState().scheduleBlobCleanup();
|
||||
}, 60000); // 每分钟清理一次
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="h-screen bg-gray-50 text-gray-900 flex flex-col font-sans">
|
||||
<div className="card card-lg rounded-none">
|
||||
|
||||
@@ -61,10 +61,10 @@ export const HistoryPanel: React.FC = () => {
|
||||
|
||||
// 分页状态
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const itemsPerPage = 30; // 每页显示的项目数
|
||||
const itemsPerPage = 20; // 减少每页显示的项目数
|
||||
|
||||
// 悬浮预览状态
|
||||
const [hoveredImage, setHoveredImage] = useState<{url: string, title: string, width?: number, height?: number, size?: number} | null>(null);
|
||||
const [hoveredImage, setHoveredImage] = useState<{url: string, title: string, width?: number, height?: number} | null>(null);
|
||||
const [previewPosition, setPreviewPosition] = useState<{x: number, y: number}>({x: 0, y: 0});
|
||||
|
||||
const generations = currentProject?.generations || [];
|
||||
@@ -76,7 +76,7 @@ export const HistoryPanel: React.FC = () => {
|
||||
const uploadResult = generationOrEdit.uploadResults[index];
|
||||
if (uploadResult.success && uploadResult.url) {
|
||||
// 添加参数以降低图片质量
|
||||
return `${uploadResult.url}?x-oss-process=image/quality,q_50`;
|
||||
return `${uploadResult.url}?x-oss-process=image/quality,q_30`; // 降低质量到30%
|
||||
}
|
||||
}
|
||||
return null;
|
||||
@@ -480,7 +480,7 @@ export const HistoryPanel: React.FC = () => {
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h4 className="text-xs font-medium text-gray-500 uppercase tracking-wide">变体</h4>
|
||||
<span className="text-xs text-gray-400">
|
||||
{filteredGenerations.length + filteredEdits.length}/1000
|
||||
{filteredGenerations.length + filteredEdits.length}/100
|
||||
</span>
|
||||
</div>
|
||||
{filteredGenerations.length === 0 && filteredEdits.length === 0 ? (
|
||||
@@ -530,25 +530,16 @@ export const HistoryPanel: React.FC = () => {
|
||||
// 创建图像对象以获取尺寸
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
// 计算文件大小(仅对base64数据)
|
||||
let size = 0;
|
||||
if (imageUrl.startsWith('data:')) {
|
||||
// 估算base64数据大小
|
||||
const base64Data = imageUrl.split(',')[1];
|
||||
size = Math.round((base64Data.length * 3) / 4);
|
||||
}
|
||||
|
||||
setHoveredImage({
|
||||
url: imageUrl,
|
||||
title: `生成记录 G${globalIndex + 1}`,
|
||||
width: img.width,
|
||||
height: img.height,
|
||||
size: size
|
||||
height: img.height
|
||||
});
|
||||
|
||||
// 计算预览位置,确保不超出屏幕边界
|
||||
const previewWidth = 500;
|
||||
const previewHeight = 500;
|
||||
const previewWidth = 300; // 减小预览窗口大小
|
||||
const previewHeight = 300;
|
||||
const offsetX = 10;
|
||||
const offsetY = 10;
|
||||
|
||||
@@ -596,13 +587,12 @@ export const HistoryPanel: React.FC = () => {
|
||||
url: imageUrl,
|
||||
title: `生成记录 G${globalIndex + 1}`,
|
||||
width: 0,
|
||||
height: 0,
|
||||
size: 0
|
||||
height: 0
|
||||
});
|
||||
|
||||
// 计算预览位置
|
||||
const previewWidth = 500;
|
||||
const previewHeight = 500;
|
||||
const previewWidth = 300;
|
||||
const previewHeight = 300;
|
||||
const offsetX = 10;
|
||||
const offsetY = 10;
|
||||
|
||||
@@ -641,8 +631,8 @@ export const HistoryPanel: React.FC = () => {
|
||||
}}
|
||||
onMouseMove={(e) => {
|
||||
// 调整预览位置以避免被遮挡
|
||||
const previewWidth = 500;
|
||||
const previewHeight = 500;
|
||||
const previewWidth = 300;
|
||||
const previewHeight = 300;
|
||||
const offsetX = 10;
|
||||
const offsetY = 10;
|
||||
|
||||
@@ -688,9 +678,9 @@ export const HistoryPanel: React.FC = () => {
|
||||
}}
|
||||
>
|
||||
{(() => {
|
||||
// 优先使用上传后的远程链接,如果没有则使用原始链接
|
||||
// 优先使用上传后的远程链接
|
||||
const imageUrl = getUploadedImageUrl(generation, 0) ||
|
||||
(generation.outputAssets && generation.outputAssets.length > 0 ? generation.outputAssets[0].url : null);
|
||||
(generation.outputAssets && generation.outputAssets.length > 0 && generation.outputAssets[0].url ? generation.outputAssets[0].url : null);
|
||||
|
||||
if (imageUrl) {
|
||||
return <img src={imageUrl} alt="生成的变体" className="w-full h-full object-cover" />;
|
||||
@@ -757,25 +747,16 @@ export const HistoryPanel: React.FC = () => {
|
||||
// 创建图像对象以获取尺寸
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
// 计算文件大小(仅对base64数据)
|
||||
let size = 0;
|
||||
if (imageUrl.startsWith('data:')) {
|
||||
// 估算base64数据大小
|
||||
const base64Data = imageUrl.split(',')[1];
|
||||
size = Math.round((base64Data.length * 3) / 4);
|
||||
}
|
||||
|
||||
setHoveredImage({
|
||||
url: imageUrl,
|
||||
title: `编辑记录 E${globalIndex + 1}`,
|
||||
width: img.width,
|
||||
height: img.height,
|
||||
size: size
|
||||
height: img.height
|
||||
});
|
||||
|
||||
// 计算预览位置,确保不超出屏幕边界
|
||||
const previewWidth = 500;
|
||||
const previewHeight = 500;
|
||||
const previewWidth = 300;
|
||||
const previewHeight = 300;
|
||||
const offsetX = 10;
|
||||
const offsetY = 10;
|
||||
|
||||
@@ -816,13 +797,12 @@ export const HistoryPanel: React.FC = () => {
|
||||
url: imageUrl,
|
||||
title: `编辑记录 E${globalIndex + 1}`,
|
||||
width: 0,
|
||||
height: 0,
|
||||
size: 0
|
||||
height: 0
|
||||
});
|
||||
|
||||
// 计算预览位置
|
||||
const previewWidth = 500;
|
||||
const previewHeight = 500;
|
||||
const previewWidth = 300;
|
||||
const previewHeight = 300;
|
||||
const offsetX = 10;
|
||||
const offsetY = 10;
|
||||
|
||||
@@ -865,8 +845,8 @@ export const HistoryPanel: React.FC = () => {
|
||||
}}
|
||||
onMouseMove={(e) => {
|
||||
// 调整预览位置以避免被遮挡
|
||||
const previewWidth = 500;
|
||||
const previewHeight = 500;
|
||||
const previewWidth = 300;
|
||||
const previewHeight = 300;
|
||||
const offsetX = 10;
|
||||
const offsetY = 10;
|
||||
|
||||
@@ -904,9 +884,9 @@ export const HistoryPanel: React.FC = () => {
|
||||
}}
|
||||
>
|
||||
{(() => {
|
||||
// 优先使用上传后的远程链接,如果没有则使用原始链接
|
||||
// 优先使用上传后的远程链接
|
||||
const imageUrl = getUploadedImageUrl(edit, 0) ||
|
||||
(edit.outputAssets && edit.outputAssets.length > 0 ? edit.outputAssets[0].url : null);
|
||||
(edit.outputAssets && edit.outputAssets.length > 0 && edit.outputAssets[0].url ? edit.outputAssets[0].url : null);
|
||||
|
||||
if (imageUrl) {
|
||||
return <img src={imageUrl} alt="编辑的变体" className="w-full h-full object-cover" />;
|
||||
@@ -1042,7 +1022,7 @@ export const HistoryPanel: React.FC = () => {
|
||||
// 获取上传后的远程链接(如果存在)
|
||||
// 参考图像在uploadResults中从索引1开始(索引0是生成的图像)
|
||||
const uploadedUrl = gen.uploadResults && gen.uploadResults[index + 1] && gen.uploadResults[index + 1].success
|
||||
? `${gen.uploadResults[index + 1].url}?x-oss-process=image/quality,q_50`
|
||||
? `${gen.uploadResults[index + 1].url}?x-oss-process=image/quality,q_30`
|
||||
: null;
|
||||
const displayUrl = uploadedUrl || asset.url;
|
||||
|
||||
@@ -1150,7 +1130,7 @@ export const HistoryPanel: React.FC = () => {
|
||||
// 获取上传后的远程链接(如果存在)
|
||||
// 参考图像在uploadResults中从索引1开始(索引0是生成的图像)
|
||||
const uploadedUrl = parentGen.uploadResults && parentGen.uploadResults[index + 1] && parentGen.uploadResults[index + 1].success
|
||||
? `${parentGen.uploadResults[index + 1].url}?x-oss-process=image/quality,q_50`
|
||||
? `${parentGen.uploadResults[index + 1].url}?x-oss-process=image/quality,q_30`
|
||||
: null;
|
||||
const displayUrl = uploadedUrl || asset.url;
|
||||
|
||||
@@ -1219,7 +1199,25 @@ export const HistoryPanel: React.FC = () => {
|
||||
|
||||
if (imageUrl) {
|
||||
// 处理数据URL和常规URL
|
||||
if (imageUrl.startsWith('data:')) {
|
||||
if (imageUrl.startsWith('data:') || imageUrl.startsWith('blob:')) {
|
||||
// 对于Blob URL,我们需要获取实际的Blob数据
|
||||
if (imageUrl.startsWith('blob:')) {
|
||||
// 从AppStore获取Blob
|
||||
const blob = useAppStore.getState().getBlob(imageUrl);
|
||||
if (blob) {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `nano-banana-${Date.now()}.png`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 对于数据URL,直接下载
|
||||
const link = document.createElement('a');
|
||||
link.href = imageUrl;
|
||||
link.download = `nano-banana-${Date.now()}.png`;
|
||||
@@ -1266,8 +1264,8 @@ export const HistoryPanel: React.FC = () => {
|
||||
style={{
|
||||
left: `${previewPosition.x}px`,
|
||||
top: `${previewPosition.y}px`,
|
||||
maxWidth: '300px',
|
||||
maxHeight: '300px'
|
||||
maxWidth: '200px', // 减小最大宽度
|
||||
maxHeight: '200px'
|
||||
}}
|
||||
>
|
||||
<div className="bg-gray-900 text-white text-xs p-2 truncate font-medium">
|
||||
@@ -1276,7 +1274,7 @@ export const HistoryPanel: React.FC = () => {
|
||||
<img
|
||||
src={hoveredImage.url}
|
||||
alt="预览"
|
||||
className="w-full h-auto max-h-[200px] object-contain"
|
||||
className="w-full h-auto max-h-[150px] object-contain"
|
||||
/>
|
||||
{/* 图像信息 */}
|
||||
<div className="p-2 bg-gray-50 border-t border-gray-200 text-xs">
|
||||
@@ -1286,12 +1284,6 @@ export const HistoryPanel: React.FC = () => {
|
||||
<span className="text-gray-800">{hoveredImage.width} × {hoveredImage.height}</span>
|
||||
</div>
|
||||
)}
|
||||
{hoveredImage.size > 0 && (
|
||||
<div className="flex justify-between text-gray-600 mt-1">
|
||||
<span>大小:</span>
|
||||
<span className="text-gray-800">{Math.round(hoveredImage.size / 1024)} KB</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -77,6 +77,13 @@ export const ImageCanvas: React.FC = () => {
|
||||
|
||||
img.src = canvasImage;
|
||||
} else {
|
||||
// 当没有图像时,清理之前的图像对象
|
||||
if (image) {
|
||||
// 清理图像对象以释放内存
|
||||
image.onload = null;
|
||||
image.onerror = null;
|
||||
image.src = '';
|
||||
}
|
||||
setImage(null);
|
||||
}
|
||||
|
||||
@@ -86,9 +93,18 @@ export const ImageCanvas: React.FC = () => {
|
||||
if (img) {
|
||||
img.onload = null;
|
||||
img.onerror = null;
|
||||
// 清理图像源以释放内存
|
||||
img.src = '';
|
||||
}
|
||||
|
||||
// 清理之前的图像对象
|
||||
if (image) {
|
||||
image.onload = null;
|
||||
image.onerror = null;
|
||||
image.src = '';
|
||||
}
|
||||
};
|
||||
}, [canvasImage, stageSize, setCanvasZoom, setCanvasPan]);
|
||||
}, [canvasImage, stageSize, setCanvasZoom, setCanvasPan, image]);
|
||||
|
||||
// 处理舞台大小调整
|
||||
useEffect(() => {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Button } from './ui/Button';
|
||||
import { useAppStore } from '../store/useAppStore';
|
||||
import { useImageGeneration, useImageEditing } from '../hooks/useImageGeneration';
|
||||
import { Upload, Wand2, Edit3, MousePointer, HelpCircle, ChevronDown, ChevronRight, RotateCcw } from 'lucide-react';
|
||||
import { blobToBase64 } from '../utils/imageUtils';
|
||||
import { blobToBase64, urlToBlob } from '../utils/imageUtils';
|
||||
import { PromptHints } from './PromptHints';
|
||||
import { cn } from '../utils/cn';
|
||||
|
||||
@@ -32,6 +32,7 @@ export const PromptComposer: React.FC = () => {
|
||||
showPromptPanel,
|
||||
setShowPromptPanel,
|
||||
clearBrushStrokes,
|
||||
addBlob
|
||||
} = useAppStore();
|
||||
|
||||
const { generate, cancelGeneration } = useImageGeneration();
|
||||
@@ -42,17 +43,45 @@ export const PromptComposer: React.FC = () => {
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleGenerate = () => {
|
||||
const handleGenerate = async () => {
|
||||
if (!currentPrompt.trim()) return;
|
||||
|
||||
if (selectedTool === 'generate') {
|
||||
const referenceImages = uploadedImages
|
||||
.filter(img => img.includes('base64,'))
|
||||
.map(img => img.split('base64,')[1]);
|
||||
// 将上传的图像转换为Blob对象
|
||||
const referenceImageBlobs: Blob[] = [];
|
||||
for (const img of uploadedImages) {
|
||||
if (img.startsWith('data:')) {
|
||||
// 从base64数据创建Blob
|
||||
const base64 = img.split('base64,')[1];
|
||||
const byteString = atob(base64);
|
||||
const mimeString = img.split(',')[0].split(':')[1].split(';')[0];
|
||||
const ab = new ArrayBuffer(byteString.length);
|
||||
const ia = new Uint8Array(ab);
|
||||
for (let i = 0; i < byteString.length; i++) {
|
||||
ia[i] = byteString.charCodeAt(i);
|
||||
}
|
||||
referenceImageBlobs.push(new Blob([ab], { type: mimeString }));
|
||||
} else if (img.startsWith('blob:')) {
|
||||
// 从Blob URL获取Blob
|
||||
const { getBlob } = useAppStore.getState();
|
||||
const blob = getBlob(img);
|
||||
if (blob) {
|
||||
referenceImageBlobs.push(blob);
|
||||
}
|
||||
} else {
|
||||
// 从URL获取Blob
|
||||
try {
|
||||
const blob = await urlToBlob(img);
|
||||
referenceImageBlobs.push(blob);
|
||||
} catch (error) {
|
||||
console.warn('无法获取参考图像:', img, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
generate({
|
||||
prompt: currentPrompt,
|
||||
referenceImages: referenceImages.length > 0 ? referenceImages : undefined,
|
||||
referenceImages: referenceImageBlobs.length > 0 ? referenceImageBlobs : undefined,
|
||||
temperature,
|
||||
seed: seed !== null ? seed : undefined
|
||||
});
|
||||
@@ -64,28 +93,28 @@ export const PromptComposer: React.FC = () => {
|
||||
const handleFileUpload = async (file: File) => {
|
||||
if (file && file.type.startsWith('image/')) {
|
||||
try {
|
||||
const base64 = await blobToBase64(file);
|
||||
const dataUrl = `data:${file.type};base64,${base64}`;
|
||||
// 直接使用Blob创建URL
|
||||
const blobUrl = addBlob(file);
|
||||
|
||||
if (selectedTool === 'generate') {
|
||||
// 添加到参考图像(最多2张)
|
||||
if (uploadedImages.length < 2) {
|
||||
addUploadedImage(dataUrl);
|
||||
addUploadedImage(blobUrl);
|
||||
}
|
||||
} else if (selectedTool === 'edit') {
|
||||
// 编辑模式下,添加到单独的编辑参考图像(最多2张)
|
||||
if (editReferenceImages.length < 2) {
|
||||
addEditReferenceImage(dataUrl);
|
||||
addEditReferenceImage(blobUrl);
|
||||
}
|
||||
// 如果没有画布图像,则设置为画布图像
|
||||
if (!canvasImage) {
|
||||
setCanvasImage(dataUrl);
|
||||
setCanvasImage(blobUrl);
|
||||
}
|
||||
} else if (selectedTool === 'mask') {
|
||||
// 遮罩模式下,立即设置为画布图像
|
||||
clearUploadedImages();
|
||||
addUploadedImage(dataUrl);
|
||||
setCanvasImage(dataUrl);
|
||||
addUploadedImage(blobUrl);
|
||||
setCanvasImage(blobUrl);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('上传图像失败:', error);
|
||||
|
||||
@@ -6,6 +6,7 @@ import { generateId } from '../utils/imageUtils'
|
||||
import { Generation, Edit, Asset } from '../types'
|
||||
import { useToast } from '../components/ToastContext'
|
||||
import { uploadImages } from '../services/uploadService'
|
||||
import { blobToBase64 } from '../utils/imageUtils'
|
||||
|
||||
export const useImageGeneration = () => {
|
||||
const { addGeneration, setIsGenerating, setCanvasImage } = useAppStore()
|
||||
@@ -19,7 +20,34 @@ export const useImageGeneration = () => {
|
||||
// 重置中断标志
|
||||
isCancelledRef.current = false
|
||||
|
||||
const result = await geminiService.generateImage(request)
|
||||
// 将参考图像从base64转换为Blob(如果需要)
|
||||
let blobReferenceImages: Blob[] | undefined;
|
||||
if (request.referenceImages) {
|
||||
blobReferenceImages = [];
|
||||
for (const img of request.referenceImages) {
|
||||
if (typeof img === 'string') {
|
||||
// 如果是base64字符串,转换为Blob
|
||||
const byteString = atob(img);
|
||||
const mimeString = 'image/png';
|
||||
const ab = new ArrayBuffer(byteString.length);
|
||||
const ia = new Uint8Array(ab);
|
||||
for (let i = 0; i < byteString.length; i++) {
|
||||
ia[i] = byteString.charCodeAt(i);
|
||||
}
|
||||
blobReferenceImages.push(new Blob([ab], { type: mimeString }));
|
||||
} else {
|
||||
// 如果已经是Blob,直接使用
|
||||
blobReferenceImages.push(img);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const blobRequest: GenerationRequest = {
|
||||
...request,
|
||||
referenceImages: blobReferenceImages
|
||||
};
|
||||
|
||||
const result = await geminiService.generateImage(blobRequest)
|
||||
|
||||
// 检查是否已中断
|
||||
if (isCancelledRef.current) {
|
||||
@@ -34,14 +62,35 @@ export const useImageGeneration = () => {
|
||||
onSuccess: async (result, request) => {
|
||||
const { images, usageMetadata } = result;
|
||||
if (images.length > 0) {
|
||||
const outputAssets: Asset[] = images.map((base64, index) => ({
|
||||
id: generateId(),
|
||||
type: 'output',
|
||||
url: `data:image/png;base64,${base64}`,
|
||||
mime: 'image/png',
|
||||
width: 1024, // 默认Gemini输出尺寸
|
||||
height: 1024,
|
||||
checksum: base64.slice(0, 32) // 简单校验和
|
||||
// 直接使用Blob并创建URL,避免存储base64数据
|
||||
const outputAssets: Asset[] = await Promise.all(images.map(async (blob, index) => {
|
||||
// 使用AppStore的addBlob方法存储Blob并获取URL
|
||||
const blobUrl = useAppStore.getState().addBlob(blob);
|
||||
|
||||
// 生成校验和(使用Blob的一部分数据)
|
||||
const checksum = await new Promise<string>(async (resolve) => {
|
||||
try {
|
||||
const arrayBuffer = await blob.slice(0, 32).arrayBuffer();
|
||||
const uint8Array = new Uint8Array(arrayBuffer);
|
||||
let checksum = '';
|
||||
for (let i = 0; i < uint8Array.length; i++) {
|
||||
checksum += uint8Array[i].toString(16).padStart(2, '0');
|
||||
}
|
||||
resolve(checksum || generateId().slice(0, 32));
|
||||
} catch (error) {
|
||||
resolve(generateId().slice(0, 32));
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
id: generateId(),
|
||||
type: 'output',
|
||||
url: blobUrl, // 存储Blob URL而不是base64
|
||||
mime: 'image/png',
|
||||
width: 1024, // 默认Gemini输出尺寸
|
||||
height: 1024,
|
||||
checksum // 使用生成的校验和
|
||||
};
|
||||
}));
|
||||
|
||||
// 获取accessToken
|
||||
@@ -58,7 +107,10 @@ export const useImageGeneration = () => {
|
||||
// 上传参考图像(如果存在,使用缓存机制)
|
||||
let referenceUploadResults: any[] = [];
|
||||
if (request.referenceImages && request.referenceImages.length > 0) {
|
||||
const referenceUrls = request.referenceImages.map(img => `data:image/png;base64,${img}`);
|
||||
// 将参考图像也转换为Blob URL
|
||||
const referenceUrls = await Promise.all(request.referenceImages.map(async (blob) => {
|
||||
return useAppStore.getState().addBlob(blob);
|
||||
}));
|
||||
referenceUploadResults = await uploadImages(referenceUrls, accessToken, false);
|
||||
}
|
||||
|
||||
@@ -96,14 +148,34 @@ export const useImageGeneration = () => {
|
||||
seed: request.seed,
|
||||
temperature: request.temperature
|
||||
},
|
||||
sourceAssets: request.referenceImages ? request.referenceImages.map((img, index) => ({
|
||||
id: generateId(),
|
||||
type: 'original' as const,
|
||||
url: `data:image/png;base64,${img}`,
|
||||
mime: 'image/png',
|
||||
width: 1024,
|
||||
height: 1024,
|
||||
checksum: img.slice(0, 32)
|
||||
sourceAssets: request.referenceImages ? await Promise.all(request.referenceImages.map(async (blob, index) => {
|
||||
// 将参考图像转换为Blob URL
|
||||
const blobUrl = useAppStore.getState().addBlob(blob);
|
||||
|
||||
// 生成校验和(使用Blob的一部分数据)
|
||||
const checksum = await new Promise<string>(async (resolve) => {
|
||||
try {
|
||||
const arrayBuffer = await blob.slice(0, 32).arrayBuffer();
|
||||
const uint8Array = new Uint8Array(arrayBuffer);
|
||||
let checksum = '';
|
||||
for (let i = 0; i < uint8Array.length; i++) {
|
||||
checksum += uint8Array[i].toString(16).padStart(2, '0');
|
||||
}
|
||||
resolve(checksum || generateId().slice(0, 32));
|
||||
} catch (error) {
|
||||
resolve(generateId().slice(0, 32));
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
id: generateId(),
|
||||
type: 'original' as const,
|
||||
url: blobUrl, // 存储Blob URL而不是base64
|
||||
mime: 'image/png',
|
||||
width: 1024,
|
||||
height: 1024,
|
||||
checksum
|
||||
};
|
||||
})) : [],
|
||||
outputAssets,
|
||||
modelVersion: 'gemini-2.5-flash-image-preview',
|
||||
@@ -157,14 +229,64 @@ export const useImageEditing = () => {
|
||||
const sourceImage = canvasImage || uploadedImages[0]
|
||||
if (!sourceImage) throw new Error('没有要编辑的图像')
|
||||
|
||||
// 将画布图像转换为base64
|
||||
const base64Image = sourceImage.includes('base64,') ? sourceImage.split('base64,')[1] : sourceImage
|
||||
// 将画布图像转换为Blob
|
||||
let originalImageBlob: Blob;
|
||||
if (sourceImage.startsWith('blob:')) {
|
||||
// 从Blob URL获取Blob数据
|
||||
const blob = useAppStore.getState().getBlob(sourceImage);
|
||||
if (!blob) throw new Error('无法从Blob URL获取图像数据');
|
||||
originalImageBlob = blob;
|
||||
} else if (sourceImage.includes('base64,')) {
|
||||
// 从base64数据创建Blob
|
||||
const base64 = sourceImage.split('base64,')[1];
|
||||
const byteString = atob(base64);
|
||||
const mimeString = 'image/png';
|
||||
const ab = new ArrayBuffer(byteString.length);
|
||||
const ia = new Uint8Array(ab);
|
||||
for (let i = 0; i < byteString.length; i++) {
|
||||
ia[i] = byteString.charCodeAt(i);
|
||||
}
|
||||
originalImageBlob = new Blob([ab], { type: mimeString });
|
||||
} else {
|
||||
// 从URL获取Blob
|
||||
const response = await fetch(sourceImage);
|
||||
originalImageBlob = await response.blob();
|
||||
}
|
||||
|
||||
// 获取用于样式指导的参考图像
|
||||
let referenceImages = editReferenceImages.filter(img => img.includes('base64,')).map(img => img.split('base64,')[1])
|
||||
let referenceImageBlobs: Blob[] = [];
|
||||
for (const img of editReferenceImages) {
|
||||
if (img.startsWith('blob:')) {
|
||||
// 从Blob URL获取Blob数据
|
||||
const blob = useAppStore.getState().getBlob(img);
|
||||
if (blob) {
|
||||
referenceImageBlobs.push(blob);
|
||||
}
|
||||
} else if (img.includes('base64,')) {
|
||||
// 从base64数据创建Blob
|
||||
const base64 = img.split('base64,')[1];
|
||||
const byteString = atob(base64);
|
||||
const mimeString = 'image/png';
|
||||
const ab = new ArrayBuffer(byteString.length);
|
||||
const ia = new Uint8Array(ab);
|
||||
for (let i = 0; i < byteString.length; i++) {
|
||||
ia[i] = byteString.charCodeAt(i);
|
||||
}
|
||||
referenceImageBlobs.push(new Blob([ab], { type: mimeString }));
|
||||
} else {
|
||||
// 从URL获取Blob
|
||||
try {
|
||||
const response = await fetch(img);
|
||||
const blob = await response.blob();
|
||||
referenceImageBlobs.push(blob);
|
||||
} catch (error) {
|
||||
console.warn('无法获取参考图像:', img, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let maskImage: string | undefined
|
||||
let maskedReferenceImage: string | undefined
|
||||
let maskImageBlob: Blob | undefined;
|
||||
let maskedReferenceImage: string | undefined;
|
||||
|
||||
// 如果存在画笔描边,则从描边创建遮罩
|
||||
if (brushStrokes.length > 0) {
|
||||
@@ -203,9 +325,16 @@ export const useImageEditing = () => {
|
||||
}
|
||||
})
|
||||
|
||||
// 将遮罩转换为base64
|
||||
const maskDataUrl = canvas.toDataURL('image/png')
|
||||
maskImage = maskDataUrl.split('base64,')[1]
|
||||
// 将遮罩转换为Blob
|
||||
maskImageBlob = await new Promise<Blob>((resolve, reject) => {
|
||||
canvas.toBlob((blob) => {
|
||||
if (blob) {
|
||||
resolve(blob);
|
||||
} else {
|
||||
reject(new Error('无法创建遮罩图像Blob'));
|
||||
}
|
||||
}, 'image/png');
|
||||
});
|
||||
|
||||
// 创建遮罩参考图像(带遮罩叠加的原始图像)
|
||||
const maskedCanvas = document.createElement('canvas')
|
||||
@@ -240,18 +369,19 @@ export const useImageEditing = () => {
|
||||
maskedCtx.globalAlpha = 1
|
||||
maskedCtx.globalCompositeOperation = 'source-over'
|
||||
|
||||
// 将遮罩参考图像转换为base64(用于后续处理)
|
||||
const maskedDataUrl = maskedCanvas.toDataURL('image/png')
|
||||
maskedReferenceImage = maskedDataUrl.split('base64,')[1]
|
||||
|
||||
// 将遮罩图像作为参考添加到模型中
|
||||
referenceImages = [maskedReferenceImage, ...referenceImages]
|
||||
referenceImageBlobs = [maskImageBlob, ...referenceImageBlobs];
|
||||
}
|
||||
|
||||
const request: EditRequest = {
|
||||
instruction,
|
||||
originalImage: base64Image,
|
||||
referenceImages: referenceImages.length > 0 ? referenceImages : undefined,
|
||||
maskImage,
|
||||
originalImage: originalImageBlob,
|
||||
referenceImages: referenceImageBlobs.length > 0 ? referenceImageBlobs : undefined,
|
||||
maskImage: maskImageBlob,
|
||||
temperature,
|
||||
seed,
|
||||
}
|
||||
@@ -271,26 +401,77 @@ export const useImageEditing = () => {
|
||||
onSuccess: async ({ result, maskedReferenceImage }, instruction) => {
|
||||
const { images, usageMetadata } = result;
|
||||
if (images.length > 0) {
|
||||
const outputAssets: Asset[] = images.map((base64, index) => ({
|
||||
id: generateId(),
|
||||
type: 'output',
|
||||
url: `data:image/png;base64,${base64}`,
|
||||
mime: 'image/png',
|
||||
width: 1024,
|
||||
height: 1024,
|
||||
checksum: base64.slice(0, 32)
|
||||
// 直接使用Blob并创建URL,避免存储base64数据
|
||||
const outputAssets: Asset[] = await Promise.all(images.map(async (blob, index) => {
|
||||
// 使用AppStore的addBlob方法存储Blob并获取URL
|
||||
const blobUrl = useAppStore.getState().addBlob(blob);
|
||||
|
||||
// 生成校验和(使用Blob的一部分数据)
|
||||
const checksum = await new Promise<string>(async (resolve) => {
|
||||
try {
|
||||
const arrayBuffer = await blob.slice(0, 32).arrayBuffer();
|
||||
const uint8Array = new Uint8Array(arrayBuffer);
|
||||
let checksum = '';
|
||||
for (let i = 0; i < uint8Array.length; i++) {
|
||||
checksum += uint8Array[i].toString(16).padStart(2, '0');
|
||||
}
|
||||
resolve(checksum || generateId().slice(0, 32));
|
||||
} catch (error) {
|
||||
resolve(generateId().slice(0, 32));
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
id: generateId(),
|
||||
type: 'output',
|
||||
url: blobUrl, // 存储Blob URL而不是base64
|
||||
mime: 'image/png',
|
||||
width: 1024,
|
||||
height: 1024,
|
||||
checksum
|
||||
};
|
||||
}));
|
||||
|
||||
// 如果有遮罩参考图像则创建遮罩参考资产
|
||||
const maskReferenceAsset: Asset | undefined = maskedReferenceImage ? {
|
||||
id: generateId(),
|
||||
type: 'mask',
|
||||
url: `data:image/png;base64,${maskedReferenceImage}`,
|
||||
mime: 'image/png',
|
||||
width: 1024,
|
||||
height: 1024,
|
||||
checksum: maskedReferenceImage.slice(0, 32)
|
||||
} : undefined;
|
||||
const maskReferenceAsset: Asset | undefined = maskedReferenceImage ? await (async () => {
|
||||
// 将base64转换为Blob
|
||||
const byteString = atob(maskedReferenceImage);
|
||||
const mimeString = 'image/png';
|
||||
const ab = new ArrayBuffer(byteString.length);
|
||||
const ia = new Uint8Array(ab);
|
||||
for (let i = 0; i < byteString.length; i++) {
|
||||
ia[i] = byteString.charCodeAt(i);
|
||||
}
|
||||
const blob = new Blob([ab], { type: mimeString });
|
||||
|
||||
// 使用AppStore的addBlob方法存储Blob并获取URL
|
||||
const blobUrl = useAppStore.getState().addBlob(blob);
|
||||
|
||||
// 生成校验和(使用Blob的一部分数据)
|
||||
const checksum = await new Promise<string>(async (resolve) => {
|
||||
try {
|
||||
const arrayBuffer = await blob.slice(0, 32).arrayBuffer();
|
||||
const uint8Array = new Uint8Array(arrayBuffer);
|
||||
let checksum = '';
|
||||
for (let i = 0; i < uint8Array.length; i++) {
|
||||
checksum += uint8Array[i].toString(16).padStart(2, '0');
|
||||
}
|
||||
resolve(checksum || generateId().slice(0, 32));
|
||||
} catch (error) {
|
||||
resolve(generateId().slice(0, 32));
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
id: generateId(),
|
||||
type: 'mask',
|
||||
url: blobUrl, // 存储Blob URL而不是base64
|
||||
mime: 'image/png',
|
||||
width: 1024,
|
||||
height: 1024,
|
||||
checksum
|
||||
};
|
||||
})() : undefined;
|
||||
|
||||
// 获取accessToken
|
||||
const accessToken = import.meta.env.VITE_ACCESS_TOKEN || '';
|
||||
|
||||
@@ -3,6 +3,10 @@ import { Project, Generation, Asset } from '../types';
|
||||
|
||||
const CACHE_PREFIX = 'nano-banana';
|
||||
const CACHE_VERSION = '1.0';
|
||||
// 限制缓存项目数量
|
||||
const MAX_CACHED_ITEMS = 50;
|
||||
// 限制缓存最大年龄 (3天)
|
||||
const MAX_CACHE_AGE = 3 * 24 * 60 * 60 * 1000;
|
||||
|
||||
export class CacheService {
|
||||
private static getKey(type: string, id: string): string {
|
||||
@@ -11,6 +15,8 @@ export class CacheService {
|
||||
|
||||
// Project caching
|
||||
static async saveProject(project: Project): Promise<void> {
|
||||
// 在保存新项目之前,清理旧缓存
|
||||
await this.clearOldCache();
|
||||
await set(this.getKey('project', project.id), project);
|
||||
}
|
||||
|
||||
@@ -33,6 +39,8 @@ export class CacheService {
|
||||
|
||||
// Asset caching (for offline access)
|
||||
static async cacheAsset(asset: Asset, data: Blob): Promise<void> {
|
||||
// 在保存新资产之前,清理旧缓存
|
||||
await this.clearOldCache();
|
||||
await set(this.getKey('asset', asset.id), {
|
||||
asset,
|
||||
data,
|
||||
@@ -47,6 +55,8 @@ export class CacheService {
|
||||
|
||||
// Generation metadata caching
|
||||
static async cacheGeneration(generation: Generation): Promise<void> {
|
||||
// 在保存新生成记录之前,清理旧缓存
|
||||
await this.clearOldCache();
|
||||
await set(this.getKey('generation', generation.id), generation);
|
||||
}
|
||||
|
||||
@@ -55,17 +65,55 @@ export class CacheService {
|
||||
}
|
||||
|
||||
// Clear old cache entries
|
||||
static async clearOldCache(maxAge: number = 7 * 24 * 60 * 60 * 1000): Promise<void> {
|
||||
static async clearOldCache(maxAge: number = MAX_CACHE_AGE): Promise<void> {
|
||||
const allKeys = await keys();
|
||||
const now = Date.now();
|
||||
|
||||
// 收集需要删除的键
|
||||
const keysToDelete: string[] = [];
|
||||
const validCachedItems: Array<{key: string, cachedAt: number}> = [];
|
||||
|
||||
for (const key of allKeys) {
|
||||
if (typeof key === 'string' && key.startsWith(CACHE_PREFIX)) {
|
||||
const cached = await get(key);
|
||||
if (cached?.cachedAt && (now - cached.cachedAt) > maxAge) {
|
||||
await del(key);
|
||||
if (cached?.cachedAt) {
|
||||
// 检查是否过期
|
||||
if ((now - cached.cachedAt) > maxAge) {
|
||||
keysToDelete.push(key);
|
||||
} else {
|
||||
validCachedItems.push({key, cachedAt: cached.cachedAt});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 如果有效项目数量超过限制,删除最旧的项目
|
||||
if (validCachedItems.length > MAX_CACHED_ITEMS) {
|
||||
// 按时间排序,最旧的在前面
|
||||
validCachedItems.sort((a, b) => a.cachedAt - b.cachedAt);
|
||||
// 计算需要删除的数量
|
||||
const excessCount = validCachedItems.length - MAX_CACHED_ITEMS;
|
||||
// 添加最旧的项目到删除列表
|
||||
for (let i = 0; i < excessCount; i++) {
|
||||
keysToDelete.push(validCachedItems[i].key);
|
||||
}
|
||||
}
|
||||
|
||||
// 执行删除
|
||||
for (const key of keysToDelete) {
|
||||
await del(key);
|
||||
}
|
||||
}
|
||||
|
||||
// 清空所有缓存
|
||||
static async clearAllCache(): Promise<void> {
|
||||
const allKeys = await keys();
|
||||
const cacheKeys = allKeys.filter(key =>
|
||||
typeof key === 'string' && key.startsWith(CACHE_PREFIX)
|
||||
);
|
||||
|
||||
for (const key of cacheKeys) {
|
||||
await del(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,16 +6,16 @@ const genAI = new GoogleGenAI({ apiKey: API_KEY })
|
||||
|
||||
export interface GenerationRequest {
|
||||
prompt: string
|
||||
referenceImages?: string[] // base64数组
|
||||
referenceImages?: Blob[] // Blob数组
|
||||
temperature?: number
|
||||
seed?: number
|
||||
}
|
||||
|
||||
export interface EditRequest {
|
||||
instruction: string
|
||||
originalImage: string // base64
|
||||
referenceImages?: string[] // base64数组
|
||||
maskImage?: string // base64
|
||||
originalImage: Blob // Blob
|
||||
referenceImages?: Blob[] // Blob数组
|
||||
maskImage?: Blob // Blob
|
||||
temperature?: number
|
||||
seed?: number
|
||||
}
|
||||
@@ -27,18 +27,37 @@ export interface UsageMetadata {
|
||||
}
|
||||
|
||||
export interface SegmentationRequest {
|
||||
image: string // base64
|
||||
image: Blob // Blob
|
||||
query: string // "像素(x,y)处的对象" 或 "红色汽车"
|
||||
}
|
||||
|
||||
export class GeminiService {
|
||||
async generateImage(request: GenerationRequest): Promise<{ images: string[]; usageMetadata?: any }> {
|
||||
// 将Blob转换为base64的辅助函数
|
||||
private async blobToBase64(blob: Blob): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
const result = reader.result as string;
|
||||
const base64 = result.split(',')[1]; // Remove data:image/png;base64, prefix
|
||||
resolve(base64);
|
||||
};
|
||||
reader.onerror = reject;
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
}
|
||||
|
||||
async generateImage(request: GenerationRequest): Promise<{ images: Blob[]; usageMetadata?: any }> {
|
||||
try {
|
||||
const contents: any[] = [{ text: request.prompt }]
|
||||
|
||||
// 如果提供了参考图像则添加
|
||||
if (request.referenceImages && request.referenceImages.length > 0) {
|
||||
request.referenceImages.forEach(image => {
|
||||
// 将Blob转换为base64以发送到API
|
||||
const base64Images = await Promise.all(
|
||||
request.referenceImages.map(blob => this.blobToBase64(blob))
|
||||
);
|
||||
|
||||
base64Images.forEach(image => {
|
||||
contents.push({
|
||||
inlineData: {
|
||||
mimeType: 'image/png',
|
||||
@@ -82,13 +101,22 @@ export class GeminiService {
|
||||
}
|
||||
}
|
||||
|
||||
const images: string[] = []
|
||||
const images: Blob[] = []
|
||||
|
||||
// 检查响应是否存在以及是否有内容
|
||||
if (response.candidates && response.candidates.length > 0 && response.candidates[0].content && response.candidates[0].content.parts) {
|
||||
for (const part of response.candidates[0].content.parts) {
|
||||
if (part.inlineData) {
|
||||
images.push(part.inlineData.data)
|
||||
// 将返回的base64数据转换为Blob
|
||||
const byteString = atob(part.inlineData.data);
|
||||
const mimeString = part.inlineData.mimeType || 'image/png';
|
||||
const ab = new ArrayBuffer(byteString.length);
|
||||
const ia = new Uint8Array(ab);
|
||||
for (let i = 0; i < byteString.length; i++) {
|
||||
ia[i] = byteString.charCodeAt(i);
|
||||
}
|
||||
const blob = new Blob([ab], { type: mimeString });
|
||||
images.push(blob);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -106,21 +134,29 @@ export class GeminiService {
|
||||
}
|
||||
}
|
||||
|
||||
async editImage(request: EditRequest): Promise<{ images: string[]; usageMetadata?: any }> {
|
||||
async editImage(request: EditRequest): Promise<{ images: Blob[]; usageMetadata?: any }> {
|
||||
try {
|
||||
// 将Blob转换为base64以发送到API
|
||||
const originalImageBase64 = await this.blobToBase64(request.originalImage);
|
||||
|
||||
const contents = [
|
||||
{ text: this.buildEditPrompt(request) },
|
||||
{
|
||||
inlineData: {
|
||||
mimeType: 'image/png',
|
||||
data: request.originalImage,
|
||||
data: originalImageBase64,
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
// 如果提供了参考图像则添加
|
||||
if (request.referenceImages && request.referenceImages.length > 0) {
|
||||
request.referenceImages.forEach(image => {
|
||||
// 将Blob转换为base64以发送到API
|
||||
const base64ReferenceImages = await Promise.all(
|
||||
request.referenceImages.map(blob => this.blobToBase64(blob))
|
||||
);
|
||||
|
||||
base64ReferenceImages.forEach(image => {
|
||||
contents.push({
|
||||
inlineData: {
|
||||
mimeType: 'image/png',
|
||||
@@ -131,10 +167,12 @@ export class GeminiService {
|
||||
}
|
||||
|
||||
if (request.maskImage) {
|
||||
// 将Blob转换为base64以发送到API
|
||||
const maskImageBase64 = await this.blobToBase64(request.maskImage);
|
||||
contents.push({
|
||||
inlineData: {
|
||||
mimeType: 'image/png',
|
||||
data: request.maskImage,
|
||||
data: maskImageBase64,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -170,13 +208,22 @@ export class GeminiService {
|
||||
}
|
||||
}
|
||||
|
||||
const images: string[] = []
|
||||
const images: Blob[] = []
|
||||
|
||||
// 检查响应是否存在以及是否有内容
|
||||
if (response.candidates && response.candidates.length > 0 && response.candidates[0].content && response.candidates[0].content.parts) {
|
||||
for (const part of response.candidates[0].content.parts) {
|
||||
if (part.inlineData) {
|
||||
images.push(part.inlineData.data)
|
||||
// 将返回的base64数据转换为Blob
|
||||
const byteString = atob(part.inlineData.data);
|
||||
const mimeString = part.inlineData.mimeType || 'image/png';
|
||||
const ab = new ArrayBuffer(byteString.length);
|
||||
const ia = new Uint8Array(ab);
|
||||
for (let i = 0; i < byteString.length; i++) {
|
||||
ia[i] = byteString.charCodeAt(i);
|
||||
}
|
||||
const blob = new Blob([ab], { type: mimeString });
|
||||
images.push(blob);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -196,6 +243,9 @@ export class GeminiService {
|
||||
|
||||
async segmentImage(request: SegmentationRequest): Promise<any> {
|
||||
try {
|
||||
// 将Blob转换为base64以发送到API
|
||||
const imageBase64 = await this.blobToBase64(request.image);
|
||||
|
||||
const prompt = [
|
||||
{
|
||||
text: `分析此图像并为以下对象创建分割遮罩: ${request.query}
|
||||
@@ -216,7 +266,7 @@ export class GeminiService {
|
||||
{
|
||||
inlineData: {
|
||||
mimeType: 'image/png',
|
||||
data: request.image,
|
||||
data: imageBase64,
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
import { SegmentationMask } from '../types';
|
||||
import { generateId } from '../utils/imageUtils';
|
||||
|
||||
export class ImageProcessor {
|
||||
// Interactive segmentation using click point
|
||||
static async createMaskFromClick(
|
||||
image: HTMLImageElement,
|
||||
x: number,
|
||||
y: number
|
||||
): Promise<SegmentationMask> {
|
||||
// Simulate mask creation - in production this would use MediaPipe
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d')!;
|
||||
canvas.width = image.width;
|
||||
canvas.height = image.height;
|
||||
|
||||
// Draw the image
|
||||
ctx.drawImage(image, 0, 0);
|
||||
|
||||
// Create a simple circular mask for demo
|
||||
const radius = 50;
|
||||
const maskCanvas = document.createElement('canvas');
|
||||
const maskCtx = maskCanvas.getContext('2d')!;
|
||||
maskCanvas.width = image.width;
|
||||
maskCanvas.height = image.height;
|
||||
|
||||
// Fill with black (background)
|
||||
maskCtx.fillStyle = 'black';
|
||||
maskCtx.fillRect(0, 0, maskCanvas.width, maskCanvas.height);
|
||||
|
||||
// Draw white circle (selected region)
|
||||
maskCtx.fillStyle = 'white';
|
||||
maskCtx.beginPath();
|
||||
maskCtx.arc(x, y, radius, 0, 2 * Math.PI);
|
||||
maskCtx.fill();
|
||||
|
||||
const imageData = maskCtx.getImageData(0, 0, maskCanvas.width, maskCanvas.height);
|
||||
|
||||
return {
|
||||
id: generateId(),
|
||||
imageData,
|
||||
bounds: {
|
||||
x: Math.max(0, x - radius),
|
||||
y: Math.max(0, y - radius),
|
||||
width: radius * 2,
|
||||
height: radius * 2
|
||||
},
|
||||
feather: 5
|
||||
};
|
||||
}
|
||||
|
||||
// Apply feathering to mask
|
||||
static applyFeathering(mask: SegmentationMask, featherRadius: number): ImageData {
|
||||
const { imageData } = mask;
|
||||
const data = new Uint8ClampedArray(imageData.data);
|
||||
|
||||
// Simple box blur for feathering
|
||||
for (let i = 0; i < featherRadius; i++) {
|
||||
this.boxBlur(data, imageData.width, imageData.height);
|
||||
}
|
||||
|
||||
return new ImageData(data, imageData.width, imageData.height);
|
||||
}
|
||||
|
||||
private static boxBlur(data: Uint8ClampedArray, width: number, height: number) {
|
||||
const temp = new Uint8ClampedArray(data);
|
||||
|
||||
for (let y = 1; y < height - 1; y++) {
|
||||
for (let x = 1; x < width - 1; x++) {
|
||||
const idx = (y * width + x) * 4;
|
||||
|
||||
// Average the alpha channel (mask channel)
|
||||
const sum =
|
||||
temp[idx - 4 + 3] + temp[idx + 3] + temp[idx + 4 + 3] +
|
||||
temp[idx - width * 4 + 3] + temp[idx + 3] + temp[idx + width * 4 + 3] +
|
||||
temp[idx - width * 4 - 4 + 3] + temp[idx - width * 4 + 4 + 3] + temp[idx + width * 4 - 4 + 3];
|
||||
|
||||
data[idx + 3] = sum / 9;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Convert ImageData to base64 for API
|
||||
static imageDataToBase64(imageData: ImageData): string {
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d')!;
|
||||
canvas.width = imageData.width;
|
||||
canvas.height = imageData.height;
|
||||
|
||||
ctx.putImageData(imageData, 0, 0);
|
||||
|
||||
const dataUrl = canvas.toDataURL('image/png');
|
||||
return dataUrl.split(',')[1]; // Remove data:image/png;base64, prefix
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,10 @@ const DB_VERSION = 1;
|
||||
const GENERATIONS_STORE = 'generations';
|
||||
const EDITS_STORE = 'edits';
|
||||
|
||||
// 重试配置
|
||||
const MAX_RETRIES = 3;
|
||||
const RETRY_DELAY = 1000;
|
||||
|
||||
// IndexedDB实例
|
||||
let db: IDBDatabase | null = null;
|
||||
|
||||
@@ -59,12 +63,50 @@ const getDB = (): IDBDatabase => {
|
||||
* 添加生成记录
|
||||
*/
|
||||
export const addGeneration = async (generation: Generation): Promise<void> => {
|
||||
// 创建轻量级生成记录,只存储必要的信息和上传后的URL
|
||||
const lightweightGeneration = {
|
||||
id: generation.id,
|
||||
prompt: generation.prompt,
|
||||
parameters: generation.parameters,
|
||||
modelVersion: generation.modelVersion,
|
||||
timestamp: generation.timestamp,
|
||||
uploadResults: generation.uploadResults,
|
||||
usageMetadata: generation.usageMetadata,
|
||||
// 只存储上传后的URL,不存储base64数据
|
||||
sourceAssets: generation.sourceAssets.map(asset => {
|
||||
const uploadedUrl = getUploadedAssetUrl(generation, asset.id, 'source');
|
||||
return {
|
||||
id: asset.id,
|
||||
type: asset.type,
|
||||
// 如果没有上传后的URL,则不存储URL以避免base64数据
|
||||
url: uploadedUrl || '',
|
||||
mime: asset.mime,
|
||||
width: asset.width,
|
||||
height: asset.height,
|
||||
checksum: asset.checksum
|
||||
};
|
||||
}),
|
||||
outputAssets: generation.outputAssets.map(asset => {
|
||||
const uploadedUrl = getUploadedAssetUrl(generation, asset.id, 'output');
|
||||
return {
|
||||
id: asset.id,
|
||||
type: asset.type,
|
||||
// 如果没有上传后的URL,则不存储URL以避免base64数据
|
||||
url: uploadedUrl || '',
|
||||
mime: asset.mime,
|
||||
width: asset.width,
|
||||
height: asset.height,
|
||||
checksum: asset.checksum
|
||||
};
|
||||
})
|
||||
};
|
||||
|
||||
const db = getDB();
|
||||
const transaction = db.transaction([GENERATIONS_STORE], 'readwrite');
|
||||
const store = transaction.objectStore(GENERATIONS_STORE);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = store.add(generation);
|
||||
const request = store.add(lightweightGeneration);
|
||||
request.onsuccess = () => resolve();
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
@@ -74,17 +116,95 @@ export const addGeneration = async (generation: Generation): Promise<void> => {
|
||||
* 添加编辑记录
|
||||
*/
|
||||
export const addEdit = async (edit: Edit): Promise<void> => {
|
||||
// 创建轻量级编辑记录,只存储必要的信息和上传后的URL
|
||||
const lightweightEdit = {
|
||||
id: edit.id,
|
||||
parentGenerationId: edit.parentGenerationId,
|
||||
maskAssetId: edit.maskAssetId,
|
||||
instruction: edit.instruction,
|
||||
timestamp: edit.timestamp,
|
||||
uploadResults: edit.uploadResults,
|
||||
parameters: edit.parameters,
|
||||
usageMetadata: edit.usageMetadata,
|
||||
// 只存储上传后的URL,不存储base64数据
|
||||
maskReferenceAsset: edit.maskReferenceAsset ? (() => {
|
||||
const uploadedUrl = getUploadedAssetUrl(edit, edit.maskReferenceAsset.id, 'mask');
|
||||
return {
|
||||
id: edit.maskReferenceAsset.id,
|
||||
type: edit.maskReferenceAsset.type,
|
||||
// 如果没有上传后的URL,则不存储URL以避免base64数据
|
||||
url: uploadedUrl || '',
|
||||
mime: edit.maskReferenceAsset.mime,
|
||||
width: edit.maskReferenceAsset.width,
|
||||
height: edit.maskReferenceAsset.height,
|
||||
checksum: edit.maskReferenceAsset.checksum
|
||||
};
|
||||
})() : undefined,
|
||||
outputAssets: edit.outputAssets.map(asset => {
|
||||
const uploadedUrl = getUploadedAssetUrl(edit, asset.id, 'output');
|
||||
return {
|
||||
id: asset.id,
|
||||
type: asset.type,
|
||||
// 如果没有上传后的URL,则不存储URL以避免base64数据
|
||||
url: uploadedUrl || '',
|
||||
mime: asset.mime,
|
||||
width: asset.width,
|
||||
height: asset.height,
|
||||
checksum: asset.checksum
|
||||
};
|
||||
})
|
||||
};
|
||||
|
||||
const db = getDB();
|
||||
const transaction = db.transaction([EDITS_STORE], 'readwrite');
|
||||
const store = transaction.objectStore(EDITS_STORE);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = store.add(edit);
|
||||
const request = store.add(lightweightEdit);
|
||||
request.onsuccess = () => resolve();
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 从uploadResults中获取资产的上传后URL
|
||||
* 注意:这个函数需要根据资产在数组中的位置来匹配上传结果
|
||||
* - 输出资产的索引与uploadResults中的索引相对应
|
||||
* - 源资产(参考图像)的索引从outputAssets.length开始
|
||||
*/
|
||||
const getUploadedAssetUrl = (record: Generation | Edit, assetId: string, assetType: 'output' | 'source' | 'mask'): string | null => {
|
||||
if (!record.uploadResults || record.uploadResults.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let assetIndex = -1;
|
||||
|
||||
// 根据资产类型确定在uploadResults中的索引
|
||||
if (assetType === 'output') {
|
||||
// 输出资产的索引与在outputAssets数组中的索引相同
|
||||
assetIndex = record.outputAssets.findIndex(a => a.id === assetId);
|
||||
} else if (assetType === 'source') {
|
||||
// 源资产(参考图像)的索引从outputAssets.length开始
|
||||
assetIndex = record.sourceAssets?.findIndex(a => a.id === assetId);
|
||||
if (assetIndex >= 0) {
|
||||
assetIndex += record.outputAssets.length;
|
||||
}
|
||||
} else if (assetType === 'mask') {
|
||||
// 遮罩参考资产通常是第一个输出资产之后的第一个源资产
|
||||
assetIndex = record.outputAssets.length;
|
||||
}
|
||||
|
||||
// 检查索引是否有效并且对应的上传结果是否存在且成功
|
||||
if (assetIndex >= 0 && assetIndex < record.uploadResults.length) {
|
||||
const uploadResult = record.uploadResults[assetIndex];
|
||||
if (uploadResult.success && uploadResult.url) {
|
||||
return uploadResult.url;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取所有生成记录(按时间倒序)
|
||||
*/
|
||||
@@ -148,18 +268,16 @@ export const getEditsByParentGenerationId = async (parentGenerationId: string):
|
||||
/**
|
||||
* 删除最旧的记录以保持限制
|
||||
*/
|
||||
export const cleanupOldRecords = async (limit: number = 1000): Promise<void> => {
|
||||
export const cleanupOldRecords = async (limit: number = 100): Promise<void> => {
|
||||
const db = getDB();
|
||||
|
||||
// 清理生成记录
|
||||
const genTransaction = db.transaction([GENERATIONS_STORE], 'readwrite');
|
||||
const genStore = genTransaction.objectStore(GENERATIONS_STORE);
|
||||
const genIndex = genStore.index('timestamp');
|
||||
|
||||
// 清理编辑记录
|
||||
const editTransaction = db.transaction([EDITS_STORE], 'readwrite');
|
||||
const editStore = editTransaction.objectStore(EDITS_STORE);
|
||||
const editIndex = editStore.index('timestamp');
|
||||
|
||||
// 获取所有记录并按时间排序
|
||||
const allGenerations = await getAllGenerations();
|
||||
@@ -181,6 +299,117 @@ export const cleanupOldRecords = async (limit: number = 1000): Promise<void> =>
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 清理记录中的base64数据
|
||||
*/
|
||||
export const cleanupBase64Data = async (): Promise<void> => {
|
||||
try {
|
||||
// 获取所有生成记录
|
||||
const generations = await getAllGenerations();
|
||||
|
||||
// 获取所有编辑记录
|
||||
const edits = await getAllEdits();
|
||||
|
||||
const db = getDB();
|
||||
|
||||
// 更新生成记录
|
||||
for (const generation of generations) {
|
||||
// 检查是否有base64数据需要清理
|
||||
let needsUpdate = false;
|
||||
|
||||
// 清理源资产中的base64数据
|
||||
const cleanedSourceAssets = generation.sourceAssets.map((asset: any) => {
|
||||
if (asset.url && asset.url.startsWith('data:')) {
|
||||
needsUpdate = true;
|
||||
return {
|
||||
...asset,
|
||||
url: '' // 移除base64数据
|
||||
};
|
||||
}
|
||||
return asset;
|
||||
});
|
||||
|
||||
// 清理输出资产中的base64数据
|
||||
const cleanedOutputAssets = generation.outputAssets.map((asset: any) => {
|
||||
if (asset.url && asset.url.startsWith('data:')) {
|
||||
needsUpdate = true;
|
||||
return {
|
||||
...asset,
|
||||
url: '' // 移除base64数据
|
||||
};
|
||||
}
|
||||
return asset;
|
||||
});
|
||||
|
||||
// 如果需要更新,则保存清理后的记录
|
||||
if (needsUpdate) {
|
||||
const cleanedGeneration = {
|
||||
...generation,
|
||||
sourceAssets: cleanedSourceAssets,
|
||||
outputAssets: cleanedOutputAssets
|
||||
};
|
||||
|
||||
const transaction = db.transaction([GENERATIONS_STORE], 'readwrite');
|
||||
const store = transaction.objectStore(GENERATIONS_STORE);
|
||||
await new Promise((resolve, reject) => {
|
||||
const request = store.put(cleanedGeneration);
|
||||
request.onsuccess = () => resolve(undefined);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 更新编辑记录
|
||||
for (const edit of edits) {
|
||||
// 检查是否有base64数据需要清理
|
||||
let needsUpdate = false;
|
||||
|
||||
// 清理遮罩参考资产中的base64数据
|
||||
let cleanedMaskReferenceAsset = edit.maskReferenceAsset;
|
||||
if (edit.maskReferenceAsset && edit.maskReferenceAsset.url && edit.maskReferenceAsset.url.startsWith('data:')) {
|
||||
needsUpdate = true;
|
||||
cleanedMaskReferenceAsset = {
|
||||
...edit.maskReferenceAsset,
|
||||
url: '' // 移除base64数据
|
||||
};
|
||||
}
|
||||
|
||||
// 清理输出资产中的base64数据
|
||||
const cleanedOutputAssets = edit.outputAssets.map((asset: any) => {
|
||||
if (asset.url && asset.url.startsWith('data:')) {
|
||||
needsUpdate = true;
|
||||
return {
|
||||
...asset,
|
||||
url: '' // 移除base64数据
|
||||
};
|
||||
}
|
||||
return asset;
|
||||
});
|
||||
|
||||
// 如果需要更新,则保存清理后的记录
|
||||
if (needsUpdate) {
|
||||
const cleanedEdit = {
|
||||
...edit,
|
||||
maskReferenceAsset: cleanedMaskReferenceAsset,
|
||||
outputAssets: cleanedOutputAssets
|
||||
};
|
||||
|
||||
const transaction = db.transaction([EDITS_STORE], 'readwrite');
|
||||
const store = transaction.objectStore(EDITS_STORE);
|
||||
await new Promise((resolve, reject) => {
|
||||
const request = store.put(cleanedEdit);
|
||||
request.onsuccess = () => resolve(undefined);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
console.log('IndexedDB中的base64数据清理完成');
|
||||
} catch (error) {
|
||||
console.error('清理IndexedDB中的base64数据时出错:', error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 清空所有记录
|
||||
*/
|
||||
@@ -206,3 +435,13 @@ export const clearAllRecords = async (): Promise<void> => {
|
||||
})
|
||||
]).then(() => undefined);
|
||||
};
|
||||
|
||||
/**
|
||||
* 关闭数据库连接
|
||||
*/
|
||||
export const closeDB = (): void => {
|
||||
if (db) {
|
||||
db.close();
|
||||
db = null;
|
||||
}
|
||||
};
|
||||
@@ -8,8 +8,8 @@ const UPLOAD_URL = 'https://api.pandorastudio.cn/auth/OSSupload'
|
||||
const uploadCache = new Map<string, UploadResult>()
|
||||
|
||||
// 缓存配置
|
||||
const MAX_CACHE_SIZE = 100; // 最大缓存条目数
|
||||
const CACHE_EXPIRY_TIME = 30 * 60 * 1000; // 缓存过期时间30分钟
|
||||
const MAX_CACHE_SIZE = 50; // 减少最大缓存条目数
|
||||
const CACHE_EXPIRY_TIME = 15 * 60 * 1000; // 缓存过期时间15分钟
|
||||
|
||||
/**
|
||||
* 清理过期的缓存条目
|
||||
@@ -41,7 +41,7 @@ function maintainCacheSize(): void {
|
||||
entries.sort((a, b) => a[1].timestamp - b[1].timestamp);
|
||||
|
||||
// 删除最旧的条目,直到缓存大小在限制内
|
||||
const deleteCount = Math.max(1, Math.floor(MAX_CACHE_SIZE * 0.1)); // 删除10%的条目
|
||||
const deleteCount = Math.max(1, Math.floor(MAX_CACHE_SIZE * 0.2)); // 删除20%的条目
|
||||
for (let i = 0; i < deleteCount && uploadCache.size >= MAX_CACHE_SIZE; i++) {
|
||||
uploadCache.delete(entries[i][0]);
|
||||
}
|
||||
@@ -52,14 +52,21 @@ function maintainCacheSize(): void {
|
||||
|
||||
/**
|
||||
* 生成图像的唯一标识符
|
||||
* @param base64Data - base64编码的图像数据
|
||||
* @param imageData - 图像数据(可以是base64或Blob URL)
|
||||
* @returns 图像的唯一标识符
|
||||
*/
|
||||
function getImageHash(base64Data: string): string {
|
||||
// 使用简单的哈希函数生成图像标识符
|
||||
function getImageHash(imageData: string): string {
|
||||
// 对于Blob URL,我们需要获取实际的数据来生成哈希
|
||||
if (imageData.startsWith('blob:')) {
|
||||
// 对于Blob URL,我们使用URL本身作为标识符的一部分
|
||||
// 这不是完美的解决方案,但对于大多数情况足够了
|
||||
return btoa(imageData).slice(0, 32);
|
||||
}
|
||||
|
||||
// 对于base64数据,使用简单的哈希函数生成图像标识符
|
||||
let hash = 0;
|
||||
for (let i = 0; i < base64Data.length; i++) {
|
||||
const char = base64Data.charCodeAt(i);
|
||||
for (let i = 0; i < imageData.length; i++) {
|
||||
const char = imageData.charCodeAt(i);
|
||||
hash = ((hash << 5) - hash) + char;
|
||||
hash = hash & hash; // 转换为32位整数
|
||||
}
|
||||
@@ -67,21 +74,38 @@ function getImageHash(base64Data: string): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* 将base64图像数据上传到指定接口
|
||||
* @param base64Data - base64编码的图像数据
|
||||
* 从Blob URL获取Blob数据
|
||||
* @param blobUrl - Blob URL
|
||||
* @returns Blob对象
|
||||
*/
|
||||
async function getBlobFromUrl(blobUrl: string): Promise<Blob> {
|
||||
// 从AppStore获取Blob
|
||||
const { getBlob } = await import('../store/useAppStore');
|
||||
const blob = getBlob().getBlob(blobUrl);
|
||||
|
||||
if (!blob) {
|
||||
throw new Error('无法从Blob URL获取图像数据');
|
||||
}
|
||||
|
||||
return blob;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将图像数据上传到指定接口
|
||||
* @param imageData - 图像数据(可以是base64、Blob URL或Blob对象)
|
||||
* @param accessToken - 访问令牌
|
||||
* @param skipCache - 是否跳过缓存检查
|
||||
* @returns 上传结果
|
||||
*/
|
||||
export const uploadImage = async (base64Data: string, accessToken: string, skipCache: boolean = false): Promise<{ success: boolean; url?: string; error?: string }> => {
|
||||
export const uploadImage = async (imageData: string | Blob, accessToken: string, skipCache: boolean = false): Promise<{ success: boolean; url?: string; error?: string }> => {
|
||||
// 检查缓存中是否已有该图像的上传结果
|
||||
const imageHash = getImageHash(base64Data)
|
||||
const imageHash = typeof imageData === 'string' ? getImageHash(imageData) : 'blob-' + Date.now();
|
||||
|
||||
if (!skipCache && uploadCache.has(imageHash)) {
|
||||
if (!skipCache && typeof imageData === 'string' && uploadCache.has(imageHash)) {
|
||||
const cachedResult = uploadCache.get(imageHash)!;
|
||||
// 检查缓存是否过期
|
||||
if (Date.now() - cachedResult.timestamp < CACHE_EXPIRY_TIME) {
|
||||
console.log('从缓存中获取上传结果')
|
||||
console.log('从缓存中获取上传结果');
|
||||
return cachedResult;
|
||||
} else {
|
||||
// 缓存过期,删除它
|
||||
@@ -90,38 +114,55 @@ export const uploadImage = async (base64Data: string, accessToken: string, skipC
|
||||
}
|
||||
|
||||
try {
|
||||
// 将base64数据转换为Blob
|
||||
const byteString = atob(base64Data.split(',')[1])
|
||||
const mimeString = base64Data.split(',')[0].split(':')[1].split(';')[0]
|
||||
const ab = new ArrayBuffer(byteString.length)
|
||||
const ia = new Uint8Array(ab)
|
||||
for (let i = 0; i < byteString.length; i++) {
|
||||
ia[i] = byteString.charCodeAt(i)
|
||||
let blob: Blob;
|
||||
|
||||
if (typeof imageData === 'string') {
|
||||
if (imageData.startsWith('blob:')) {
|
||||
// 从Blob URL获取Blob数据
|
||||
blob = await getBlobFromUrl(imageData);
|
||||
} else if (imageData.includes('base64,')) {
|
||||
// 从base64数据创建Blob
|
||||
const base64Data = imageData.split('base64,')[1];
|
||||
const byteString = atob(base64Data);
|
||||
const mimeString = 'image/png'; // 默认MIME类型
|
||||
const ab = new ArrayBuffer(byteString.length);
|
||||
const ia = new Uint8Array(ab);
|
||||
for (let i = 0; i < byteString.length; i++) {
|
||||
ia[i] = byteString.charCodeAt(i);
|
||||
}
|
||||
blob = new Blob([ab], { type: mimeString });
|
||||
} else {
|
||||
// 从URL获取Blob
|
||||
const response = await fetch(imageData);
|
||||
blob = await response.blob();
|
||||
}
|
||||
} else {
|
||||
// 如果已经是Blob对象,直接使用
|
||||
blob = imageData;
|
||||
}
|
||||
const blob = new Blob([ab], { type: mimeString })
|
||||
|
||||
// 创建FormData对象
|
||||
const formData = new FormData()
|
||||
formData.append('file', blob, 'generated-image.png')
|
||||
const formData = new FormData();
|
||||
formData.append('file', blob, 'generated-image.png');
|
||||
|
||||
// 发送POST请求
|
||||
const response = await fetch(UPLOAD_URL, {
|
||||
method: 'POST',
|
||||
headers: { accessToken },
|
||||
body: formData,
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
throw new Error(`上传失败: ${response.status} ${response.statusText} - ${errorText}`)
|
||||
const errorText = await response.text();
|
||||
throw new Error(`上传失败: ${response.status} ${response.statusText} - ${errorText}`);
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
const result = await response.json();
|
||||
// 根据返回格式处理结果: {"code": 200,"msg": "上传成功","data": "9ecbaa0a0.jpg"}
|
||||
if (result.code === 200) {
|
||||
// 使用环境变量中的VITE_UPLOAD_ASSET_URL作为前缀
|
||||
const uploadAssetUrl = import.meta.env.VITE_UPLOAD_ASSET_URL || ''
|
||||
const fullUrl = uploadAssetUrl ? `${uploadAssetUrl}/${result.data}` : result.data
|
||||
const uploadAssetUrl = import.meta.env.VITE_UPLOAD_ASSET_URL || '';
|
||||
const fullUrl = uploadAssetUrl ? `${uploadAssetUrl}/${result.data}` : result.data;
|
||||
|
||||
// 清理过期缓存
|
||||
cleanupExpiredCache();
|
||||
@@ -130,19 +171,21 @@ export const uploadImage = async (base64Data: string, accessToken: string, skipC
|
||||
maintainCacheSize();
|
||||
|
||||
// 将上传结果存储到缓存中
|
||||
const uploadResult = { success: true, url: fullUrl, error: undefined }
|
||||
uploadCache.set(imageHash, {
|
||||
...uploadResult,
|
||||
timestamp: Date.now()
|
||||
})
|
||||
const uploadResult = { success: true, url: fullUrl, error: undefined };
|
||||
if (typeof imageData === 'string') {
|
||||
uploadCache.set(imageHash, {
|
||||
...uploadResult,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
}
|
||||
|
||||
return uploadResult
|
||||
return uploadResult;
|
||||
} else {
|
||||
throw new Error(`上传失败: ${result.msg}`)
|
||||
throw new Error(`上传失败: ${result.msg}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('上传图像时出错:', error)
|
||||
const errorResult = { success: false, url: undefined, error: error instanceof Error ? error.message : String(error) }
|
||||
console.error('上传图像时出错:', error);
|
||||
const errorResult = { success: false, url: undefined, error: error instanceof Error ? error.message : String(error) };
|
||||
|
||||
// 清理过期缓存
|
||||
cleanupExpiredCache();
|
||||
@@ -151,61 +194,63 @@ export const uploadImage = async (base64Data: string, accessToken: string, skipC
|
||||
maintainCacheSize();
|
||||
|
||||
// 将失败的上传结果也存储到缓存中(可选)
|
||||
uploadCache.set(imageHash, {
|
||||
...errorResult,
|
||||
timestamp: Date.now()
|
||||
})
|
||||
if (typeof imageData === 'string') {
|
||||
uploadCache.set(imageHash, {
|
||||
...errorResult,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
}
|
||||
|
||||
return errorResult
|
||||
return errorResult;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传多个图像
|
||||
* @param base64Images - base64编码的图像数组
|
||||
* @param imageDatas - 图像数据数组(可以是base64、Blob URL或Blob对象)
|
||||
* @param accessToken - 访问令牌
|
||||
* @param skipCache - 是否跳过缓存检查
|
||||
* @returns 上传结果数组
|
||||
*/
|
||||
export const uploadImages = async (base64Images: string[], accessToken: string, skipCache: boolean = false): Promise<UploadResult[]> => {
|
||||
export const uploadImages = async (imageDatas: (string | Blob)[], accessToken: string, skipCache: boolean = false): Promise<UploadResult[]> => {
|
||||
try {
|
||||
const results: UploadResult[] = []
|
||||
const results: UploadResult[] = [];
|
||||
|
||||
for (let i = 0; i < base64Images.length; i++) {
|
||||
const base64Data = base64Images[i]
|
||||
for (let i = 0; i < imageDatas.length; i++) {
|
||||
const imageData = imageDatas[i];
|
||||
try {
|
||||
const uploadResult = await uploadImage(base64Data, accessToken, skipCache)
|
||||
const uploadResult = await uploadImage(imageData, accessToken, skipCache);
|
||||
const result: UploadResult = {
|
||||
success: uploadResult.success,
|
||||
url: uploadResult.url,
|
||||
error: uploadResult.error,
|
||||
timestamp: Date.now(),
|
||||
}
|
||||
results.push(result)
|
||||
console.log(`第${i + 1}张图像上传${uploadResult.success ? '成功' : '失败'}:`, uploadResult)
|
||||
};
|
||||
results.push(result);
|
||||
console.log(`第${i + 1}张图像上传${uploadResult.success ? '成功' : '失败'}:`, uploadResult);
|
||||
} catch (error) {
|
||||
const result: UploadResult = {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
timestamp: Date.now(),
|
||||
}
|
||||
results.push(result)
|
||||
console.error(`第${i + 1}张图像上传失败:`, error)
|
||||
};
|
||||
results.push(result);
|
||||
console.error(`第${i + 1}张图像上传失败:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
// 检查是否有任何上传失败
|
||||
const failedUploads = results.filter(r => !r.success)
|
||||
const failedUploads = results.filter(r => !r.success);
|
||||
if (failedUploads.length > 0) {
|
||||
console.warn(`${failedUploads.length}张图像上传失败`)
|
||||
console.warn(`${failedUploads.length}张图像上传失败`);
|
||||
} else {
|
||||
console.log(`所有${results.length}张图像上传成功`)
|
||||
console.log(`所有${results.length}张图像上传成功`);
|
||||
}
|
||||
|
||||
return results
|
||||
return results;
|
||||
} catch (error) {
|
||||
console.error('批量上传图像时出错:', error)
|
||||
throw error
|
||||
console.error('批量上传图像时出错:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -213,6 +258,6 @@ export const uploadImages = async (base64Images: string[], accessToken: string,
|
||||
* 清除上传缓存
|
||||
*/
|
||||
export const clearUploadCache = (): void => {
|
||||
uploadCache.clear()
|
||||
console.log('上传缓存已清除')
|
||||
uploadCache.clear();
|
||||
console.log('上传缓存已清除');
|
||||
}
|
||||
@@ -127,8 +127,14 @@ interface AppState {
|
||||
// Blob URL清理操作
|
||||
revokeBlobUrls: (urls: string[]) => void;
|
||||
cleanupAllBlobUrls: () => void;
|
||||
|
||||
// 定期清理Blob URL
|
||||
scheduleBlobCleanup: () => void;
|
||||
}
|
||||
|
||||
// 限制历史记录数量
|
||||
const MAX_HISTORY_ITEMS = 50;
|
||||
|
||||
export const useAppStore = create<AppState>()(
|
||||
devtools(
|
||||
persist(
|
||||
@@ -250,7 +256,19 @@ export const useAppStore = create<AppState>()(
|
||||
checksum: asset.checksum,
|
||||
blobUrl
|
||||
};
|
||||
} else if (asset.url.startsWith('blob:')) {
|
||||
// 如果已经是Blob URL,直接使用
|
||||
return {
|
||||
id: asset.id,
|
||||
type: asset.type,
|
||||
mime: asset.mime,
|
||||
width: asset.width,
|
||||
height: asset.height,
|
||||
checksum: asset.checksum,
|
||||
blobUrl: asset.url
|
||||
};
|
||||
}
|
||||
// 对于其他URL类型,创建一个新的Blob URL
|
||||
return {
|
||||
id: asset.id,
|
||||
type: asset.type,
|
||||
@@ -285,7 +303,11 @@ export const useAppStore = create<AppState>()(
|
||||
});
|
||||
|
||||
return blobUrl;
|
||||
} else if (asset.url.startsWith('blob:')) {
|
||||
// 如果已经是Blob URL,直接使用
|
||||
return asset.url;
|
||||
}
|
||||
// 对于其他URL类型,直接使用
|
||||
return asset.url;
|
||||
});
|
||||
|
||||
@@ -316,11 +338,11 @@ export const useAppStore = create<AppState>()(
|
||||
updatedAt: Date.now()
|
||||
};
|
||||
|
||||
// 清理旧记录以保持在限制内(现在限制为1000条)
|
||||
if (updatedProject.generations.length > 1000) {
|
||||
// 清理旧记录以保持在限制内
|
||||
if (updatedProject.generations.length > MAX_HISTORY_ITEMS) {
|
||||
// 收集需要释放的Blob URLs
|
||||
const urlsToRevoke: string[] = [];
|
||||
const generationsToRemove = updatedProject.generations.slice(0, updatedProject.generations.length - 1000);
|
||||
const generationsToRemove = updatedProject.generations.slice(0, updatedProject.generations.length - MAX_HISTORY_ITEMS);
|
||||
generationsToRemove.forEach(gen => {
|
||||
gen.sourceAssets.forEach(asset => {
|
||||
if (asset.blobUrl.startsWith('blob:')) {
|
||||
@@ -348,9 +370,9 @@ export const useAppStore = create<AppState>()(
|
||||
}
|
||||
|
||||
// 清理数组
|
||||
updatedProject.generations.splice(0, updatedProject.generations.length - 1000);
|
||||
updatedProject.generations.splice(0, updatedProject.generations.length - MAX_HISTORY_ITEMS);
|
||||
// 同时清理IndexedDB中的旧记录
|
||||
indexedDBService.cleanupOldRecords(1000).catch(err => {
|
||||
indexedDBService.cleanupOldRecords(MAX_HISTORY_ITEMS).catch(err => {
|
||||
console.error('清理IndexedDB旧记录失败:', err);
|
||||
});
|
||||
}
|
||||
@@ -415,7 +437,11 @@ export const useAppStore = create<AppState>()(
|
||||
});
|
||||
|
||||
return blobUrl;
|
||||
} else if (asset.url.startsWith('blob:')) {
|
||||
// 如果已经是Blob URL,直接使用
|
||||
return asset.url;
|
||||
}
|
||||
// 对于其他URL类型,直接使用
|
||||
return asset.url;
|
||||
});
|
||||
|
||||
@@ -441,11 +467,11 @@ export const useAppStore = create<AppState>()(
|
||||
updatedAt: Date.now()
|
||||
};
|
||||
|
||||
// 清理旧记录以保持在限制内(现在限制为1000条)
|
||||
if (updatedProject.edits.length > 1000) {
|
||||
// 清理旧记录以保持在限制内
|
||||
if (updatedProject.edits.length > MAX_HISTORY_ITEMS) {
|
||||
// 收集需要释放的Blob URLs
|
||||
const urlsToRevoke: string[] = [];
|
||||
const editsToRemove = updatedProject.edits.slice(0, updatedProject.edits.length - 1000);
|
||||
const editsToRemove = updatedProject.edits.slice(0, updatedProject.edits.length - MAX_HISTORY_ITEMS);
|
||||
editsToRemove.forEach(edit => {
|
||||
if (edit.maskReferenceAssetBlobUrl && edit.maskReferenceAssetBlobUrl.startsWith('blob:')) {
|
||||
urlsToRevoke.push(edit.maskReferenceAssetBlobUrl);
|
||||
@@ -471,9 +497,9 @@ export const useAppStore = create<AppState>()(
|
||||
}
|
||||
|
||||
// 清理数组
|
||||
updatedProject.edits.splice(0, updatedProject.edits.length - 1000);
|
||||
updatedProject.edits.splice(0, updatedProject.edits.length - MAX_HISTORY_ITEMS);
|
||||
// 同时清理IndexedDB中的旧记录
|
||||
indexedDBService.cleanupOldRecords(1000).catch(err => {
|
||||
indexedDBService.cleanupOldRecords(MAX_HISTORY_ITEMS).catch(err => {
|
||||
console.error('清理IndexedDB旧记录失败:', err);
|
||||
});
|
||||
}
|
||||
@@ -492,7 +518,7 @@ export const useAppStore = create<AppState>()(
|
||||
|
||||
setSelectedTool: (tool) => set({ selectedTool: tool }),
|
||||
|
||||
// 清理旧的历史记录,保留最多1000条
|
||||
// 清理旧的历史记录
|
||||
cleanupOldHistory: () => set((state) => {
|
||||
if (!state.currentProject) return {};
|
||||
|
||||
@@ -502,9 +528,9 @@ export const useAppStore = create<AppState>()(
|
||||
// 收集需要释放的Blob URLs
|
||||
const urlsToRevoke: string[] = [];
|
||||
|
||||
// 如果生成记录超过1000条,只保留最新的1000条
|
||||
if (generations.length > 1000) {
|
||||
const generationsToRemove = generations.slice(0, generations.length - 1000);
|
||||
// 如果生成记录超过限制,只保留最新的记录
|
||||
if (generations.length > MAX_HISTORY_ITEMS) {
|
||||
const generationsToRemove = generations.slice(0, generations.length - MAX_HISTORY_ITEMS);
|
||||
generationsToRemove.forEach(gen => {
|
||||
gen.sourceAssets.forEach(asset => {
|
||||
if (asset.blobUrl.startsWith('blob:')) {
|
||||
@@ -517,12 +543,12 @@ export const useAppStore = create<AppState>()(
|
||||
}
|
||||
});
|
||||
});
|
||||
generations.splice(0, generations.length - 1000);
|
||||
generations.splice(0, generations.length - MAX_HISTORY_ITEMS);
|
||||
}
|
||||
|
||||
// 如果编辑记录超过1000条,只保留最新的1000条
|
||||
if (edits.length > 1000) {
|
||||
const editsToRemove = edits.slice(0, edits.length - 1000);
|
||||
// 如果编辑记录超过限制,只保留最新的记录
|
||||
if (edits.length > MAX_HISTORY_ITEMS) {
|
||||
const editsToRemove = edits.slice(0, edits.length - MAX_HISTORY_ITEMS);
|
||||
editsToRemove.forEach(edit => {
|
||||
if (edit.maskReferenceAssetBlobUrl && edit.maskReferenceAssetBlobUrl.startsWith('blob:')) {
|
||||
urlsToRevoke.push(edit.maskReferenceAssetBlobUrl);
|
||||
@@ -533,7 +559,7 @@ export const useAppStore = create<AppState>()(
|
||||
}
|
||||
});
|
||||
});
|
||||
edits.splice(0, edits.length - 1000);
|
||||
edits.splice(0, edits.length - MAX_HISTORY_ITEMS);
|
||||
}
|
||||
|
||||
// 释放Blob URLs
|
||||
@@ -550,7 +576,7 @@ export const useAppStore = create<AppState>()(
|
||||
}
|
||||
|
||||
// 同时清理IndexedDB中的旧记录
|
||||
indexedDBService.cleanupOldRecords(1000).catch(err => {
|
||||
indexedDBService.cleanupOldRecords(MAX_HISTORY_ITEMS).catch(err => {
|
||||
console.error('清理IndexedDB旧记录失败:', err);
|
||||
});
|
||||
|
||||
@@ -583,7 +609,42 @@ export const useAppStore = create<AppState>()(
|
||||
URL.revokeObjectURL(url);
|
||||
});
|
||||
return { ...state, blobStore: new Map() };
|
||||
})
|
||||
}),
|
||||
|
||||
// 定期清理Blob URL
|
||||
scheduleBlobCleanup: () => {
|
||||
// 清理超过10分钟未使用的Blob
|
||||
const state = get();
|
||||
const now = Date.now();
|
||||
|
||||
// 这里我们简单地清理所有Blob,因为在实际应用中很难跟踪哪些Blob正在使用
|
||||
// 在生产环境中,您可能需要更复杂的跟踪机制
|
||||
state.blobStore.forEach((blob, url) => {
|
||||
// 检查URL是否仍在使用中
|
||||
const isUsedInProject = state.currentProject && (
|
||||
state.currentProject.generations.some(gen =>
|
||||
gen.sourceAssets.some(asset => asset.blobUrl === url) ||
|
||||
gen.outputAssetsBlobUrls.some(outputUrl => outputUrl === url)
|
||||
) ||
|
||||
state.currentProject.edits.some(edit =>
|
||||
(edit.maskReferenceAssetBlobUrl === url) ||
|
||||
edit.outputAssetsBlobUrls.some(outputUrl => outputUrl === url)
|
||||
)
|
||||
);
|
||||
|
||||
const isUsedInCanvas = state.canvasImage === url;
|
||||
const isUsedInUploads = state.uploadedImages.includes(url);
|
||||
const isUsedInEdits = state.editReferenceImages.includes(url);
|
||||
|
||||
// 如果Blob没有被使用,则清理它
|
||||
if (!isUsedInProject && !isUsedInCanvas && !isUsedInUploads && !isUsedInEdits) {
|
||||
URL.revokeObjectURL(url);
|
||||
const newBlobStore = new Map(state.blobStore);
|
||||
newBlobStore.delete(url);
|
||||
set({ blobStore: newBlobStore });
|
||||
}
|
||||
});
|
||||
}
|
||||
}),
|
||||
{
|
||||
name: 'nano-banana-store',
|
||||
|
||||
@@ -23,6 +23,12 @@ export function blobToBase64(blob: Blob): Promise<string> {
|
||||
});
|
||||
}
|
||||
|
||||
// 将URL转换为Blob
|
||||
export async function urlToBlob(url: string): Promise<Blob> {
|
||||
const response = await fetch(url);
|
||||
return await response.blob();
|
||||
}
|
||||
|
||||
export function createImageFromBase64(base64: string): Promise<HTMLImageElement> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
@@ -48,16 +54,93 @@ export function generateId(): string {
|
||||
return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
}
|
||||
|
||||
export function downloadImage(base64: string, filename: string): void {
|
||||
const blob = base64ToBlob(base64);
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
|
||||
URL.revokeObjectURL(url);
|
||||
export function downloadImage(imageData: string, filename: string): void {
|
||||
if (imageData.startsWith('blob:')) {
|
||||
// 对于Blob URL,我们需要获取实际的Blob数据
|
||||
fetch(imageData)
|
||||
.then(response => response.blob())
|
||||
.then(blob => {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
});
|
||||
} else if (imageData.startsWith('data:')) {
|
||||
// 对于数据URL,直接下载
|
||||
const a = document.createElement('a');
|
||||
a.href = imageData;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
} else {
|
||||
// 对于其他URL,获取并转换为blob
|
||||
fetch(imageData)
|
||||
.then(response => response.blob())
|
||||
.then(blob => {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 优化的图像压缩函数
|
||||
export async function compressImage(blob: Blob, quality: number = 0.8): Promise<Blob> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
if (!ctx) {
|
||||
reject(new Error('无法获取canvas上下文'));
|
||||
return;
|
||||
}
|
||||
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
// 设置canvas尺寸
|
||||
canvas.width = img.width;
|
||||
canvas.height = img.height;
|
||||
|
||||
// 绘制图像
|
||||
ctx.drawImage(img, 0, 0);
|
||||
|
||||
// 转换为Blob
|
||||
canvas.toBlob(
|
||||
(compressedBlob) => {
|
||||
if (compressedBlob) {
|
||||
resolve(compressedBlob);
|
||||
} else {
|
||||
reject(new Error('图像压缩失败'));
|
||||
}
|
||||
},
|
||||
'image/jpeg',
|
||||
quality
|
||||
);
|
||||
};
|
||||
|
||||
img.onerror = reject;
|
||||
|
||||
// 将Blob转换为URL以便加载到图像中
|
||||
const url = URL.createObjectURL(blob);
|
||||
img.src = url;
|
||||
|
||||
// 清理URL
|
||||
img.onload = () => {
|
||||
URL.revokeObjectURL(url);
|
||||
// 调用原始的onload处理程序
|
||||
if (img.onload) {
|
||||
(img.onload as any).call(img);
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user