修复内存溢出问题

This commit is contained in:
2025-09-19 01:25:30 +08:00
parent 803cc100be
commit 9674740c0d
13 changed files with 1085 additions and 337 deletions

79
debug/test_cleanup.js Normal file
View 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();

View File

@@ -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">

View File

@@ -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>
)}

View File

@@ -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(() => {

View File

@@ -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);

View File

@@ -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 || '';
@@ -373,4 +554,4 @@ export const useImageEditing = () => {
error: editMutation.error,
cancelEdit,
}
}
}

View File

@@ -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);
}
}
}

View File

@@ -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,
},
},
]

View File

@@ -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
}
}

View File

@@ -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);
}
};
/**
* 清空所有记录
*/
@@ -205,4 +434,14 @@ export const clearAllRecords = async (): Promise<void> => {
request.onerror = () => reject(request.error);
})
]).then(() => undefined);
};
/**
* 关闭数据库连接
*/
export const closeDB = (): void => {
if (db) {
db.close();
db = null;
}
};

View File

@@ -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('上传缓存已清除');
}

View File

@@ -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',

View File

@@ -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);
}
};
});
}