From 9674740c0dd9d26dcc4254c3c03f2ae512879d57 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=A2=81=E6=B6=9B?= Date: Fri, 19 Sep 2025 01:25:30 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=86=85=E5=AD=98=E6=BA=A2?= =?UTF-8?q?=E5=87=BA=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- debug/test_cleanup.js | 79 +++++++++ src/App.tsx | 24 ++- src/components/HistoryPanel.tsx | 104 ++++++----- src/components/ImageCanvas.tsx | 18 +- src/components/PromptComposer.tsx | 55 ++++-- src/hooks/useImageGeneration.ts | 277 ++++++++++++++++++++++++------ src/services/cacheService.ts | 54 +++++- src/services/geminiService.ts | 82 +++++++-- src/services/imageProcessing.ts | 95 ---------- src/services/indexedDBService.ts | 249 ++++++++++++++++++++++++++- src/services/uploadService.ts | 175 ++++++++++++------- src/store/useAppStore.ts | 103 ++++++++--- src/utils/imageUtils.ts | 107 ++++++++++-- 13 files changed, 1085 insertions(+), 337 deletions(-) create mode 100644 debug/test_cleanup.js delete mode 100644 src/services/imageProcessing.ts diff --git a/debug/test_cleanup.js b/debug/test_cleanup.js new file mode 100644 index 0000000..0c620fa --- /dev/null +++ b/debug/test_cleanup.js @@ -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(); \ No newline at end of file diff --git a/src/App.tsx b/src/App.tsx index 2ed90a3..e494eb8 100644 --- a/src/App.tsx +++ b/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 (
diff --git a/src/components/HistoryPanel.tsx b/src/components/HistoryPanel.tsx index d3e4ca1..3ab8d42 100644 --- a/src/components/HistoryPanel.tsx +++ b/src/components/HistoryPanel.tsx @@ -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 = () => {

变体

- {filteredGenerations.length + filteredEdits.length}/1000 + {filteredGenerations.length + filteredEdits.length}/100
{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 生成的变体; @@ -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 编辑的变体; @@ -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' }} >
@@ -1276,7 +1274,7 @@ export const HistoryPanel: React.FC = () => { 预览 {/* 图像信息 */}
@@ -1286,12 +1284,6 @@ export const HistoryPanel: React.FC = () => { {hoveredImage.width} × {hoveredImage.height}
)} - {hoveredImage.size > 0 && ( -
- 大小: - {Math.round(hoveredImage.size / 1024)} KB -
- )}
)} diff --git a/src/components/ImageCanvas.tsx b/src/components/ImageCanvas.tsx index f9766ec..d4e7553 100644 --- a/src/components/ImageCanvas.tsx +++ b/src/components/ImageCanvas.tsx @@ -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(() => { diff --git a/src/components/PromptComposer.tsx b/src/components/PromptComposer.tsx index 612b3d7..001236f 100644 --- a/src/components/PromptComposer.tsx +++ b/src/components/PromptComposer.tsx @@ -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(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); diff --git a/src/hooks/useImageGeneration.ts b/src/hooks/useImageGeneration.ts index 3bd79c9..302f2c9 100644 --- a/src/hooks/useImageGeneration.ts +++ b/src/hooks/useImageGeneration.ts @@ -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(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(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((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(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(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, } -} +} \ No newline at end of file diff --git a/src/services/cacheService.ts b/src/services/cacheService.ts index ed732f1..a90ecb8 100644 --- a/src/services/cacheService.ts +++ b/src/services/cacheService.ts @@ -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 { + // 在保存新项目之前,清理旧缓存 + 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 { + // 在保存新资产之前,清理旧缓存 + 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 { + // 在保存新生成记录之前,清理旧缓存 + 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 { + static async clearOldCache(maxAge: number = MAX_CACHE_AGE): Promise { 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 { + const allKeys = await keys(); + const cacheKeys = allKeys.filter(key => + typeof key === 'string' && key.startsWith(CACHE_PREFIX) + ); + + for (const key of cacheKeys) { + await del(key); + } } } \ No newline at end of file diff --git a/src/services/geminiService.ts b/src/services/geminiService.ts index c8c4a96..7406436 100644 --- a/src/services/geminiService.ts +++ b/src/services/geminiService.ts @@ -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 { + 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 { 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, }, }, ] diff --git a/src/services/imageProcessing.ts b/src/services/imageProcessing.ts deleted file mode 100644 index 7569546..0000000 --- a/src/services/imageProcessing.ts +++ /dev/null @@ -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 { - // 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 - } -} \ No newline at end of file diff --git a/src/services/indexedDBService.ts b/src/services/indexedDBService.ts index 04ff5e0..d048984 100644 --- a/src/services/indexedDBService.ts +++ b/src/services/indexedDBService.ts @@ -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 => { + // 创建轻量级生成记录,只存储必要的信息和上传后的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 => { * 添加编辑记录 */ export const addEdit = async (edit: Edit): Promise => { + // 创建轻量级编辑记录,只存储必要的信息和上传后的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 => { +export const cleanupOldRecords = async (limit: number = 100): Promise => { 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 => } }; +/** + * 清理记录中的base64数据 + */ +export const cleanupBase64Data = async (): Promise => { + 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 => { request.onerror = () => reject(request.error); }) ]).then(() => undefined); +}; + +/** + * 关闭数据库连接 + */ +export const closeDB = (): void => { + if (db) { + db.close(); + db = null; + } }; \ No newline at end of file diff --git a/src/services/uploadService.ts b/src/services/uploadService.ts index ce16a62..ae01063 100644 --- a/src/services/uploadService.ts +++ b/src/services/uploadService.ts @@ -8,8 +8,8 @@ const UPLOAD_URL = 'https://api.pandorastudio.cn/auth/OSSupload' const uploadCache = new Map() // 缓存配置 -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 { + // 从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 => { +export const uploadImages = async (imageDatas: (string | Blob)[], accessToken: string, skipCache: boolean = false): Promise => { 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('上传缓存已清除'); +} \ No newline at end of file diff --git a/src/store/useAppStore.ts b/src/store/useAppStore.ts index b833160..e6723cd 100644 --- a/src/store/useAppStore.ts +++ b/src/store/useAppStore.ts @@ -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()( devtools( persist( @@ -250,7 +256,19 @@ export const useAppStore = create()( 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()( }); 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()( 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()( } // 清理数组 - 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()( }); 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()( 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()( } // 清理数组 - 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()( setSelectedTool: (tool) => set({ selectedTool: tool }), - // 清理旧的历史记录,保留最多1000条 + // 清理旧的历史记录 cleanupOldHistory: () => set((state) => { if (!state.currentProject) return {}; @@ -502,9 +528,9 @@ export const useAppStore = create()( // 收集需要释放的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()( } }); }); - 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()( } }); }); - edits.splice(0, edits.length - 1000); + edits.splice(0, edits.length - MAX_HISTORY_ITEMS); } // 释放Blob URLs @@ -550,7 +576,7 @@ export const useAppStore = create()( } // 同时清理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()( 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', diff --git a/src/utils/imageUtils.ts b/src/utils/imageUtils.ts index e24c844..449e3ec 100644 --- a/src/utils/imageUtils.ts +++ b/src/utils/imageUtils.ts @@ -23,6 +23,12 @@ export function blobToBase64(blob: Blob): Promise { }); } +// 将URL转换为Blob +export async function urlToBlob(url: string): Promise { + const response = await fetch(url); + return await response.blob(); +} + export function createImageFromBase64(base64: string): Promise { 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 { + 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); + } + }; + }); } \ No newline at end of file