You've already forked Nano-Banana-AI-Image-Editor
Merge branch 'futrue'
This commit is contained in:
@@ -1,26 +0,0 @@
|
||||
import type { Config } from 'jest';
|
||||
|
||||
const config: Config = {
|
||||
preset: 'ts-jest',
|
||||
testEnvironment: 'jsdom',
|
||||
setupFilesAfterEnv: ['<rootDir>/src/__tests__/setup.ts'],
|
||||
moduleNameMapper: {
|
||||
'\.(css|less|scss|sass)$': 'identity-obj-proxy',
|
||||
'^@/(.*)$': '<rootDir>/src/$1'
|
||||
},
|
||||
testMatch: [
|
||||
'<rootDir>/src/__tests__/**/*.{ts,tsx}',
|
||||
'<rootDir>/src/**/*.{spec,test}.{ts,tsx}'
|
||||
],
|
||||
transform: {
|
||||
'^.+\.(ts|tsx)$': 'ts-jest'
|
||||
},
|
||||
collectCoverageFrom: [
|
||||
'src/**/*.{ts,tsx}',
|
||||
'!src/**/*.d.ts',
|
||||
'!src/main.tsx',
|
||||
'!src/vite-env.d.ts'
|
||||
]
|
||||
};
|
||||
|
||||
export default config;
|
||||
9
package-lock.json
generated
9
package-lock.json
generated
@@ -4456,9 +4456,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/caniuse-lite": {
|
||||
"version": "1.0.30001667",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001667.tgz",
|
||||
"integrity": "sha512-7LTwJjcRkzKFmtqGsibMeuXmvFDfZq/nzIjnmgCGzKKRVzjD72selLDK1oPF/Oxzmt4fNcPvTDvGqSDG4tCALw==",
|
||||
"version": "1.0.30001743",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001743.tgz",
|
||||
"integrity": "sha512-e6Ojr7RV14Un7dz6ASD0aZDmQPT/A+eZU+nuTNfjqmRrmkmQlnTNWH0SKmqagx9PeW87UVqapSurtAXifmtdmw==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -4473,7 +4473,8 @@
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
]
|
||||
],
|
||||
"license": "CC-BY-4.0"
|
||||
},
|
||||
"node_modules/canvas": {
|
||||
"version": "3.2.0",
|
||||
|
||||
21
src/App.tsx
21
src/App.tsx
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React, { useEffect, useState, useReducer } from 'react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { cn } from './utils/cn';
|
||||
import { Header } from './components/Header';
|
||||
@@ -40,6 +40,16 @@ function AppContent() {
|
||||
};
|
||||
|
||||
init();
|
||||
|
||||
// 组件卸载时清理所有Blob URL
|
||||
return () => {
|
||||
const { blobStore } = useAppStore.getState();
|
||||
blobStore.forEach((blob, url) => {
|
||||
if (url.startsWith('blob:')) {
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
});
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 在挂载时设置移动设备默认值
|
||||
@@ -65,15 +75,6 @@ function AppContent() {
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
// 定期清理未使用的Blob URL
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
useAppStore.getState().scheduleBlobCleanup();
|
||||
}, 60000); // 每分钟清理一次
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
// 控制预览窗口的显示和隐藏动画
|
||||
useEffect(() => {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,25 +2,21 @@ import React, { useRef, useEffect, useState, useCallback } from 'react';
|
||||
import { Stage, Layer, Image as KonvaImage, Line } from 'react-konva';
|
||||
import { useAppStore } from '../store/useAppStore';
|
||||
import { Button } from './ui/Button';
|
||||
import { ZoomIn, ZoomOut, RotateCcw, Download, Eye, EyeOff, Eraser } from 'lucide-react';
|
||||
import { cn } from '../utils/cn';
|
||||
import { ZoomIn, ZoomOut, RotateCcw, Download } from 'lucide-react';
|
||||
|
||||
export const ImageCanvas: React.FC = () => {
|
||||
const {
|
||||
canvasImage,
|
||||
canvasZoom,
|
||||
setCanvasZoom,
|
||||
canvasPan,
|
||||
setCanvasZoom,
|
||||
setCanvasPan,
|
||||
brushStrokes,
|
||||
addBrushStroke,
|
||||
clearBrushStrokes,
|
||||
showMasks,
|
||||
setShowMasks,
|
||||
selectedTool,
|
||||
isGenerating,
|
||||
brushSize,
|
||||
setBrushSize,
|
||||
showHistory,
|
||||
showPromptPanel
|
||||
} = useAppStore();
|
||||
@@ -50,119 +46,198 @@ export const ImageCanvas: React.FC = () => {
|
||||
}
|
||||
}, 0);
|
||||
}
|
||||
}, []);
|
||||
}, [setCanvasZoom]);
|
||||
|
||||
// 加载图像
|
||||
useEffect(() => {
|
||||
let img: HTMLImageElement | null = null;
|
||||
console.log('useEffect triggered, canvasImage:', canvasImage);
|
||||
|
||||
if (canvasImage) {
|
||||
console.log('开始加载图像,URL:', canvasImage);
|
||||
// 如果没有图像URL,直接返回
|
||||
if (!canvasImage) {
|
||||
console.log('没有图像需要加载');
|
||||
setImage(null);
|
||||
return;
|
||||
}
|
||||
|
||||
let img: HTMLImageElement | null = null;
|
||||
let isCancelled = false;
|
||||
|
||||
console.log('开始加载图像,URL:', canvasImage);
|
||||
|
||||
img = new window.Image();
|
||||
|
||||
img.onload = () => {
|
||||
// 检查是否已取消
|
||||
if (isCancelled) {
|
||||
console.log('图像加载被取消');
|
||||
return;
|
||||
}
|
||||
|
||||
img = new window.Image();
|
||||
let isCancelled = false;
|
||||
console.log('图像加载成功,尺寸:', img.width, 'x', img.height);
|
||||
setImage(img);
|
||||
|
||||
img.onload = () => {
|
||||
// 检查是否已取消
|
||||
if (isCancelled) {
|
||||
console.log('图像加载被取消');
|
||||
return;
|
||||
}
|
||||
// 只在图像首次加载时自动适应画布
|
||||
if (!isCancelled && img) {
|
||||
const isMobile = window.innerWidth < 768;
|
||||
const padding = isMobile ? 0.9 : 0.8;
|
||||
|
||||
console.log('图像加载成功,尺寸:', img.width, 'x', img.height);
|
||||
setImage(img);
|
||||
const scaleX = (stageSize.width * padding) / img.width;
|
||||
const scaleY = (stageSize.height * padding) / img.height;
|
||||
|
||||
// 只在图像首次加载时自动适应画布
|
||||
if (!isCancelled && img) {
|
||||
const isMobile = window.innerWidth < 768;
|
||||
const padding = isMobile ? 0.9 : 0.8;
|
||||
const maxZoom = isMobile ? 0.3 : 0.8;
|
||||
const optimalZoom = Math.min(scaleX, scaleY, maxZoom);
|
||||
|
||||
// 立即更新React状态以确保Konva Image组件使用正确的缩放值
|
||||
setCanvasZoom(optimalZoom);
|
||||
setCanvasPan({ x: 0, y: 0 });
|
||||
|
||||
// 使用setTimeout确保DOM已更新后再设置Stage
|
||||
setTimeout(() => {
|
||||
// 检查是否已取消
|
||||
if (isCancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const scaleX = (stageSize.width * padding) / img.width;
|
||||
const scaleY = (stageSize.height * padding) / img.height;
|
||||
|
||||
const maxZoom = isMobile ? 0.3 : 0.8;
|
||||
const optimalZoom = Math.min(scaleX, scaleY, maxZoom);
|
||||
|
||||
// 立即更新React状态以确保Konva Image组件使用正确的缩放值
|
||||
setCanvasZoom(optimalZoom);
|
||||
setCanvasPan({ x: 0, y: 0 });
|
||||
|
||||
// 使用setTimeout确保DOM已更新后再设置Stage
|
||||
setTimeout(() => {
|
||||
if (!isCancelled && img) {
|
||||
// 直接设置缩放,但保持Stage居中
|
||||
const stage = stageRef.current;
|
||||
if (stage) {
|
||||
stage.scale({ x: optimalZoom, y: optimalZoom });
|
||||
// 重置Stage位置以确保居中
|
||||
stage.position({ x: 0, y: 0 });
|
||||
stage.batchDraw();
|
||||
if (!isCancelled && img) {
|
||||
// 直接设置缩放,但保持Stage居中
|
||||
const stage = stageRef.current;
|
||||
if (stage) {
|
||||
stage.scale({ x: optimalZoom, y: optimalZoom });
|
||||
// 重置Stage位置以确保居中
|
||||
stage.position({ x: 0, y: 0 });
|
||||
stage.batchDraw();
|
||||
}
|
||||
|
||||
console.log('图像自动适应画布完成,缩放:', optimalZoom);
|
||||
}
|
||||
}, 0);
|
||||
}
|
||||
};
|
||||
|
||||
img.onerror = (error) => {
|
||||
// 检查是否已取消
|
||||
if (isCancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.error('图像加载失败:', error);
|
||||
console.error('图像URL:', canvasImage);
|
||||
|
||||
// 检查是否是IndexedDB URL
|
||||
if (canvasImage.startsWith('indexeddb://')) {
|
||||
console.log('正在处理IndexedDB图像...');
|
||||
|
||||
// 从IndexedDB获取图像并创建Blob URL
|
||||
const imageId = canvasImage.replace('indexeddb://', '');
|
||||
import('../services/referenceImageService').then((module) => {
|
||||
const referenceImageService = module;
|
||||
referenceImageService.getReferenceImage(imageId)
|
||||
.then(blob => {
|
||||
// 检查是否已取消
|
||||
if (isCancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('图像自动适应画布完成,缩放:', optimalZoom);
|
||||
}
|
||||
}, 0);
|
||||
}
|
||||
};
|
||||
|
||||
img.onerror = (error) => {
|
||||
if (!isCancelled) {
|
||||
console.error('图像加载失败:', error);
|
||||
console.error('图像URL:', canvasImage);
|
||||
if (blob) {
|
||||
const newUrl = URL.createObjectURL(blob);
|
||||
console.log('从IndexedDB创建新的Blob URL:', newUrl);
|
||||
// 更新canvasImage为新的URL
|
||||
import('../store/useAppStore').then((storeModule) => {
|
||||
const useAppStore = storeModule.useAppStore;
|
||||
// 检查是否已取消
|
||||
if (!isCancelled) {
|
||||
useAppStore.getState().setCanvasImage(newUrl);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
console.error('IndexedDB中未找到图像');
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
// 检查是否已取消
|
||||
if (isCancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.error('从IndexedDB获取图像时出错:', err);
|
||||
});
|
||||
}).catch(err => {
|
||||
// 检查是否已取消
|
||||
if (isCancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查是否是Blob URL
|
||||
if (canvasImage.startsWith('blob:')) {
|
||||
console.log('正在检查Blob URL是否有效...');
|
||||
console.error('导入referenceImageService时出错:', err);
|
||||
});
|
||||
}
|
||||
// 检查是否是Blob URL
|
||||
else if (canvasImage.startsWith('blob:')) {
|
||||
console.log('正在检查Blob URL是否有效...');
|
||||
|
||||
// 尝试从AppStore重新获取Blob并创建新的URL
|
||||
import('../store/useAppStore').then((module) => {
|
||||
const useAppStore = module.useAppStore;
|
||||
const blob = useAppStore.getState().getBlob(canvasImage);
|
||||
if (blob) {
|
||||
// 检查是否已取消
|
||||
if (isCancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查Blob URL是否仍然有效
|
||||
console.log('从AppStore找到Blob,尝试重新创建URL...');
|
||||
// 重新创建Blob URL并重试加载
|
||||
const newUrl = URL.createObjectURL(blob);
|
||||
console.log('创建新的Blob URL:', newUrl);
|
||||
// 更新canvasImage为新的URL
|
||||
useAppStore.getState().setCanvasImage(newUrl);
|
||||
} else {
|
||||
// 检查是否已取消
|
||||
if (isCancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.error('AppStore中未找到Blob');
|
||||
// 如果AppStore中也没有,尝试通过fetch检查URL
|
||||
fetch(canvasImage)
|
||||
.then(response => {
|
||||
// 检查是否已取消
|
||||
if (isCancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
console.error('Blob URL无法访问:', response.status, response.statusText);
|
||||
} else {
|
||||
console.log('Blob URL可以访问');
|
||||
console.log('Blob URL可以访问,但图像加载仍然失败');
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('检查Blob URL时出错:', err);
|
||||
// 尝试从AppStore重新获取Blob
|
||||
import('../store/useAppStore').then((module) => {
|
||||
const useAppStore = module.useAppStore;
|
||||
const blob = useAppStore.getState().getBlob(canvasImage);
|
||||
if (blob) {
|
||||
console.log('从AppStore找到Blob,尝试重新创建URL...');
|
||||
// 重新创建Blob URL并重试加载
|
||||
const newUrl = URL.createObjectURL(blob);
|
||||
console.log('创建新的Blob URL:', newUrl);
|
||||
// 更新canvasImage为新的URL
|
||||
useAppStore.getState().setCanvasImage(newUrl);
|
||||
} else {
|
||||
console.error('AppStore中未找到Blob');
|
||||
}
|
||||
}).catch(err => {
|
||||
console.error('导入AppStore时出错:', err);
|
||||
});
|
||||
.catch(fetchErr => {
|
||||
// 检查是否已取消
|
||||
if (isCancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.error('检查Blob URL时出错:', fetchErr);
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
img.src = canvasImage;
|
||||
} else {
|
||||
console.log('没有图像需要加载');
|
||||
// 当没有图像时,清理之前的图像对象
|
||||
if (image) {
|
||||
// 清理图像对象以释放内存
|
||||
image.onload = null;
|
||||
image.onerror = null;
|
||||
image.src = '';
|
||||
}).catch(err => {
|
||||
// 检查是否已取消
|
||||
if (isCancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.error('导入AppStore时出错:', err);
|
||||
});
|
||||
}
|
||||
setImage(null);
|
||||
}
|
||||
};
|
||||
|
||||
img.src = canvasImage;
|
||||
|
||||
// 清理函数
|
||||
return () => {
|
||||
console.log('清理图像加载资源');
|
||||
// 标记为已取消
|
||||
isCancelled = true;
|
||||
// 取消图像加载
|
||||
if (img) {
|
||||
img.onload = null;
|
||||
@@ -170,15 +245,8 @@ export const ImageCanvas: React.FC = () => {
|
||||
// 清理图像源以释放内存
|
||||
img.src = '';
|
||||
}
|
||||
|
||||
// 清理之前的图像对象
|
||||
if (image) {
|
||||
image.onload = null;
|
||||
image.onerror = null;
|
||||
image.src = '';
|
||||
}
|
||||
};
|
||||
}, [canvasImage]); // 只依赖canvasImage,避免其他依赖引起循环
|
||||
}, [canvasImage, setCanvasZoom, setCanvasPan, stageSize.height, stageSize.width]); // 移除image依赖项
|
||||
|
||||
// 处理舞台大小调整
|
||||
useEffect(() => {
|
||||
@@ -195,7 +263,7 @@ export const ImageCanvas: React.FC = () => {
|
||||
updateSize();
|
||||
window.addEventListener('resize', updateSize);
|
||||
return () => window.removeEventListener('resize', updateSize);
|
||||
}, []);
|
||||
}, [showPromptPanel, showHistory]);
|
||||
|
||||
// 监听面板状态变化以调整画布大小
|
||||
useEffect(() => {
|
||||
@@ -226,14 +294,13 @@ export const ImageCanvas: React.FC = () => {
|
||||
|
||||
container.addEventListener('wheel', handleWheel, { passive: false });
|
||||
return () => container.removeEventListener('wheel', handleWheel);
|
||||
}, [canvasZoom]);
|
||||
}, [canvasZoom, handleZoom]);
|
||||
|
||||
const handleMouseDown = (e: any) => {
|
||||
const handleMouseDown = (e: Konva.KonvaEventObject<MouseEvent>) => {
|
||||
if (selectedTool !== 'mask' || !image) return;
|
||||
|
||||
setIsDrawing(true);
|
||||
const stage = e.target.getStage();
|
||||
const pos = stage.getPointerPosition();
|
||||
|
||||
// 使用 Konva 的 getRelativePointerPosition 获取准确坐标
|
||||
const relativePos = stage.getRelativePointerPosition();
|
||||
@@ -252,11 +319,10 @@ export const ImageCanvas: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseMove = (e: any) => {
|
||||
const handleMouseMove = (e: Konva.KonvaEventObject<MouseEvent>) => {
|
||||
if (!isDrawing || selectedTool !== 'mask' || !image) return;
|
||||
|
||||
const stage = e.target.getStage();
|
||||
const pos = stage.getPointerPosition();
|
||||
|
||||
// 使用 Konva 的 getRelativePointerPosition 获取准确坐标
|
||||
const relativePos = stage.getRelativePointerPosition();
|
||||
@@ -334,17 +400,17 @@ export const ImageCanvas: React.FC = () => {
|
||||
// 下载第一个上传结果(通常是生成的图像)
|
||||
const uploadResult = selectedRecord.uploadResults[0];
|
||||
if (uploadResult.success && uploadResult.url) {
|
||||
// 使用async IIFE处理异步操作
|
||||
(async () => {
|
||||
try {
|
||||
// 首先尝试使用fetch获取图像数据
|
||||
const response = await fetch(uploadResult.url);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
const blob = await response.blob();
|
||||
|
||||
// 创建下载链接
|
||||
// 使用fetch获取图像数据并创建Blob URL以确保正确下载
|
||||
// 添加更多缓存控制头以绕过CDN缓存
|
||||
fetch(uploadResult.url, {
|
||||
headers: {
|
||||
'Cache-Control': 'no-cache, no-store, must-revalidate',
|
||||
'Pragma': 'no-cache',
|
||||
'Expires': '0'
|
||||
}
|
||||
})
|
||||
.then(response => response.blob())
|
||||
.then(blob => {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
@@ -352,64 +418,21 @@ export const ImageCanvas: React.FC = () => {
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
|
||||
// 清理创建的URL
|
||||
setTimeout(() => URL.revokeObjectURL(url), 100);
|
||||
|
||||
console.log('上传后的图像下载成功:', uploadResult.url);
|
||||
} catch (error) {
|
||||
console.error('使用fetch下载上传后的图像时出错:', error);
|
||||
// 如果fetch失败(可能是跨域问题),使用Canvas方案
|
||||
try {
|
||||
const img = new Image();
|
||||
img.crossOrigin = 'Anonymous'; // 设置跨域属性
|
||||
img.onload = () => {
|
||||
try {
|
||||
// 创建canvas并绘制图像
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = img.width;
|
||||
canvas.height = img.height;
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.drawImage(img, 0, 0);
|
||||
|
||||
// 将canvas转换为blob并下载
|
||||
canvas.toBlob((blob) => {
|
||||
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
|
||||
setTimeout(() => URL.revokeObjectURL(url), 100);
|
||||
|
||||
console.log('使用Canvas方案下载成功');
|
||||
} else {
|
||||
console.error('Canvas转换为blob失败');
|
||||
}
|
||||
}, 'image/png');
|
||||
} catch (canvasError) {
|
||||
console.error('Canvas处理失败:', canvasError);
|
||||
}
|
||||
};
|
||||
|
||||
img.onerror = (imgError) => {
|
||||
console.error('图像加载失败:', imgError);
|
||||
console.log('下载失败,未执行回退方案');
|
||||
};
|
||||
|
||||
img.src = uploadResult.url;
|
||||
} catch (canvasError) {
|
||||
console.error('Canvas方案也失败了:', canvasError);
|
||||
console.log('下载失败,未执行回退方案');
|
||||
}
|
||||
}
|
||||
})();
|
||||
URL.revokeObjectURL(url);
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('下载图像失败:', error);
|
||||
// 如果fetch失败,回退到直接使用a标签
|
||||
const link = document.createElement('a');
|
||||
link.href = uploadResult.url;
|
||||
link.download = `nano-banana-${Date.now()}.png`;
|
||||
link.target = '_blank';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
});
|
||||
|
||||
// 立即返回,让异步操作在后台进行
|
||||
// 立即返回
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -446,14 +469,17 @@ export const ImageCanvas: React.FC = () => {
|
||||
document.body.removeChild(link);
|
||||
} else if (canvasImage.startsWith('blob:')) {
|
||||
// Blob URL格式
|
||||
// 使用async IIFE处理异步操作
|
||||
(async () => {
|
||||
try {
|
||||
const response = await fetch(canvasImage);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
const blob = await response.blob();
|
||||
// 使用fetch获取图像数据并创建Blob URL以确保正确下载
|
||||
// 添加更多缓存控制头以绕过CDN缓存
|
||||
fetch(canvasImage, {
|
||||
headers: {
|
||||
'Cache-Control': 'no-cache, no-store, must-revalidate',
|
||||
'Pragma': 'no-cache',
|
||||
'Expires': '0'
|
||||
}
|
||||
})
|
||||
.then(response => response.blob())
|
||||
.then(blob => {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
@@ -461,68 +487,32 @@ export const ImageCanvas: React.FC = () => {
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
// 清理创建的URL
|
||||
setTimeout(() => URL.revokeObjectURL(url), 100);
|
||||
} catch (error) {
|
||||
console.error('下载Blob图像时出错:', error);
|
||||
// 如果fetch失败(可能是跨域问题),使用Canvas方案
|
||||
try {
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
try {
|
||||
// 创建canvas并绘制图像
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = img.width;
|
||||
canvas.height = img.height;
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.drawImage(img, 0, 0);
|
||||
|
||||
// 将canvas转换为blob并下载
|
||||
canvas.toBlob((blob) => {
|
||||
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
|
||||
setTimeout(() => URL.revokeObjectURL(url), 100);
|
||||
|
||||
console.log('使用Canvas方案下载成功');
|
||||
} else {
|
||||
console.error('Canvas转换为blob失败');
|
||||
}
|
||||
}, 'image/png');
|
||||
} catch (canvasError) {
|
||||
console.error('Canvas处理失败:', canvasError);
|
||||
}
|
||||
};
|
||||
|
||||
img.onerror = (imgError) => {
|
||||
console.error('图像加载失败:', imgError);
|
||||
console.log('下载失败,未执行回退方案');
|
||||
};
|
||||
|
||||
img.src = canvasImage;
|
||||
} catch (canvasError) {
|
||||
console.error('Canvas方案也失败了:', canvasError);
|
||||
console.log('下载失败,未执行回退方案');
|
||||
}
|
||||
}
|
||||
})();
|
||||
URL.revokeObjectURL(url);
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('下载图像失败:', error);
|
||||
// 如果fetch失败,回退到直接使用a标签
|
||||
const link = document.createElement('a');
|
||||
link.href = canvasImage;
|
||||
link.download = `nano-banana-${Date.now()}.png`;
|
||||
link.target = '_blank';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
});
|
||||
} else {
|
||||
// 普通URL格式
|
||||
// 使用async IIFE处理异步操作
|
||||
(async () => {
|
||||
try {
|
||||
const response = await fetch(canvasImage);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
const blob = await response.blob();
|
||||
// 使用fetch获取图像数据并创建Blob URL以确保正确下载
|
||||
// 添加更多缓存控制头以绕过CDN缓存
|
||||
fetch(canvasImage, {
|
||||
headers: {
|
||||
'Cache-Control': 'no-cache, no-store, must-revalidate',
|
||||
'Pragma': 'no-cache',
|
||||
'Expires': '0'
|
||||
}
|
||||
})
|
||||
.then(response => response.blob())
|
||||
.then(blob => {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
@@ -530,59 +520,19 @@ export const ImageCanvas: React.FC = () => {
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
// 清理创建的URL
|
||||
setTimeout(() => URL.revokeObjectURL(url), 100);
|
||||
} catch (error) {
|
||||
console.error('下载图像时出错:', error);
|
||||
// 如果fetch失败(可能是跨域问题),使用Canvas方案
|
||||
try {
|
||||
const img = new Image();
|
||||
img.crossOrigin = 'Anonymous'; // 设置跨域属性
|
||||
img.onload = () => {
|
||||
try {
|
||||
// 创建canvas并绘制图像
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = img.width;
|
||||
canvas.height = img.height;
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.drawImage(img, 0, 0);
|
||||
|
||||
// 将canvas转换为blob并下载
|
||||
canvas.toBlob((blob) => {
|
||||
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
|
||||
setTimeout(() => URL.revokeObjectURL(url), 100);
|
||||
|
||||
console.log('使用Canvas方案下载成功');
|
||||
} else {
|
||||
console.error('Canvas转换为blob失败');
|
||||
}
|
||||
}, 'image/png');
|
||||
} catch (canvasError) {
|
||||
console.error('Canvas处理失败:', canvasError);
|
||||
}
|
||||
};
|
||||
|
||||
img.onerror = (imgError) => {
|
||||
console.error('图像加载失败:', imgError);
|
||||
console.log('下载失败,未执行回退方案');
|
||||
};
|
||||
|
||||
img.src = canvasImage;
|
||||
} catch (canvasError) {
|
||||
console.error('Canvas方案也失败了:', canvasError);
|
||||
console.log('下载失败,未执行回退方案');
|
||||
}
|
||||
}
|
||||
})();
|
||||
URL.revokeObjectURL(url);
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('下载图像失败:', error);
|
||||
// 如果fetch失败,回退到直接使用a标签
|
||||
const link = document.createElement('a');
|
||||
link.href = canvasImage;
|
||||
link.download = `nano-banana-${Date.now()}.png`;
|
||||
link.target = '_blank';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -630,7 +580,7 @@ export const ImageCanvas: React.FC = () => {
|
||||
width={stageSize.width}
|
||||
height={stageSize.height}
|
||||
draggable={selectedTool !== 'mask'}
|
||||
onDragEnd={(e) => {
|
||||
onDragEnd={() => {
|
||||
// 通过stageRef直接获取和设置位置
|
||||
const stage = stageRef.current;
|
||||
if (stage) {
|
||||
|
||||
@@ -1,13 +1,119 @@
|
||||
import React, { useState, useRef } from 'react';
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { Textarea } from './ui/Textarea';
|
||||
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, urlToBlob } from '../utils/imageUtils';
|
||||
import { urlToBlob } from '../utils/imageUtils';
|
||||
import { PromptHints } from './PromptHints';
|
||||
import { PromptSuggestions } from './PromptSuggestions';
|
||||
import { cn } from '../utils/cn';
|
||||
import * as referenceImageService from '../services/referenceImageService';
|
||||
|
||||
// 图像预览组件
|
||||
const ImagePreview: React.FC<{
|
||||
image: string;
|
||||
index: number;
|
||||
selectedTool: 'generate' | 'edit' | 'mask';
|
||||
onRemove: () => void;
|
||||
onDragStart?: (e: React.DragEvent<HTMLDivElement>, index: number) => void;
|
||||
onDragOver?: (e: React.DragEvent<HTMLDivElement>, index: number) => void;
|
||||
onDragEnd?: (e: React.DragEvent<HTMLDivElement>) => void;
|
||||
onDrop?: (e: React.DragEvent<HTMLDivElement>, index: number) => void;
|
||||
isDragging?: boolean;
|
||||
}> = ({ image, index, onRemove, onDragStart, onDragOver, onDragEnd, onDrop, isDragging }) => {
|
||||
const [imageSrc, setImageSrc] = useState<string>(image);
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
const [error, setError] = useState<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
// 如果是IndexedDB图像,需要获取实际的Blob数据
|
||||
if (image.startsWith('indexeddb://')) {
|
||||
const imageId = image.replace('indexeddb://', '');
|
||||
referenceImageService.getReferenceImage(imageId)
|
||||
.then(blob => {
|
||||
if (blob) {
|
||||
const url = URL.createObjectURL(blob);
|
||||
setImageSrc(url);
|
||||
setLoading(false);
|
||||
} else {
|
||||
setError(true);
|
||||
setLoading(false);
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('获取参考图像失败:', err);
|
||||
setError(true);
|
||||
setLoading(false);
|
||||
});
|
||||
} else {
|
||||
// 对于其他类型的URL,直接使用
|
||||
setImageSrc(image);
|
||||
setLoading(false);
|
||||
}
|
||||
}, [image]);
|
||||
|
||||
// 清理创建的Blob URL
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (imageSrc.startsWith('blob:') && imageSrc !== image) {
|
||||
URL.revokeObjectURL(imageSrc);
|
||||
}
|
||||
};
|
||||
}, [imageSrc, image]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="relative w-full h-20 rounded-lg border-2 border-gray-200 bg-gray-100 flex items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-gray-500"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="relative w-full h-20 rounded-lg border-2 border-gray-200 bg-red-50 flex items-center justify-center">
|
||||
<span className="text-red-500 text-sm">图像加载失败</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("relative", isDragging ? "opacity-50" : "")}
|
||||
draggable
|
||||
onDragStart={(e) => onDragStart && onDragStart(e, index)}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
onDragOver && onDragOver(e, index);
|
||||
}}
|
||||
onDragEnd={(e) => onDragEnd && onDragEnd(e)}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
onDrop && onDrop(e, index);
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={imageSrc}
|
||||
alt={`参考图像 ${index + 1}`}
|
||||
className="w-full h-20 object-cover rounded-lg border-2 border-gray-200"
|
||||
onError={() => setError(true)}
|
||||
/>
|
||||
<button
|
||||
onClick={onRemove}
|
||||
className="absolute -top-2 -right-2 bg-red-500 text-white rounded-full p-1.5 transition-colors shadow-md hover:bg-red-600"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3">
|
||||
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||||
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||||
</svg>
|
||||
</button>
|
||||
<div className="absolute bottom-2 left-2 bg-black/60 text-white text-xs px-2 py-1 rounded-lg">
|
||||
参考 {index + 1}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const PromptComposer: React.FC = () => {
|
||||
const {
|
||||
@@ -23,6 +129,7 @@ export const PromptComposer: React.FC = () => {
|
||||
uploadedImages,
|
||||
addUploadedImage,
|
||||
removeUploadedImage,
|
||||
reorderUploadedImage,
|
||||
clearUploadedImages,
|
||||
editReferenceImages,
|
||||
addEditReferenceImage,
|
||||
@@ -32,8 +139,7 @@ export const PromptComposer: React.FC = () => {
|
||||
setCanvasImage,
|
||||
showPromptPanel,
|
||||
setShowPromptPanel,
|
||||
clearBrushStrokes,
|
||||
addBlob
|
||||
clearBrushStrokes
|
||||
} = useAppStore();
|
||||
|
||||
const { generate, cancelGeneration } = useImageGeneration();
|
||||
@@ -43,14 +149,30 @@ export const PromptComposer: React.FC = () => {
|
||||
const [showClearConfirm, setShowClearConfirm] = useState(false);
|
||||
const [showHintsModal, setShowHintsModal] = useState(false);
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
const [draggedIndex, setDraggedIndex] = useState<number | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// 初始化参考图像数据库
|
||||
useEffect(() => {
|
||||
const initDB = async () => {
|
||||
try {
|
||||
await referenceImageService.initReferenceImageDB();
|
||||
console.log('参考图像数据库初始化成功');
|
||||
} catch (error) {
|
||||
console.error('参考图像数据库初始化失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
initDB();
|
||||
}, []);
|
||||
|
||||
const handleGenerate = async () => {
|
||||
if (!currentPrompt.trim()) return;
|
||||
|
||||
if (selectedTool === 'generate') {
|
||||
// 将上传的图像转换为Blob对象
|
||||
const referenceImageBlobs: Blob[] = [];
|
||||
|
||||
for (const img of uploadedImages) {
|
||||
if (img.startsWith('data:')) {
|
||||
// 从base64数据创建Blob
|
||||
@@ -63,12 +185,42 @@ export const PromptComposer: React.FC = () => {
|
||||
ia[i] = byteString.charCodeAt(i);
|
||||
}
|
||||
referenceImageBlobs.push(new Blob([ab], { type: mimeString }));
|
||||
} else if (img.startsWith('indexeddb://')) {
|
||||
// 从IndexedDB获取参考图像
|
||||
const imageId = img.replace('indexeddb://', '');
|
||||
try {
|
||||
const blob = await referenceImageService.getReferenceImage(imageId);
|
||||
if (blob) {
|
||||
referenceImageBlobs.push(blob);
|
||||
} else {
|
||||
console.warn('无法从IndexedDB获取参考图像:', imageId);
|
||||
// 如果无法获取图像,尝试重新上传
|
||||
console.log('尝试重新处理参考图像...');
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('无法从IndexedDB获取参考图像:', imageId, error);
|
||||
// 如果无法获取图像,尝试重新上传
|
||||
console.log('尝试重新处理参考图像...');
|
||||
}
|
||||
} else if (img.startsWith('blob:')) {
|
||||
// 从Blob URL获取Blob
|
||||
const { getBlob } = useAppStore.getState();
|
||||
const blob = getBlob(img);
|
||||
if (blob) {
|
||||
referenceImageBlobs.push(blob);
|
||||
} else {
|
||||
// 如果在AppStore中找不到Blob,尝试重新创建
|
||||
try {
|
||||
const response = await fetch(img);
|
||||
if (response.ok) {
|
||||
const blob = await response.blob();
|
||||
referenceImageBlobs.push(blob);
|
||||
} else {
|
||||
console.warn('无法重新获取参考图像:', img);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('无法重新获取参考图像:', img, error);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 从URL获取Blob
|
||||
@@ -81,9 +233,13 @@ export const PromptComposer: React.FC = () => {
|
||||
}
|
||||
}
|
||||
|
||||
// 过滤掉无效的Blob,只保留有效的参考图像
|
||||
const validBlobs = referenceImageBlobs.filter(blob => blob.size > 0);
|
||||
|
||||
// 即使没有参考图像也继续生成,因为提示文本是必需的
|
||||
generate({
|
||||
prompt: currentPrompt,
|
||||
referenceImages: referenceImageBlobs.length > 0 ? referenceImageBlobs : undefined,
|
||||
referenceImages: validBlobs.length > 0 ? validBlobs : undefined,
|
||||
temperature,
|
||||
seed: seed !== null ? seed : undefined
|
||||
});
|
||||
@@ -95,28 +251,43 @@ export const PromptComposer: React.FC = () => {
|
||||
const handleFileUpload = async (file: File) => {
|
||||
if (file && file.type.startsWith('image/')) {
|
||||
try {
|
||||
// 直接使用Blob创建URL
|
||||
const blobUrl = addBlob(file);
|
||||
// 保存参考图像到IndexedDB
|
||||
const imageId = await referenceImageService.saveReferenceImage(file);
|
||||
|
||||
// 创建一个特殊的URL来标识这是存储在IndexedDB中的图像
|
||||
const imageUrl = `indexeddb://${imageId}`;
|
||||
|
||||
// 同时创建一个可以直接在画布上显示的Blob URL
|
||||
const blob = await referenceImageService.getReferenceImage(imageId);
|
||||
let displayUrl = imageUrl; // 默认使用IndexedDB URL
|
||||
if (blob) {
|
||||
displayUrl = URL.createObjectURL(blob);
|
||||
}
|
||||
|
||||
if (selectedTool === 'generate') {
|
||||
// 添加到参考图像(最多2张)
|
||||
if (uploadedImages.length < 2) {
|
||||
addUploadedImage(blobUrl);
|
||||
addUploadedImage(imageUrl);
|
||||
}
|
||||
} else if (selectedTool === 'edit') {
|
||||
// 编辑模式下,添加到单独的编辑参考图像(最多2张)
|
||||
if (editReferenceImages.length < 2) {
|
||||
addEditReferenceImage(blobUrl);
|
||||
addEditReferenceImage(imageUrl);
|
||||
}
|
||||
// 如果没有画布图像,则设置为画布图像
|
||||
// 如果没有画布图像,则设置为画布图像(使用可以直接显示的URL)
|
||||
if (!canvasImage) {
|
||||
setCanvasImage(blobUrl);
|
||||
setCanvasImage(displayUrl);
|
||||
}
|
||||
} else if (selectedTool === 'mask') {
|
||||
// 遮罩模式下,立即设置为画布图像
|
||||
clearUploadedImages();
|
||||
addUploadedImage(blobUrl);
|
||||
setCanvasImage(blobUrl);
|
||||
// 遮罩模式下,将图像添加为参考图像而不是清除现有图像
|
||||
// 只有在没有画布图像时才设置为画布图像(使用可以直接显示的URL)
|
||||
if (!canvasImage) {
|
||||
setCanvasImage(displayUrl);
|
||||
}
|
||||
// 不清除现有的上传图像,而是将新图像添加为参考图像(如果还有空间)
|
||||
if (uploadedImages.length < 2) {
|
||||
addUploadedImage(imageUrl);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('上传图像失败:', error);
|
||||
@@ -150,7 +321,32 @@ export const PromptComposer: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleClearSession = () => {
|
||||
// 拖拽排序处理函数
|
||||
const handleDragStart = (e: React.DragEvent<HTMLDivElement>, index: number) => {
|
||||
setDraggedIndex(index);
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
// 在Firefox中需要设置dataTransfer数据
|
||||
e.dataTransfer.setData('text/plain', index.toString());
|
||||
};
|
||||
|
||||
const handleDragOverPreview = (e: React.DragEvent<HTMLDivElement>, index: number) => {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
};
|
||||
|
||||
const handleDropPreview = (e: React.DragEvent<HTMLDivElement>, index: number) => {
|
||||
e.preventDefault();
|
||||
if (draggedIndex !== null && draggedIndex !== index) {
|
||||
reorderUploadedImage(draggedIndex, index);
|
||||
}
|
||||
setDraggedIndex(null);
|
||||
};
|
||||
|
||||
const handleDragEnd = () => {
|
||||
setDraggedIndex(null);
|
||||
};
|
||||
|
||||
const handleClearSession = async () => {
|
||||
setCurrentPrompt('');
|
||||
clearUploadedImages();
|
||||
clearEditReferenceImages();
|
||||
@@ -159,6 +355,17 @@ export const PromptComposer: React.FC = () => {
|
||||
setSeed(null);
|
||||
setTemperature(0.7);
|
||||
setShowClearConfirm(false);
|
||||
|
||||
// 清空IndexedDB中的所有参考图像
|
||||
try {
|
||||
await referenceImageService.clearAllReferenceImages();
|
||||
console.log('已清空IndexedDB中的所有参考图像');
|
||||
} catch (error) {
|
||||
console.error('清空IndexedDB中的参考图像失败:', error);
|
||||
}
|
||||
|
||||
// 清理所有Blob URL
|
||||
useAppStore.getState().cleanupAllBlobUrls();
|
||||
};
|
||||
|
||||
const tools = [
|
||||
@@ -303,25 +510,18 @@ export const PromptComposer: React.FC = () => {
|
||||
(selectedTool === 'edit' && editReferenceImages.length > 0)) && (
|
||||
<div className="space-y-2.5">
|
||||
{(selectedTool === 'generate' ? uploadedImages : editReferenceImages).map((image, index) => (
|
||||
<div key={index} className="relative">
|
||||
<img
|
||||
src={image}
|
||||
alt={`参考图像 ${index + 1}`}
|
||||
className="w-full h-20 object-cover rounded-lg border-2 border-gray-200"
|
||||
/>
|
||||
<button
|
||||
onClick={() => selectedTool === 'generate' ? removeUploadedImage(index) : removeEditReferenceImage(index)}
|
||||
className="absolute -top-2 -right-2 bg-red-500 text-white rounded-full p-1.5 transition-colors shadow-md hover:bg-red-600"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3">
|
||||
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||||
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||||
</svg>
|
||||
</button>
|
||||
<div className="absolute bottom-2 left-2 bg-black/60 text-white text-xs px-2 py-1 rounded-lg">
|
||||
参考 {index + 1}
|
||||
</div>
|
||||
</div>
|
||||
<ImagePreview
|
||||
key={index}
|
||||
image={image}
|
||||
index={index}
|
||||
selectedTool={selectedTool}
|
||||
onRemove={() => selectedTool === 'generate' ? removeUploadedImage(index) : removeEditReferenceImage(index)}
|
||||
onDragStart={selectedTool === 'generate' ? handleDragStart : undefined}
|
||||
onDragOver={selectedTool === 'generate' ? handleDragOverPreview : undefined}
|
||||
onDragEnd={selectedTool === 'generate' ? handleDragEnd : undefined}
|
||||
onDrop={selectedTool === 'generate' ? handleDropPreview : undefined}
|
||||
isDragging={selectedTool === 'generate' && draggedIndex === index}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
@@ -402,7 +602,7 @@ export const PromptComposer: React.FC = () => {
|
||||
<div className="mt-4 animate-in slide-down duration-300">
|
||||
<PromptSuggestions
|
||||
onWordSelect={(word) => {
|
||||
setCurrentPrompt(currentPrompt ? `${currentPrompt}, ${word}` : word);
|
||||
setCurrentPrompt(currentPrompt ? `${currentPrompt};${word}` : word);
|
||||
}}
|
||||
minFrequency={3}
|
||||
showTitle={false}
|
||||
@@ -472,7 +672,7 @@ export const PromptComposer: React.FC = () => {
|
||||
<div className="flex space-x-3">
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleClearSession}
|
||||
onClick={async () => await handleClearSession()}
|
||||
className="flex-1 h-10 text-sm font-semibold card text-gray-700"
|
||||
>
|
||||
确认
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { Button } from './ui/Button';
|
||||
import { useAppStore } from '../store/useAppStore';
|
||||
import { cn } from '../utils/cn';
|
||||
@@ -18,17 +18,17 @@ export const PromptSuggestions: React.FC<{
|
||||
const [showAll, setShowAll] = useState(false);
|
||||
|
||||
// 从提示词中提取词语并统计频次
|
||||
const extractWords = (text: string): string[] => {
|
||||
const extractWords = useCallback((text: string): string[] => {
|
||||
// 移除标点符号并分割词语
|
||||
return text
|
||||
.toLowerCase()
|
||||
.replace(/[^\w\s\u4e00-\u9fff]/g, ' ') // 保留中文字符
|
||||
.split(/\s+/)
|
||||
.filter(word => word.length > 1); // 过滤掉单字符
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 统计词语频次
|
||||
const calculateWordFrequency = (): WordFrequency[] => {
|
||||
const calculateWordFrequency = useCallback((): WordFrequency[] => {
|
||||
const wordCount: Record<string, number> = {};
|
||||
|
||||
// 收集所有提示词
|
||||
@@ -52,7 +52,7 @@ export const PromptSuggestions: React.FC<{
|
||||
});
|
||||
}
|
||||
|
||||
// 提取词语并统计频次
|
||||
// 统计词语频次
|
||||
allPrompts.forEach(prompt => {
|
||||
const words = extractWords(prompt);
|
||||
words.forEach(word => {
|
||||
@@ -65,11 +65,11 @@ export const PromptSuggestions: React.FC<{
|
||||
.map(([word, count]) => ({ word, count }))
|
||||
.filter(({ count }) => count >= minFrequency)
|
||||
.sort((a, b) => b.count - a.count);
|
||||
};
|
||||
}, [currentProject, minFrequency, extractWords]);
|
||||
|
||||
useEffect(() => {
|
||||
setFrequentWords(calculateWordFrequency());
|
||||
}, [currentProject, minFrequency]);
|
||||
}, [currentProject, minFrequency, calculateWordFrequency]);
|
||||
|
||||
// 显示的词语数量
|
||||
const displayWords = showAll ? frequentWords : frequentWords.slice(0, 20);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { createContext, useContext, useReducer, useState, useEffect, useRef } from 'react';
|
||||
import React, { createContext, useContext, useEffect, useReducer, useRef } from 'react';
|
||||
import { Toast } from './Toast';
|
||||
|
||||
type ToastType = 'success' | 'error' | 'warning' | 'info';
|
||||
|
||||
@@ -1,22 +1,38 @@
|
||||
import React from 'react';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import { cn } from '../../utils/cn';
|
||||
|
||||
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {}
|
||||
export interface InputProps
|
||||
extends React.InputHTMLAttributes<HTMLInputElement>,
|
||||
VariantProps<typeof inputVariants> {
|
||||
// Additional props can be added here if needed
|
||||
}
|
||||
|
||||
export const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
({ className, type, ...props }, ref) => {
|
||||
const inputVariants = cva(
|
||||
'flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'border-gray-300 focus-visible:ring-yellow-400',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
({ className, variant, type, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
'flex h-10 w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 ring-offset-white file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-gray-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-yellow-400 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className
|
||||
)}
|
||||
className={cn(inputVariants({ variant, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
Input.displayName = 'Input';
|
||||
|
||||
Input.displayName = 'Input';
|
||||
export { Input };
|
||||
@@ -1,7 +1,11 @@
|
||||
import React from 'react';
|
||||
import { cn } from '../../utils/cn';
|
||||
|
||||
export interface TextareaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
|
||||
export interface TextareaProps
|
||||
extends React.TextareaHTMLAttributes<HTMLTextAreaElement>,
|
||||
VariantProps<typeof textareaVariants> {
|
||||
// Additional props can be added here if needed
|
||||
}
|
||||
|
||||
export const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||
({ className, ...props }, ref) => {
|
||||
|
||||
@@ -14,12 +14,21 @@ export const useImageGeneration = () => {
|
||||
|
||||
// 创建中断标志引用
|
||||
const isCancelledRef = React.useRef(false)
|
||||
|
||||
// 创建AbortController引用
|
||||
const abortControllerRef = React.useRef<AbortController | null>(null)
|
||||
|
||||
// 保存参考图像Blob的引用,防止在错误后丢失
|
||||
const referenceImageBlobsRef = React.useRef<Blob[]>([])
|
||||
|
||||
const generateMutation = useMutation({
|
||||
mutationFn: async (request: GenerationRequest) => {
|
||||
// 重置中断标志
|
||||
isCancelledRef.current = false
|
||||
|
||||
// 创建新的AbortController
|
||||
abortControllerRef.current = new AbortController()
|
||||
|
||||
// 将参考图像从base64转换为Blob(如果需要)
|
||||
let blobReferenceImages: Blob[] | undefined;
|
||||
if (request.referenceImages) {
|
||||
@@ -34,17 +43,21 @@ export const useImageGeneration = () => {
|
||||
for (let i = 0; i < byteString.length; i++) {
|
||||
ia[i] = byteString.charCodeAt(i);
|
||||
}
|
||||
blobReferenceImages.push(new Blob([ab], { type: mimeString }));
|
||||
const blob = new Blob([ab], { type: mimeString });
|
||||
blobReferenceImages.push(blob);
|
||||
} else {
|
||||
// 如果已经是Blob,直接使用
|
||||
blobReferenceImages.push(img);
|
||||
}
|
||||
}
|
||||
// 保存参考图像Blob的引用
|
||||
referenceImageBlobsRef.current = blobReferenceImages;
|
||||
}
|
||||
|
||||
const blobRequest: GenerationRequest = {
|
||||
...request,
|
||||
referenceImages: blobReferenceImages
|
||||
referenceImages: blobReferenceImages,
|
||||
abortSignal: abortControllerRef.current.signal
|
||||
};
|
||||
|
||||
const result = await geminiService.generateImage(blobRequest)
|
||||
@@ -63,7 +76,7 @@ export const useImageGeneration = () => {
|
||||
const { images, usageMetadata } = result;
|
||||
if (images.length > 0) {
|
||||
// 直接使用Blob并创建URL,避免存储base64数据
|
||||
const outputAssets: Asset[] = await Promise.all(images.map(async (blob, index) => {
|
||||
const outputAssets: Asset[] = await Promise.all(images.map(async (blob) => {
|
||||
// 使用AppStore的addBlob方法存储Blob并获取URL
|
||||
const blobUrl = useAppStore.getState().addBlob(blob);
|
||||
|
||||
@@ -77,7 +90,7 @@ export const useImageGeneration = () => {
|
||||
checksum += uint8Array[i].toString(16).padStart(2, '0');
|
||||
}
|
||||
resolve(checksum || generateId().slice(0, 32));
|
||||
} catch (error) {
|
||||
} catch {
|
||||
resolve(generateId().slice(0, 32));
|
||||
}
|
||||
});
|
||||
@@ -95,7 +108,7 @@ export const useImageGeneration = () => {
|
||||
|
||||
// 获取accessToken
|
||||
const accessToken = import.meta.env.VITE_ACCESS_TOKEN || '';
|
||||
let uploadResults: any[] | undefined;
|
||||
let uploadResults: Array<{success: boolean, url?: string, error?: string, timestamp: number}> | undefined;
|
||||
|
||||
// 上传生成的图像和参考图像
|
||||
if (accessToken) {
|
||||
@@ -105,7 +118,7 @@ export const useImageGeneration = () => {
|
||||
const outputUploadResults = await uploadImages(imageUrls, accessToken, true);
|
||||
|
||||
// 上传参考图像(如果存在,使用缓存机制)
|
||||
let referenceUploadResults: any[] = [];
|
||||
let referenceUploadResults: Array<{success: boolean, url?: string, error?: string, timestamp: number}> = [];
|
||||
if (request.referenceImages && request.referenceImages.length > 0) {
|
||||
// 将参考图像转换为base64字符串格式上传(与老版本保持一致)
|
||||
const referenceBase64s = await Promise.all(request.referenceImages.map(async (blob) => {
|
||||
@@ -136,7 +149,7 @@ export const useImageGeneration = () => {
|
||||
console.log(`${uploadResults.length}张图像全部上传成功`);
|
||||
addToast('图像已成功上传', 'success', 3000);
|
||||
}
|
||||
} catch (error) {
|
||||
} catch {
|
||||
console.error('上传图像时出错:', error);
|
||||
addToast('图像上传失败', 'error', 5000);
|
||||
uploadResults = undefined;
|
||||
@@ -158,7 +171,7 @@ export const useImageGeneration = () => {
|
||||
seed: request.seed,
|
||||
temperature: request.temperature
|
||||
},
|
||||
sourceAssets: request.referenceImages ? await Promise.all(request.referenceImages.map(async (blob, index) => {
|
||||
sourceAssets: request.referenceImages ? await Promise.all(request.referenceImages.map(async (blob) => {
|
||||
// 将参考图像转换为Blob URL
|
||||
const blobUrl = useAppStore.getState().addBlob(blob);
|
||||
|
||||
@@ -172,7 +185,7 @@ export const useImageGeneration = () => {
|
||||
checksum += uint8Array[i].toString(16).padStart(2, '0');
|
||||
}
|
||||
resolve(checksum || generateId().slice(0, 32));
|
||||
} catch (error) {
|
||||
} catch {
|
||||
resolve(generateId().slice(0, 32));
|
||||
}
|
||||
});
|
||||
@@ -195,7 +208,15 @@ export const useImageGeneration = () => {
|
||||
};
|
||||
|
||||
addGeneration(generation);
|
||||
setCanvasImage(outputAssets[0].url);
|
||||
|
||||
// 调试日志:检查outputAssets
|
||||
console.log('生成完成,outputAssets:', outputAssets);
|
||||
if (outputAssets && outputAssets.length > 0) {
|
||||
console.log('第一个输出资产URL:', outputAssets[0].url);
|
||||
setCanvasImage(outputAssets[0].url);
|
||||
} else {
|
||||
console.error('生成完成但没有输出资产');
|
||||
}
|
||||
|
||||
// 自动选择新生成的记录
|
||||
const { selectGeneration } = useAppStore.getState();
|
||||
@@ -209,11 +230,21 @@ export const useImageGeneration = () => {
|
||||
const errorDetails = error instanceof Error ? error.stack : undefined
|
||||
addToast(`图像生成失败: ${errorMessage}`, 'error', 5000, errorDetails)
|
||||
setIsGenerating(false)
|
||||
// 保持参考图像不变,以便用户可以重新尝试生成
|
||||
console.log('生成失败,但参考图像已保留,用户可以重新尝试生成');
|
||||
// 如果有参考图像数据,确保它们不会被清除
|
||||
if (referenceImageBlobsRef.current.length > 0) {
|
||||
console.log(`保留了 ${referenceImageBlobsRef.current.length} 个参考图像,用户可以重新尝试生成`);
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const cancelGeneration = () => {
|
||||
isCancelledRef.current = true
|
||||
// 取消网络请求
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort()
|
||||
}
|
||||
setIsGenerating(false)
|
||||
addToast('生成已中断', 'info', 3000)
|
||||
}
|
||||
@@ -233,12 +264,18 @@ export const useImageEditing = () => {
|
||||
|
||||
// 创建中断标志引用
|
||||
const isCancelledRef = React.useRef(false)
|
||||
|
||||
// 创建AbortController引用
|
||||
const abortControllerRef = React.useRef<AbortController | null>(null)
|
||||
|
||||
const editMutation = useMutation({
|
||||
mutationFn: async (instruction: string) => {
|
||||
// 重置中断标志
|
||||
isCancelledRef.current = false
|
||||
|
||||
// 创建新的AbortController
|
||||
abortControllerRef.current = new AbortController()
|
||||
|
||||
// 如果可用,始终使用画布图像作为主要目标,否则使用第一张上传的图像
|
||||
const sourceImage = canvasImage || uploadedImages[0]
|
||||
if (!sourceImage) throw new Error('没有要编辑的图像')
|
||||
@@ -269,12 +306,34 @@ export const useImageEditing = () => {
|
||||
|
||||
// 获取用于样式指导的参考图像
|
||||
let referenceImageBlobs: Blob[] = [];
|
||||
for (const img of editReferenceImages) {
|
||||
const updatedReferenceImageUrls: string[] = [...editReferenceImages]; // 保存更新后的URL
|
||||
|
||||
for (let i = 0; i < editReferenceImages.length; i++) {
|
||||
const img = editReferenceImages[i];
|
||||
if (img.startsWith('blob:')) {
|
||||
// 从Blob URL获取Blob数据
|
||||
const blob = useAppStore.getState().getBlob(img);
|
||||
if (blob) {
|
||||
referenceImageBlobs.push(blob);
|
||||
} else {
|
||||
// 如果在AppStore中找不到Blob,尝试重新获取
|
||||
try {
|
||||
const response = await fetch(img);
|
||||
if (response.ok) {
|
||||
const blob = await response.blob();
|
||||
referenceImageBlobs.push(blob);
|
||||
// 重新添加到AppStore
|
||||
const newUrl = useAppStore.getState().addBlob(blob);
|
||||
// 更新editReferenceImages中的URL(但不立即修改状态)
|
||||
updatedReferenceImageUrls[i] = newUrl;
|
||||
} else {
|
||||
// 即使无法重新获取,也要保留原始URL并在下次尝试时重新获取
|
||||
console.warn('无法重新获取参考图像,将在下次尝试时重新获取:', img);
|
||||
}
|
||||
} catch {
|
||||
// 即使出现错误,也要保留原始URL并在下次尝试时重新获取
|
||||
console.warn('无法重新获取参考图像,将在下次尝试时重新获取:', img, error);
|
||||
}
|
||||
}
|
||||
} else if (img.includes('base64,')) {
|
||||
// 从base64数据创建Blob
|
||||
@@ -283,8 +342,8 @@ export const useImageEditing = () => {
|
||||
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);
|
||||
for (let j = 0; j < byteString.length; j++) {
|
||||
ia[j] = byteString.charCodeAt(j);
|
||||
}
|
||||
referenceImageBlobs.push(new Blob([ab], { type: mimeString }));
|
||||
} else {
|
||||
@@ -293,11 +352,28 @@ export const useImageEditing = () => {
|
||||
const response = await fetch(img);
|
||||
const blob = await response.blob();
|
||||
referenceImageBlobs.push(blob);
|
||||
} catch (error) {
|
||||
} catch {
|
||||
console.warn('无法获取参考图像:', img, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 过滤掉无效的Blob,只保留有效的参考图像
|
||||
const validBlobs = referenceImageBlobs.filter(blob => blob.size > 0);
|
||||
|
||||
// 更新editReferenceImages状态(如果需要)
|
||||
if (updatedReferenceImageUrls.some((url, index) => url !== editReferenceImages[index])) {
|
||||
const { clearEditReferenceImages, addEditReferenceImage } = useAppStore.getState();
|
||||
clearEditReferenceImages();
|
||||
updatedReferenceImageUrls.forEach(imageUrl => {
|
||||
if (imageUrl) {
|
||||
addEditReferenceImage(imageUrl);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 使用有效的参考图像Blob
|
||||
referenceImageBlobs = validBlobs;
|
||||
|
||||
let maskImageBlob: Blob | undefined;
|
||||
let maskedReferenceImage: string | undefined;
|
||||
@@ -398,6 +474,7 @@ export const useImageEditing = () => {
|
||||
maskImage: maskImageBlob,
|
||||
temperature,
|
||||
seed,
|
||||
abortSignal: abortControllerRef.current.signal
|
||||
}
|
||||
|
||||
const result = await geminiService.editImage(request)
|
||||
@@ -416,7 +493,7 @@ export const useImageEditing = () => {
|
||||
const { images, usageMetadata } = result;
|
||||
if (images.length > 0) {
|
||||
// 直接使用Blob并创建URL,避免存储base64数据
|
||||
const outputAssets: Asset[] = await Promise.all(images.map(async (blob, index) => {
|
||||
const outputAssets: Asset[] = await Promise.all(images.map(async (blob) => {
|
||||
// 使用AppStore的addBlob方法存储Blob并获取URL
|
||||
const blobUrl = useAppStore.getState().addBlob(blob);
|
||||
|
||||
@@ -430,7 +507,7 @@ export const useImageEditing = () => {
|
||||
checksum += uint8Array[i].toString(16).padStart(2, '0');
|
||||
}
|
||||
resolve(checksum || generateId().slice(0, 32));
|
||||
} catch (error) {
|
||||
} catch {
|
||||
resolve(generateId().slice(0, 32));
|
||||
}
|
||||
});
|
||||
@@ -471,7 +548,7 @@ export const useImageEditing = () => {
|
||||
checksum += uint8Array[i].toString(16).padStart(2, '0');
|
||||
}
|
||||
resolve(checksum || generateId().slice(0, 32));
|
||||
} catch (error) {
|
||||
} catch {
|
||||
resolve(generateId().slice(0, 32));
|
||||
}
|
||||
});
|
||||
@@ -489,7 +566,7 @@ export const useImageEditing = () => {
|
||||
|
||||
// 获取accessToken
|
||||
const accessToken = import.meta.env.VITE_ACCESS_TOKEN || '';
|
||||
let uploadResults: any[] | undefined;
|
||||
let uploadResults: Array<{success: boolean, url?: string, error?: string, timestamp: number}> | undefined;
|
||||
|
||||
// 上传编辑后的图像
|
||||
if (accessToken) {
|
||||
@@ -499,7 +576,7 @@ export const useImageEditing = () => {
|
||||
const outputUploadResults = await uploadImages(imageUrls, accessToken, true);
|
||||
|
||||
// 上传参考图像(如果存在,使用缓存机制)
|
||||
let referenceUploadResults: any[] = [];
|
||||
let referenceUploadResults: Array<{success: boolean, url?: string, error?: string, timestamp: number}> = [];
|
||||
if (referenceImageBlobs.length > 0) {
|
||||
// 将参考图像转换为base64字符串格式上传(与老版本保持一致)
|
||||
const referenceBase64s = await Promise.all(referenceImageBlobs.map(async (blob) => {
|
||||
@@ -524,7 +601,7 @@ export const useImageEditing = () => {
|
||||
console.log(`${uploadResults.length}张编辑后的图像全部上传成功`);
|
||||
addToast('编辑后的图像已成功上传', 'success', 3000);
|
||||
}
|
||||
} catch (error) {
|
||||
} catch {
|
||||
console.error('上传编辑后的图像时出错:', error);
|
||||
addToast('编辑后的图像上传失败', 'error', 5000);
|
||||
uploadResults = undefined;
|
||||
@@ -538,12 +615,44 @@ export const useImageEditing = () => {
|
||||
addToast(`本次编辑消耗 ${usageMetadata.totalTokenCount} Tokens`, 'info', 3000);
|
||||
}
|
||||
|
||||
// 将参考图像Blob转换为Asset对象
|
||||
const sourceAssets: Asset[] = await Promise.all(referenceImageBlobs.map(async (blob) => {
|
||||
// 使用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 {
|
||||
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
|
||||
};
|
||||
}));
|
||||
|
||||
const edit: Edit = {
|
||||
id: generateId(),
|
||||
parentGenerationId: selectedGenerationId || '',
|
||||
maskAssetId: brushStrokes.length > 0 ? generateId() : undefined,
|
||||
maskReferenceAsset,
|
||||
instruction,
|
||||
sourceAssets, // 添加参考图像信息
|
||||
outputAssets,
|
||||
timestamp: Date.now(),
|
||||
uploadResults: uploadResults,
|
||||
@@ -570,11 +679,17 @@ export const useImageEditing = () => {
|
||||
const errorDetails = error instanceof Error ? error.stack : undefined
|
||||
addToast(`图像编辑失败: ${errorMessage}`, 'error', 5000, errorDetails)
|
||||
setIsGenerating(false)
|
||||
// 保持参考图像不变,以便用户可以重新尝试编辑
|
||||
console.log('编辑失败,但参考图像已保留,用户可以重新尝试编辑');
|
||||
},
|
||||
})
|
||||
|
||||
const cancelEdit = () => {
|
||||
isCancelledRef.current = true
|
||||
// 取消网络请求
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort()
|
||||
}
|
||||
setIsGenerating(false)
|
||||
addToast('编辑已中断', 'info', 3000)
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import * as indexedDBService from '../services/indexedDBService';
|
||||
import { Generation, Edit } from '../types';
|
||||
|
||||
export const useIndexedDBListener = () => {
|
||||
const [generations, setGenerations] = useState<any[]>([]);
|
||||
const [edits, setEdits] = useState<any[]>([]);
|
||||
const [generations, setGenerations] = useState<Generation[]>([]);
|
||||
const [edits, setEdits] = useState<Edit[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const isMountedRef = useRef(true);
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useAppStore } from '../store/useAppStore';
|
||||
import { useImageGeneration, useImageEditing } from '../hooks/useImageGeneration';
|
||||
import * as referenceImageService from '../services/referenceImageService';
|
||||
import { urlToBlob } from '../utils/imageUtils';
|
||||
|
||||
export const useKeyboardShortcuts = () => {
|
||||
const {
|
||||
@@ -20,11 +22,24 @@ export const useKeyboardShortcuts = () => {
|
||||
uploadedImages: generateUploadedImages
|
||||
} = useAppStore();
|
||||
|
||||
const { generate } = useImageGeneration();
|
||||
const { edit } = useImageEditing();
|
||||
const { generate, cancelGeneration } = useImageGeneration();
|
||||
const { edit, cancelEdit } = useImageEditing();
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
// Handle Escape key to cancel generation/editing
|
||||
if (event.key === 'Escape') {
|
||||
if (isGenerating) {
|
||||
event.preventDefault();
|
||||
if (selectedTool === 'generate') {
|
||||
cancelGeneration();
|
||||
} else {
|
||||
cancelEdit();
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Ignore if user is typing in an input
|
||||
if (event.target instanceof HTMLInputElement ||
|
||||
event.target instanceof HTMLTextAreaElement) {
|
||||
@@ -34,16 +49,63 @@ export const useKeyboardShortcuts = () => {
|
||||
if (!isGenerating && currentPrompt.trim()) {
|
||||
// 触发生成操作
|
||||
if (selectedTool === 'generate') {
|
||||
const referenceImages = generateUploadedImages
|
||||
.filter(img => img.includes('base64,'))
|
||||
.map(img => img.split('base64,')[1]);
|
||||
// 使用与PromptComposer中相同的逻辑处理参考图像
|
||||
const processReferenceImages = async () => {
|
||||
const referenceImageBlobs: Blob[] = [];
|
||||
|
||||
for (const img of generateUploadedImages) {
|
||||
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('indexeddb://')) {
|
||||
// 从IndexedDB获取参考图像
|
||||
const imageId = img.replace('indexeddb://', '');
|
||||
try {
|
||||
const blob = await referenceImageService.getReferenceImage(imageId);
|
||||
if (blob) {
|
||||
referenceImageBlobs.push(blob);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('无法从IndexedDB获取参考图像:', imageId, error);
|
||||
}
|
||||
} 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 过滤掉无效的Blob,只保留有效的参考图像
|
||||
const validBlobs = referenceImageBlobs.filter(blob => blob.size > 0);
|
||||
|
||||
generate({
|
||||
prompt: currentPrompt,
|
||||
referenceImages: validBlobs.length > 0 ? validBlobs : undefined,
|
||||
temperature,
|
||||
seed: seed !== null ? seed : undefined
|
||||
});
|
||||
};
|
||||
|
||||
generate({
|
||||
prompt: currentPrompt,
|
||||
referenceImages: referenceImages.length > 0 ? referenceImages : undefined,
|
||||
temperature,
|
||||
seed: seed !== null ? seed : undefined
|
||||
});
|
||||
processReferenceImages();
|
||||
} else if (selectedTool === 'edit' || selectedTool === 'mask') {
|
||||
edit(currentPrompt);
|
||||
}
|
||||
@@ -84,16 +146,63 @@ export const useKeyboardShortcuts = () => {
|
||||
if (currentPrompt.trim() && !isGenerating) {
|
||||
event.preventDefault();
|
||||
if (selectedTool === 'generate') {
|
||||
const referenceImages = generateUploadedImages
|
||||
.filter(img => img.includes('base64,'))
|
||||
.map(img => img.split('base64,')[1]);
|
||||
// 使用与PromptComposer中相同的逻辑处理参考图像
|
||||
const processReferenceImages = async () => {
|
||||
const referenceImageBlobs: Blob[] = [];
|
||||
|
||||
for (const img of generateUploadedImages) {
|
||||
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('indexeddb://')) {
|
||||
// 从IndexedDB获取参考图像
|
||||
const imageId = img.replace('indexeddb://', '');
|
||||
try {
|
||||
const blob = await referenceImageService.getReferenceImage(imageId);
|
||||
if (blob) {
|
||||
referenceImageBlobs.push(blob);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('无法从IndexedDB获取参考图像:', imageId, error);
|
||||
}
|
||||
} 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 过滤掉无效的Blob,只保留有效的参考图像
|
||||
const validBlobs = referenceImageBlobs.filter(blob => blob.size > 0);
|
||||
|
||||
generate({
|
||||
prompt: currentPrompt,
|
||||
referenceImages: validBlobs.length > 0 ? validBlobs : undefined,
|
||||
temperature,
|
||||
seed: seed !== null ? seed : undefined
|
||||
});
|
||||
};
|
||||
|
||||
generate({
|
||||
prompt: currentPrompt,
|
||||
referenceImages: referenceImages.length > 0 ? referenceImages : undefined,
|
||||
temperature,
|
||||
seed: seed !== null ? seed : undefined
|
||||
});
|
||||
processReferenceImages();
|
||||
} else if (selectedTool === 'edit' || selectedTool === 'mask') {
|
||||
edit(currentPrompt);
|
||||
}
|
||||
@@ -120,6 +229,8 @@ export const useKeyboardShortcuts = () => {
|
||||
temperature,
|
||||
seed,
|
||||
generate,
|
||||
edit
|
||||
edit,
|
||||
cancelGeneration,
|
||||
cancelEdit
|
||||
]);
|
||||
};
|
||||
@@ -4,9 +4,9 @@ import { Project, Generation, Asset } from '../types';
|
||||
const CACHE_PREFIX = 'nano-banana';
|
||||
const CACHE_VERSION = '1.0';
|
||||
// 限制缓存项目数量
|
||||
const MAX_CACHED_ITEMS = 50;
|
||||
const MAX_CACHED_ITEMS = 1000;
|
||||
// 限制缓存最大年龄 (3天)
|
||||
const MAX_CACHE_AGE = 3 * 24 * 60 * 60 * 1000;
|
||||
const MAX_CACHE_AGE = 30 * 24 * 60 * 60 * 1000;
|
||||
|
||||
export class CacheService {
|
||||
private static getKey(type: string, id: string): string {
|
||||
|
||||
@@ -9,6 +9,8 @@ export interface GenerationRequest {
|
||||
referenceImages?: Blob[] // Blob数组
|
||||
temperature?: number
|
||||
seed?: number
|
||||
// 添加abortSignal参数
|
||||
abortSignal?: AbortSignal
|
||||
}
|
||||
|
||||
export interface EditRequest {
|
||||
@@ -18,6 +20,8 @@ export interface EditRequest {
|
||||
maskImage?: Blob // Blob
|
||||
temperature?: number
|
||||
seed?: number
|
||||
// 添加abortSignal参数
|
||||
abortSignal?: AbortSignal
|
||||
}
|
||||
|
||||
export interface UsageMetadata {
|
||||
@@ -29,48 +33,130 @@ export interface UsageMetadata {
|
||||
export interface SegmentationRequest {
|
||||
image: Blob // Blob
|
||||
query: string // "像素(x,y)处的对象" 或 "红色汽车"
|
||||
// 添加abortSignal参数
|
||||
abortSignal?: AbortSignal
|
||||
}
|
||||
|
||||
export class GeminiService {
|
||||
// 缓存base64图像数据,确保它们不会被清除
|
||||
private base64ImagesCache: Map<string, string> = new Map()
|
||||
|
||||
// 将Blob转换为base64的辅助函数
|
||||
private async blobToBase64(blob: Blob): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
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);
|
||||
});
|
||||
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 }> {
|
||||
// 生成Blob的唯一标识符
|
||||
private async generateBlobId(blob: Blob): Promise<string> {
|
||||
// 使用Blob的部分内容生成唯一标识符
|
||||
const arrayBuffer = await blob.slice(0, 1024).arrayBuffer()
|
||||
const uint8Array = new Uint8Array(arrayBuffer)
|
||||
let hash = ''
|
||||
for (let i = 0; i < uint8Array.length; i++) {
|
||||
hash += uint8Array[i].toString(16).padStart(2, '0')
|
||||
}
|
||||
return `${blob.type}-${blob.size}-${hash.substring(0, 32)}`
|
||||
}
|
||||
|
||||
// 清理过期的缓存项(可选)
|
||||
private cleanupExpiredCache(): void {
|
||||
// 在这个实现中,我们不自动清理缓存
|
||||
// 只有在显式调用clearBase64Cache时才清理
|
||||
console.log('缓存大小:', this.base64ImagesCache.size)
|
||||
}
|
||||
|
||||
async generateImage(request: GenerationRequest): Promise<{ images: Blob[]; usageMetadata?: UsageMetadata }> {
|
||||
try {
|
||||
const contents: any[] = [{ text: request.prompt }]
|
||||
const contents: Array<{ text: string } | { inlineData: { mimeType: string; data: string } }> = [{ text: request.prompt }]
|
||||
|
||||
// 如果提供了参考图像则添加
|
||||
if (request.referenceImages && request.referenceImages.length > 0) {
|
||||
// 将Blob转换为base64以发送到API
|
||||
const base64Images = await Promise.all(
|
||||
request.referenceImages.map(blob => this.blobToBase64(blob))
|
||||
);
|
||||
|
||||
const base64Images: string[] = []
|
||||
|
||||
// 为每个参考图像生成或获取base64数据
|
||||
for (const blob of request.referenceImages) {
|
||||
// 生成Blob的唯一标识符
|
||||
const blobId = await this.generateBlobId(blob)
|
||||
|
||||
let base64: string
|
||||
// 检查缓存中是否已有该图像的base64数据
|
||||
if (this.base64ImagesCache.has(blobId)) {
|
||||
// 从缓存中获取base64数据
|
||||
base64 = this.base64ImagesCache.get(blobId)!
|
||||
console.log('从缓存中获取参考图像base64数据')
|
||||
} else {
|
||||
// 转换Blob为base64并缓存结果
|
||||
base64 = await this.blobToBase64(blob)
|
||||
// 将base64数据存储到缓存中
|
||||
this.base64ImagesCache.set(blobId, base64)
|
||||
console.log('生成并缓存参考图像base64数据')
|
||||
}
|
||||
|
||||
// 如果base64数据为空,重新生成
|
||||
if (!base64 || base64.length === 0) {
|
||||
console.warn('参考图像base64数据为空,重新生成')
|
||||
base64 = await this.blobToBase64(blob)
|
||||
// 更新缓存
|
||||
this.base64ImagesCache.set(blobId, base64)
|
||||
}
|
||||
|
||||
base64Images.push(base64)
|
||||
}
|
||||
|
||||
base64Images.forEach(image => {
|
||||
contents.push({
|
||||
inlineData: {
|
||||
mimeType: 'image/png',
|
||||
data: image,
|
||||
},
|
||||
})
|
||||
// 确保图像数据不为空
|
||||
if (image && image.length > 0) {
|
||||
contents.push({
|
||||
inlineData: {
|
||||
mimeType: 'image/png',
|
||||
data: image,
|
||||
},
|
||||
})
|
||||
} else {
|
||||
console.warn('跳过空的参考图像数据')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const response = await genAI.models.generateContent({
|
||||
// 检查contents是否包含有效的图像数据或文本提示
|
||||
const hasImageData = contents.some(item => item.inlineData && item.inlineData.data && item.inlineData.data.length > 0)
|
||||
const hasTextPrompt = contents.some(item => item.text && item.text.length > 0)
|
||||
|
||||
// 如果既没有图像数据也没有文本提示,抛出错误
|
||||
if (!hasImageData && !hasTextPrompt) {
|
||||
throw new Error('没有有效的图像数据或文本提示用于生成')
|
||||
}
|
||||
|
||||
// 准备请求配置,包括abortSignal
|
||||
const generateContentParams: {
|
||||
model: string;
|
||||
contents: Array<{ text: string } | { inlineData: { mimeType: string; data: string } }>;
|
||||
config?: { httpOptions: { abortSignal: AbortSignal } };
|
||||
} = {
|
||||
model: 'gemini-2.5-flash-image-preview',
|
||||
contents,
|
||||
})
|
||||
}
|
||||
|
||||
// 如果提供了abortSignal,则添加到请求配置中
|
||||
if (request.abortSignal) {
|
||||
generateContentParams.config = {
|
||||
httpOptions: {
|
||||
abortSignal: request.abortSignal
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const response = await genAI.models.generateContent(generateContentParams)
|
||||
|
||||
// 检查是否有被禁止的内容
|
||||
if (response.candidates && response.candidates.length > 0) {
|
||||
@@ -79,24 +165,24 @@ export class GeminiService {
|
||||
throw new Error('内容被禁止:您的请求包含不允许的内容。请尝试其他提示。')
|
||||
}
|
||||
if (candidate.finishReason === 'IMAGE_SAFETY') {
|
||||
throw new Error('内容被禁止:您的请求包含不允许的内容。请尝试其他提示。')
|
||||
throw new Error('图像安全检查失败:请求的图像内容可能不安全。请尝试调整提示词。')
|
||||
}
|
||||
// 检查finishReason为STOP但没有inlineData的情况
|
||||
if (candidate.finishReason === 'STOP') {
|
||||
// 检查是否有inlineData
|
||||
let hasInlineData = false;
|
||||
let hasInlineData = false
|
||||
if (candidate.content && candidate.content.parts) {
|
||||
for (const part of candidate.content.parts) {
|
||||
if (part.inlineData) {
|
||||
hasInlineData = true;
|
||||
break;
|
||||
hasInlineData = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 如果没有inlineData,则抛出错误
|
||||
if (!hasInlineData && candidate.content && candidate.content.parts && candidate.content.parts.length > 0) {
|
||||
throw new Error(candidate.content.parts[0].text || '生成失败:未返回图像数据');
|
||||
throw new Error(candidate.content.parts[0].text || '生成失败:未返回图像数据')
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -108,15 +194,28 @@ export class GeminiService {
|
||||
for (const part of response.candidates[0].content.parts) {
|
||||
if (part.inlineData) {
|
||||
// 将返回的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);
|
||||
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);
|
||||
ia[i] = byteString.charCodeAt(i)
|
||||
}
|
||||
const blob = new Blob([ab], { type: mimeString });
|
||||
images.push(blob);
|
||||
const blob = new Blob([ab], { type: mimeString })
|
||||
images.push(blob)
|
||||
}
|
||||
}
|
||||
|
||||
// 如果没有图像数据但有文本响应,抛出包含文本的错误
|
||||
if (images.length === 0) {
|
||||
let textResponse = ''
|
||||
for (const part of response.candidates[0].content.parts) {
|
||||
if (part.text) {
|
||||
textResponse += part.text
|
||||
}
|
||||
}
|
||||
if (textResponse) {
|
||||
throw new Error(`生成失败:${textResponse}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -127,6 +226,10 @@ export class GeminiService {
|
||||
return { images, usageMetadata }
|
||||
} catch (error) {
|
||||
console.error('生成图像时出错:', error)
|
||||
// 检查是否是由于abortSignal导致的取消
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
throw new Error('生成已取消')
|
||||
}
|
||||
if (error instanceof Error && error.message) {
|
||||
throw error
|
||||
}
|
||||
@@ -134,11 +237,35 @@ export class GeminiService {
|
||||
}
|
||||
}
|
||||
|
||||
async editImage(request: EditRequest): Promise<{ images: Blob[]; usageMetadata?: any }> {
|
||||
async editImage(request: EditRequest): Promise<{ images: Blob[]; usageMetadata?: UsageMetadata }> {
|
||||
try {
|
||||
// 将Blob转换为base64以发送到API
|
||||
const originalImageBase64 = await this.blobToBase64(request.originalImage);
|
||||
|
||||
// 将原始图像Blob转换为base64以发送到API
|
||||
let originalImageBase64: string
|
||||
|
||||
// 生成原始图像Blob的唯一标识符
|
||||
const originalBlobId = await this.generateBlobId(request.originalImage)
|
||||
|
||||
// 检查缓存中是否已有该图像的base64数据
|
||||
if (this.base64ImagesCache.has(originalBlobId)) {
|
||||
// 从缓存中获取base64数据
|
||||
originalImageBase64 = this.base64ImagesCache.get(originalBlobId)!
|
||||
console.log('从缓存中获取原始图像base64数据')
|
||||
} else {
|
||||
// 转换Blob为base64并缓存结果
|
||||
originalImageBase64 = await this.blobToBase64(request.originalImage)
|
||||
// 将base64数据存储到缓存中
|
||||
this.base64ImagesCache.set(originalBlobId, originalImageBase64)
|
||||
console.log('生成并缓存原始图像base64数据')
|
||||
}
|
||||
|
||||
// 如果base64数据为空,重新生成
|
||||
if (!originalImageBase64 || originalImageBase64.length === 0) {
|
||||
console.warn('原始图像base64数据为空,重新生成')
|
||||
originalImageBase64 = await this.blobToBase64(request.originalImage)
|
||||
// 更新缓存
|
||||
this.base64ImagesCache.set(originalBlobId, originalImageBase64)
|
||||
}
|
||||
|
||||
const contents = [
|
||||
{ text: this.buildEditPrompt(request) },
|
||||
{
|
||||
@@ -152,35 +279,123 @@ export class GeminiService {
|
||||
// 如果提供了参考图像则添加
|
||||
if (request.referenceImages && request.referenceImages.length > 0) {
|
||||
// 将Blob转换为base64以发送到API
|
||||
const base64ReferenceImages = await Promise.all(
|
||||
request.referenceImages.map(blob => this.blobToBase64(blob))
|
||||
);
|
||||
|
||||
const base64ReferenceImages: string[] = []
|
||||
|
||||
// 为每个参考图像生成或获取base64数据
|
||||
for (const blob of request.referenceImages) {
|
||||
// 生成Blob的唯一标识符
|
||||
const blobId = await this.generateBlobId(blob)
|
||||
|
||||
let base64: string
|
||||
// 检查缓存中是否已有该图像的base64数据
|
||||
if (this.base64ImagesCache.has(blobId)) {
|
||||
// 从缓存中获取base64数据
|
||||
base64 = this.base64ImagesCache.get(blobId)!
|
||||
console.log('从缓存中获取参考图像base64数据')
|
||||
} else {
|
||||
// 转换Blob为base64并缓存结果
|
||||
base64 = await this.blobToBase64(blob)
|
||||
// 将base64数据存储到缓存中
|
||||
this.base64ImagesCache.set(blobId, base64)
|
||||
console.log('生成并缓存参考图像base64数据')
|
||||
}
|
||||
|
||||
// 如果base64数据为空,重新生成
|
||||
if (!base64 || base64.length === 0) {
|
||||
console.warn('参考图像base64数据为空,重新生成')
|
||||
base64 = await this.blobToBase64(blob)
|
||||
// 更新缓存
|
||||
this.base64ImagesCache.set(blobId, base64)
|
||||
}
|
||||
|
||||
base64ReferenceImages.push(base64)
|
||||
}
|
||||
|
||||
base64ReferenceImages.forEach(image => {
|
||||
contents.push({
|
||||
inlineData: {
|
||||
mimeType: 'image/png',
|
||||
data: image,
|
||||
},
|
||||
})
|
||||
// 确保图像数据不为空
|
||||
if (image && image.length > 0) {
|
||||
contents.push({
|
||||
inlineData: {
|
||||
mimeType: 'image/png',
|
||||
data: image,
|
||||
},
|
||||
})
|
||||
} else {
|
||||
console.warn('跳过空的参考图像数据')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if (request.maskImage) {
|
||||
// 将Blob转换为base64以发送到API
|
||||
const maskImageBase64 = await this.blobToBase64(request.maskImage);
|
||||
contents.push({
|
||||
inlineData: {
|
||||
mimeType: 'image/png',
|
||||
data: maskImageBase64,
|
||||
},
|
||||
})
|
||||
// 将遮罩图像Blob转换为base64以发送到API
|
||||
let maskImageBase64: string
|
||||
|
||||
// 生成遮罩图像Blob的唯一标识符
|
||||
const maskBlobId = await this.generateBlobId(request.maskImage)
|
||||
|
||||
// 检查缓存中是否已有该图像的base64数据
|
||||
if (this.base64ImagesCache.has(maskBlobId)) {
|
||||
// 从缓存中获取base64数据
|
||||
maskImageBase64 = this.base64ImagesCache.get(maskBlobId)!
|
||||
console.log('从缓存中获取遮罩图像base64数据')
|
||||
} else {
|
||||
// 转换Blob为base64并缓存结果
|
||||
maskImageBase64 = await this.blobToBase64(request.maskImage)
|
||||
// 将base64数据存储到缓存中
|
||||
this.base64ImagesCache.set(maskBlobId, maskImageBase64)
|
||||
console.log('生成并缓存遮罩图像base64数据')
|
||||
}
|
||||
|
||||
// 如果base64数据为空,重新生成
|
||||
if (!maskImageBase64 || maskImageBase64.length === 0) {
|
||||
console.warn('遮罩图像base64数据为空,重新生成')
|
||||
maskImageBase64 = await this.blobToBase64(request.maskImage)
|
||||
// 更新缓存
|
||||
this.base64ImagesCache.set(maskBlobId, maskImageBase64)
|
||||
}
|
||||
|
||||
// 确保遮罩图像数据不为空
|
||||
if (maskImageBase64 && maskImageBase64.length > 0) {
|
||||
contents.push({
|
||||
inlineData: {
|
||||
mimeType: 'image/png',
|
||||
data: maskImageBase64,
|
||||
},
|
||||
})
|
||||
} else {
|
||||
console.warn('跳过空的遮罩图像数据')
|
||||
}
|
||||
}
|
||||
|
||||
const response = await genAI.models.generateContent({
|
||||
// 检查contents是否包含有效的图像数据或文本提示
|
||||
const hasImageData = contents.some(item => item.inlineData && item.inlineData.data && item.inlineData.data.length > 0)
|
||||
const hasTextPrompt = contents.some(item => item.text && item.text.length > 0)
|
||||
|
||||
// 如果既没有图像数据也没有文本提示,抛出错误
|
||||
if (!hasImageData && !hasTextPrompt) {
|
||||
throw new Error('没有有效的图像数据或文本提示用于编辑')
|
||||
}
|
||||
|
||||
// 准备请求配置,包括abortSignal
|
||||
const generateContentParams: {
|
||||
model: string;
|
||||
contents: Array<{ text: string } | { inlineData: { mimeType: string; data: string } }>;
|
||||
config?: { httpOptions: { abortSignal: AbortSignal } };
|
||||
} = {
|
||||
model: 'gemini-2.5-flash-image-preview',
|
||||
contents,
|
||||
})
|
||||
}
|
||||
|
||||
// 如果提供了abortSignal,则添加到请求配置中
|
||||
if (request.abortSignal) {
|
||||
generateContentParams.config = {
|
||||
httpOptions: {
|
||||
abortSignal: request.abortSignal
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const response = await genAI.models.generateContent(generateContentParams)
|
||||
|
||||
// 检查是否有被禁止的内容
|
||||
if (response.candidates && response.candidates.length > 0) {
|
||||
@@ -188,22 +403,25 @@ export class GeminiService {
|
||||
if (candidate.finishReason === 'PROHIBITED_CONTENT') {
|
||||
throw new Error('内容被禁止:您的请求包含不允许的内容。请尝试其他提示。')
|
||||
}
|
||||
if (candidate.finishReason === 'IMAGE_SAFETY') {
|
||||
throw new Error('图像安全检查失败:请求的图像内容可能不安全。请尝试调整提示词。')
|
||||
}
|
||||
// 检查finishReason为STOP但没有inlineData的情况
|
||||
if (candidate.finishReason === 'STOP') {
|
||||
// 检查是否有inlineData
|
||||
let hasInlineData = false;
|
||||
let hasInlineData = false
|
||||
if (candidate.content && candidate.content.parts) {
|
||||
for (const part of candidate.content.parts) {
|
||||
if (part.inlineData) {
|
||||
hasInlineData = true;
|
||||
break;
|
||||
hasInlineData = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 如果没有inlineData,则抛出错误
|
||||
if (!hasInlineData && candidate.content && candidate.content.parts && candidate.content.parts.length > 0) {
|
||||
throw new Error(candidate.content.parts[0].text || '编辑失败:未返回图像数据');
|
||||
throw new Error(candidate.content.parts[0].text || '编辑失败:未返回图像数据')
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -215,15 +433,28 @@ export class GeminiService {
|
||||
for (const part of response.candidates[0].content.parts) {
|
||||
if (part.inlineData) {
|
||||
// 将返回的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);
|
||||
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);
|
||||
ia[i] = byteString.charCodeAt(i)
|
||||
}
|
||||
const blob = new Blob([ab], { type: mimeString });
|
||||
images.push(blob);
|
||||
const blob = new Blob([ab], { type: mimeString })
|
||||
images.push(blob)
|
||||
}
|
||||
}
|
||||
|
||||
// 如果没有图像数据但有文本响应,抛出包含文本的错误
|
||||
if (images.length === 0) {
|
||||
let textResponse = ''
|
||||
for (const part of response.candidates[0].content.parts) {
|
||||
if (part.text) {
|
||||
textResponse += part.text
|
||||
}
|
||||
}
|
||||
if (textResponse) {
|
||||
throw new Error(`编辑失败:${textResponse}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -234,6 +465,10 @@ export class GeminiService {
|
||||
return { images, usageMetadata }
|
||||
} catch (error) {
|
||||
console.error('编辑图像时出错:', error)
|
||||
// 检查是否是由于abortSignal导致的取消
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
throw new Error('编辑已取消')
|
||||
}
|
||||
if (error instanceof Error && error.message) {
|
||||
throw error
|
||||
}
|
||||
@@ -241,11 +476,35 @@ export class GeminiService {
|
||||
}
|
||||
}
|
||||
|
||||
async segmentImage(request: SegmentationRequest): Promise<any> {
|
||||
async segmentImage(request: SegmentationRequest): Promise<{ masks: Array<{ label: string; box_2d: [number, number, number, number]; mask: string }> }> {
|
||||
try {
|
||||
// 将Blob转换为base64以发送到API
|
||||
const imageBase64 = await this.blobToBase64(request.image);
|
||||
|
||||
// 将图像Blob转换为base64以发送到API
|
||||
let imageBase64: string
|
||||
|
||||
// 生成图像Blob的唯一标识符
|
||||
const blobId = await this.generateBlobId(request.image)
|
||||
|
||||
// 检查缓存中是否已有该图像的base64数据
|
||||
if (this.base64ImagesCache.has(blobId)) {
|
||||
// 从缓存中获取base64数据
|
||||
imageBase64 = this.base64ImagesCache.get(blobId)!
|
||||
console.log('从缓存中获取分割图像base64数据')
|
||||
} else {
|
||||
// 转换Blob为base64并缓存结果
|
||||
imageBase64 = await this.blobToBase64(request.image)
|
||||
// 将base64数据存储到缓存中
|
||||
this.base64ImagesCache.set(blobId, imageBase64)
|
||||
console.log('生成并缓存分割图像base64数据')
|
||||
}
|
||||
|
||||
// 如果base64数据为空,重新生成
|
||||
if (!imageBase64 || imageBase64.length === 0) {
|
||||
console.warn('分割图像base64数据为空,重新生成')
|
||||
imageBase64 = await this.blobToBase64(request.image)
|
||||
// 更新缓存
|
||||
this.base64ImagesCache.set(blobId, imageBase64)
|
||||
}
|
||||
|
||||
const prompt = [
|
||||
{
|
||||
text: `分析此图像并为以下对象创建分割遮罩: ${request.query}
|
||||
@@ -263,18 +522,49 @@ export class GeminiService {
|
||||
|
||||
仅分割请求的特定对象或区域。遮罩应该是二进制PNG,其中白色像素(255)表示选定区域,黑色像素(0)表示背景。`,
|
||||
},
|
||||
{
|
||||
]
|
||||
|
||||
// 确保图像数据不为空
|
||||
if (imageBase64 && imageBase64.length > 0) {
|
||||
prompt.push({
|
||||
inlineData: {
|
||||
mimeType: 'image/png',
|
||||
data: imageBase64,
|
||||
},
|
||||
},
|
||||
]
|
||||
})
|
||||
} else {
|
||||
console.warn('跳过空的分割图像数据')
|
||||
}
|
||||
|
||||
const response = await genAI.models.generateContent({
|
||||
// 检查prompt是否包含有效的图像数据或文本提示
|
||||
const hasImageData = prompt.some(item => item.inlineData && item.inlineData.data && item.inlineData.data.length > 0)
|
||||
const hasTextPrompt = prompt.some(item => item.text && item.text.length > 0)
|
||||
|
||||
// 如果既没有图像数据也没有文本提示,抛出错误
|
||||
if (!hasImageData && !hasTextPrompt) {
|
||||
throw new Error('没有有效的图像数据或文本提示用于分割')
|
||||
}
|
||||
|
||||
// 准备请求配置,包括abortSignal
|
||||
const generateContentParams: {
|
||||
model: string;
|
||||
contents: Array<{ text: string } | { inlineData: { mimeType: string; data: string } }>;
|
||||
config?: { httpOptions: { abortSignal: AbortSignal } };
|
||||
} = {
|
||||
model: 'gemini-2.5-flash-image-preview',
|
||||
contents: prompt,
|
||||
})
|
||||
}
|
||||
|
||||
// 如果提供了abortSignal,则添加到请求配置中
|
||||
if (request.abortSignal) {
|
||||
generateContentParams.config = {
|
||||
httpOptions: {
|
||||
abortSignal: request.abortSignal
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const response = await genAI.models.generateContent(generateContentParams)
|
||||
|
||||
// 检查是否有被禁止的内容
|
||||
if (response.candidates && response.candidates.length > 0) {
|
||||
@@ -282,22 +572,25 @@ export class GeminiService {
|
||||
if (candidate.finishReason === 'PROHIBITED_CONTENT') {
|
||||
throw new Error('内容被禁止:您的请求包含不允许的内容。请尝试其他提示。')
|
||||
}
|
||||
if (candidate.finishReason === 'IMAGE_SAFETY') {
|
||||
throw new Error('图像安全检查失败:请求的图像内容可能不安全。请尝试调整提示词。')
|
||||
}
|
||||
// 检查finishReason为STOP但没有inlineData的情况
|
||||
if (candidate.finishReason === 'STOP') {
|
||||
// 检查是否有inlineData
|
||||
let hasInlineData = false;
|
||||
let hasInlineData = false
|
||||
if (candidate.content && candidate.content.parts) {
|
||||
for (const part of candidate.content.parts) {
|
||||
if (part.inlineData) {
|
||||
hasInlineData = true;
|
||||
break;
|
||||
hasInlineData = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 如果没有inlineData,则抛出错误
|
||||
if (!hasInlineData && candidate.content && candidate.content.parts && candidate.content.parts.length > 0) {
|
||||
throw new Error(candidate.content.parts[0].text || '分割失败:未返回结果数据');
|
||||
throw new Error(candidate.content.parts[0].text || '分割失败:未返回结果数据')
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -306,6 +599,10 @@ export class GeminiService {
|
||||
return JSON.parse(responseText)
|
||||
} catch (error) {
|
||||
console.error('分割图像时出错:', error)
|
||||
// 检查是否是由于abortSignal导致的取消
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
throw new Error('分割已取消')
|
||||
}
|
||||
if (error instanceof Error && error.message) {
|
||||
throw error
|
||||
}
|
||||
@@ -316,12 +613,19 @@ export class GeminiService {
|
||||
private buildEditPrompt(request: EditRequest): string {
|
||||
const maskInstruction = request.maskImage ? '\n\n重要: 仅在遮罩图像显示白色像素(值255)的地方应用更改。完全不更改所有其他区域。精确遵守遮罩边界并在边缘保持无缝混合。' : ''
|
||||
|
||||
return `根据以下指令编辑此图像: ${request.instruction}
|
||||
return `根据以下指令编辑此图像: ${request.instruction}\n\n保持原始图像的光照、透视和整体构图。使更改看起来自然且无缝集成。${maskInstruction}\n\n保持图像质量并确保编辑看起来专业且逼真。`
|
||||
}
|
||||
|
||||
保持原始图像的光照、透视和整体构图。使更改看起来自然且无缝集成。${maskInstruction}
|
||||
// 公共方法:清除base64图像缓存
|
||||
public clearBase64Cache(): void {
|
||||
this.base64ImagesCache.clear()
|
||||
console.log('已清除base64图像缓存')
|
||||
}
|
||||
|
||||
保持图像质量并确保编辑看起来专业且逼真。`
|
||||
// 公共方法:获取缓存大小
|
||||
public getCacheSize(): number {
|
||||
return this.base64ImagesCache.size
|
||||
}
|
||||
}
|
||||
|
||||
export const geminiService = new GeminiService()
|
||||
export const geminiService = new GeminiService()
|
||||
@@ -2,13 +2,14 @@ import { Generation, Edit } from '../types';
|
||||
|
||||
// 数据库配置
|
||||
const DB_NAME = 'NanoBananaDB';
|
||||
const DB_VERSION = 1;
|
||||
const DB_VERSION = 2; // 更新版本号
|
||||
const GENERATIONS_STORE = 'generations';
|
||||
const EDITS_STORE = 'edits';
|
||||
const REFERENCE_IMAGES_STORE = 'referenceImages'; // 新增参考图像存储
|
||||
|
||||
// 重试配置
|
||||
const MAX_RETRIES = 3;
|
||||
const RETRY_DELAY = 1000;
|
||||
// const MAX_RETRIES = 3;
|
||||
// const RETRY_DELAY = 1000;
|
||||
|
||||
// IndexedDB实例
|
||||
let db: IDBDatabase | null = null;
|
||||
@@ -45,6 +46,12 @@ export const initDB = (): Promise<void> => {
|
||||
editStore.createIndex('timestamp', 'timestamp', { unique: false });
|
||||
editStore.createIndex('parentGenerationId', 'parentGenerationId', { unique: false });
|
||||
}
|
||||
|
||||
// 创建参考图像存储
|
||||
if (!db.objectStoreNames.contains(REFERENCE_IMAGES_STORE)) {
|
||||
const refImageStore = db.createObjectStore(REFERENCE_IMAGES_STORE, { keyPath: 'id' });
|
||||
refImageStore.createIndex('timestamp', 'timestamp', { unique: false });
|
||||
}
|
||||
};
|
||||
});
|
||||
};
|
||||
@@ -318,7 +325,7 @@ export const cleanupBase64Data = async (): Promise<void> => {
|
||||
let needsUpdate = false;
|
||||
|
||||
// 清理源资产中的base64数据
|
||||
const cleanedSourceAssets = generation.sourceAssets.map((asset: any) => {
|
||||
const cleanedSourceAssets = generation.sourceAssets.map((asset) => {
|
||||
if (asset.url && asset.url.startsWith('data:')) {
|
||||
needsUpdate = true;
|
||||
return {
|
||||
@@ -330,7 +337,7 @@ export const cleanupBase64Data = async (): Promise<void> => {
|
||||
});
|
||||
|
||||
// 清理输出资产中的base64数据
|
||||
const cleanedOutputAssets = generation.outputAssets.map((asset: any) => {
|
||||
const cleanedOutputAssets = generation.outputAssets.map((asset) => {
|
||||
if (asset.url && asset.url.startsWith('data:')) {
|
||||
needsUpdate = true;
|
||||
return {
|
||||
@@ -375,7 +382,7 @@ export const cleanupBase64Data = async (): Promise<void> => {
|
||||
}
|
||||
|
||||
// 清理输出资产中的base64数据
|
||||
const cleanedOutputAssets = edit.outputAssets.map((asset: any) => {
|
||||
const cleanedOutputAssets = edit.outputAssets.map((asset) => {
|
||||
if (asset.url && asset.url.startsWith('data:')) {
|
||||
needsUpdate = true;
|
||||
return {
|
||||
@@ -478,6 +485,86 @@ export const deleteEdits = async (ids: string[]): Promise<void> => {
|
||||
return Promise.all(promises).then(() => undefined);
|
||||
};
|
||||
|
||||
/**
|
||||
* 添加参考图像
|
||||
*/
|
||||
export const addReferenceImage = async (image: { id: string; data: string; timestamp: number }): Promise<void> => {
|
||||
const db = getDB();
|
||||
const transaction = db.transaction([REFERENCE_IMAGES_STORE], 'readwrite');
|
||||
const store = transaction.objectStore(REFERENCE_IMAGES_STORE);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = store.add(image);
|
||||
request.onsuccess = () => resolve();
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取所有参考图像
|
||||
*/
|
||||
export const getAllReferenceImages = async (): Promise<Array<{ id: string; data: string; timestamp: number }>> => {
|
||||
const db = getDB();
|
||||
const transaction = db.transaction([REFERENCE_IMAGES_STORE], 'readonly');
|
||||
const store = transaction.objectStore(REFERENCE_IMAGES_STORE);
|
||||
const index = store.index('timestamp');
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = index.getAll();
|
||||
request.onsuccess = () => {
|
||||
// 按时间倒序排列
|
||||
const images = request.result.sort((a, b) => b.timestamp - a.timestamp);
|
||||
resolve(images);
|
||||
};
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 根据ID获取参考图像
|
||||
*/
|
||||
export const getReferenceImageById = async (id: string): Promise<{ id: string; data: string; timestamp: number } | undefined> => {
|
||||
const db = getDB();
|
||||
const transaction = db.transaction([REFERENCE_IMAGES_STORE], 'readonly');
|
||||
const store = transaction.objectStore(REFERENCE_IMAGES_STORE);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = store.get(id);
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 删除参考图像
|
||||
*/
|
||||
export const deleteReferenceImage = async (id: string): Promise<void> => {
|
||||
const db = getDB();
|
||||
const transaction = db.transaction([REFERENCE_IMAGES_STORE], 'readwrite');
|
||||
const store = transaction.objectStore(REFERENCE_IMAGES_STORE);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = store.delete(id);
|
||||
request.onsuccess = () => resolve();
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 清空所有参考图像
|
||||
*/
|
||||
export const clearAllReferenceImages = async (): Promise<void> => {
|
||||
const db = getDB();
|
||||
const transaction = db.transaction([REFERENCE_IMAGES_STORE], 'readwrite');
|
||||
const store = transaction.objectStore(REFERENCE_IMAGES_STORE);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = store.clear();
|
||||
request.onsuccess = () => resolve();
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 清空所有记录
|
||||
*/
|
||||
@@ -490,6 +577,9 @@ export const clearAllRecords = async (): Promise<void> => {
|
||||
const editTransaction = db.transaction([EDITS_STORE], 'readwrite');
|
||||
const editStore = editTransaction.objectStore(EDITS_STORE);
|
||||
|
||||
const refImageTransaction = db.transaction([REFERENCE_IMAGES_STORE], 'readwrite');
|
||||
const refImageStore = refImageTransaction.objectStore(REFERENCE_IMAGES_STORE);
|
||||
|
||||
return Promise.all([
|
||||
new Promise<void>((resolve, reject) => {
|
||||
const request = genStore.clear();
|
||||
@@ -500,13 +590,14 @@ export const clearAllRecords = async (): Promise<void> => {
|
||||
const request = editStore.clear();
|
||||
request.onsuccess = () => resolve();
|
||||
request.onerror = () => reject(request.error);
|
||||
}),
|
||||
new Promise<void>((resolve, reject) => {
|
||||
const request = refImageStore.clear();
|
||||
request.onsuccess = () => resolve();
|
||||
request.onerror = () => reject(request.error);
|
||||
})
|
||||
]).then(() => undefined);
|
||||
};
|
||||
|
||||
/**
|
||||
* 关闭数据库连接
|
||||
*/
|
||||
export const closeDB = (): void => {
|
||||
if (db) {
|
||||
db.close();
|
||||
|
||||
114
src/services/referenceImageService.ts
Normal file
114
src/services/referenceImageService.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import * as indexedDBService from './indexedDBService';
|
||||
import { generateId } from '../utils/imageUtils';
|
||||
|
||||
// 初始化数据库
|
||||
export const initReferenceImageDB = async (): Promise<void> => {
|
||||
try {
|
||||
await indexedDBService.initDB();
|
||||
} catch (error) {
|
||||
console.error('初始化参考图像数据库失败:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// 将Blob转换为base64
|
||||
const blobToBase64 = (blob: Blob): Promise<string> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
const result = reader.result as string;
|
||||
resolve(result);
|
||||
};
|
||||
reader.onerror = reject;
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
};
|
||||
|
||||
// 将base64转换为Blob
|
||||
const base64ToBlob = (base64: string, mimeType: string): Blob => {
|
||||
const byteString = atob(base64.split(',')[1]);
|
||||
const ab = new ArrayBuffer(byteString.length);
|
||||
const ia = new Uint8Array(ab);
|
||||
for (let i = 0; i < byteString.length; i++) {
|
||||
ia[i] = byteString.charCodeAt(i);
|
||||
}
|
||||
return new Blob([ab], { type: mimeType });
|
||||
};
|
||||
|
||||
// 保存参考图像到IndexedDB
|
||||
export const saveReferenceImage = async (blob: Blob): Promise<string> => {
|
||||
try {
|
||||
// 生成唯一ID
|
||||
const id = generateId();
|
||||
|
||||
// 转换Blob为base64
|
||||
const base64Data = await blobToBase64(blob);
|
||||
|
||||
// 保存到IndexedDB
|
||||
await indexedDBService.addReferenceImage({
|
||||
id,
|
||||
data: base64Data,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
|
||||
console.log('参考图像已保存到IndexedDB:', id);
|
||||
return id;
|
||||
} catch (error) {
|
||||
console.error('保存参考图像失败:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// 从IndexedDB获取参考图像
|
||||
export const getReferenceImage = async (id: string): Promise<Blob | null> => {
|
||||
try {
|
||||
const imageRecord = await indexedDBService.getReferenceImageById(id);
|
||||
if (!imageRecord) {
|
||||
console.warn('未找到参考图像:', id);
|
||||
return null;
|
||||
}
|
||||
|
||||
// 从base64数据创建Blob
|
||||
const mimeType = imageRecord.data.match(/data:([^;]+)/)?.[1] || 'image/png';
|
||||
const blob = base64ToBlob(imageRecord.data, mimeType);
|
||||
|
||||
console.log('参考图像已从IndexedDB获取:', id);
|
||||
return blob;
|
||||
} catch (error) {
|
||||
console.error('获取参考图像失败:', error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// 删除参考图像
|
||||
export const deleteReferenceImage = async (id: string): Promise<void> => {
|
||||
try {
|
||||
await indexedDBService.deleteReferenceImage(id);
|
||||
console.log('参考图像已从IndexedDB删除:', id);
|
||||
} catch (error) {
|
||||
console.error('删除参考图像失败:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// 获取所有参考图像ID
|
||||
export const getAllReferenceImageIds = async (): Promise<string[]> => {
|
||||
try {
|
||||
const images = await indexedDBService.getAllReferenceImages();
|
||||
return images.map(image => image.id);
|
||||
} catch (error) {
|
||||
console.error('获取所有参考图像ID失败:', error);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
// 清空所有参考图像
|
||||
export const clearAllReferenceImages = async (): Promise<void> => {
|
||||
try {
|
||||
await indexedDBService.clearAllReferenceImages();
|
||||
console.log('所有参考图像已从IndexedDB清空');
|
||||
} catch (error) {
|
||||
console.error('清空所有参考图像失败:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
@@ -1,8 +1,9 @@
|
||||
import { create } from 'zustand';
|
||||
import { devtools, persist } from 'zustand/middleware';
|
||||
import { Project, Generation, Edit, SegmentationMask, BrushStroke, UploadResult } from '../types';
|
||||
import { Generation, Edit, BrushStroke, UploadResult } from '../types';
|
||||
import { generateId } from '../utils/imageUtils';
|
||||
import * as indexedDBService from '../services/indexedDBService';
|
||||
import * as referenceImageService from '../services/referenceImageService';
|
||||
|
||||
// 定义不包含图像数据的轻量级项目结构
|
||||
interface LightweightProject {
|
||||
@@ -93,6 +94,7 @@ interface AppState {
|
||||
|
||||
addUploadedImage: (url: string) => void;
|
||||
removeUploadedImage: (index: number) => void;
|
||||
reorderUploadedImage: (fromIndex: number, toIndex: number) => void;
|
||||
clearUploadedImages: () => void;
|
||||
|
||||
addEditReferenceImage: (url: string) => void;
|
||||
@@ -135,7 +137,7 @@ interface AppState {
|
||||
}
|
||||
|
||||
// 限制历史记录数量
|
||||
const MAX_HISTORY_ITEMS = 50;
|
||||
const MAX_HISTORY_ITEMS = 1000;
|
||||
|
||||
export const useAppStore = create<AppState>()(
|
||||
devtools(
|
||||
@@ -179,18 +181,70 @@ export const useAppStore = create<AppState>()(
|
||||
addUploadedImage: (url) => set((state) => ({
|
||||
uploadedImages: [...state.uploadedImages, url]
|
||||
})),
|
||||
removeUploadedImage: (index) => set((state) => ({
|
||||
uploadedImages: state.uploadedImages.filter((_, i) => i !== index)
|
||||
})),
|
||||
clearUploadedImages: () => set({ uploadedImages: [] }),
|
||||
removeUploadedImage: (index) => set((state) => {
|
||||
// 如果删除的是IndexedDB中的参考图像,同时从IndexedDB中删除
|
||||
const imageUrl = state.uploadedImages[index];
|
||||
if (imageUrl && imageUrl.startsWith('indexeddb://')) {
|
||||
const imageId = imageUrl.replace('indexeddb://', '');
|
||||
referenceImageService.deleteReferenceImage(imageId).catch(err => {
|
||||
console.error('删除参考图像失败:', err);
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
uploadedImages: state.uploadedImages.filter((_, i) => i !== index)
|
||||
};
|
||||
}),
|
||||
reorderUploadedImage: (fromIndex, toIndex) => set((state) => {
|
||||
const newUploadedImages = [...state.uploadedImages];
|
||||
const [movedItem] = newUploadedImages.splice(fromIndex, 1);
|
||||
newUploadedImages.splice(toIndex, 0, movedItem);
|
||||
return { uploadedImages: newUploadedImages };
|
||||
}),
|
||||
clearUploadedImages: () => set((state) => {
|
||||
// 删除所有存储在IndexedDB中的参考图像
|
||||
state.uploadedImages.forEach(imageUrl => {
|
||||
if (imageUrl.startsWith('indexeddb://')) {
|
||||
const imageId = imageUrl.replace('indexeddb://', '');
|
||||
referenceImageService.deleteReferenceImage(imageId).catch(err => {
|
||||
console.error('删除参考图像失败:', err);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return { uploadedImages: [] };
|
||||
}),
|
||||
|
||||
addEditReferenceImage: (url) => set((state) => ({
|
||||
editReferenceImages: [...state.editReferenceImages, url]
|
||||
})),
|
||||
removeEditReferenceImage: (index) => set((state) => ({
|
||||
editReferenceImages: state.editReferenceImages.filter((_, i) => i !== index)
|
||||
})),
|
||||
clearEditReferenceImages: () => set({ editReferenceImages: [] }),
|
||||
removeEditReferenceImage: (index) => set((state) => {
|
||||
// 如果删除的是IndexedDB中的参考图像,同时从IndexedDB中删除
|
||||
const imageUrl = state.editReferenceImages[index];
|
||||
if (imageUrl && imageUrl.startsWith('indexeddb://')) {
|
||||
const imageId = imageUrl.replace('indexeddb://', '');
|
||||
referenceImageService.deleteReferenceImage(imageId).catch(err => {
|
||||
console.error('删除参考图像失败:', err);
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
editReferenceImages: state.editReferenceImages.filter((_, i) => i !== index)
|
||||
};
|
||||
}),
|
||||
clearEditReferenceImages: () => set((state) => {
|
||||
// 删除所有存储在IndexedDB中的参考图像
|
||||
state.editReferenceImages.forEach(imageUrl => {
|
||||
if (imageUrl.startsWith('indexeddb://')) {
|
||||
const imageId = imageUrl.replace('indexeddb://', '');
|
||||
referenceImageService.deleteReferenceImage(imageId).catch(err => {
|
||||
console.error('删除参考图像失败:', err);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return { editReferenceImages: [] };
|
||||
}),
|
||||
|
||||
addBrushStroke: (stroke) => set((state) => ({
|
||||
brushStrokes: [...state.brushStrokes, stroke]
|
||||
@@ -353,43 +407,18 @@ export const useAppStore = create<AppState>()(
|
||||
};
|
||||
|
||||
// 清理旧记录以保持在限制内
|
||||
if (updatedProject.generations.length > MAX_HISTORY_ITEMS) {
|
||||
// 收集需要释放的Blob URLs
|
||||
const urlsToRevoke: string[] = [];
|
||||
const generationsToRemove = updatedProject.generations.slice(0, updatedProject.generations.length - MAX_HISTORY_ITEMS);
|
||||
generationsToRemove.forEach(gen => {
|
||||
gen.sourceAssets.forEach(asset => {
|
||||
if (asset.blobUrl.startsWith('blob:')) {
|
||||
urlsToRevoke.push(asset.blobUrl);
|
||||
}
|
||||
});
|
||||
gen.outputAssetsBlobUrls.forEach(url => {
|
||||
if (url.startsWith('blob:')) {
|
||||
urlsToRevoke.push(url);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// 释放Blob URLs
|
||||
if (urlsToRevoke.length > 0) {
|
||||
set((innerState) => {
|
||||
urlsToRevoke.forEach(url => {
|
||||
URL.revokeObjectURL(url);
|
||||
const newBlobStore = new Map(innerState.blobStore);
|
||||
newBlobStore.delete(url);
|
||||
innerState = { ...innerState, blobStore: newBlobStore };
|
||||
});
|
||||
return innerState;
|
||||
});
|
||||
}
|
||||
|
||||
// 清理数组
|
||||
updatedProject.generations.splice(0, updatedProject.generations.length - MAX_HISTORY_ITEMS);
|
||||
// 同时清理IndexedDB中的旧记录
|
||||
indexedDBService.cleanupOldRecords(MAX_HISTORY_ITEMS).catch(err => {
|
||||
console.error('清理IndexedDB旧记录失败:', err);
|
||||
});
|
||||
}
|
||||
if (updatedProject.generations.length > MAX_HISTORY_ITEMS) {
|
||||
// 不再清理Blob URLs,以确保参考图像不会被意外删除
|
||||
// 只记录信息
|
||||
console.log('历史记录已达到限制,但Blob清理已禁用,参考图像将被永久保留');
|
||||
|
||||
// 清理数组
|
||||
updatedProject.generations.splice(0, updatedProject.generations.length - MAX_HISTORY_ITEMS);
|
||||
// 同时清理IndexedDB中的旧记录
|
||||
indexedDBService.cleanupOldRecords(MAX_HISTORY_ITEMS).catch(err => {
|
||||
console.error('清理IndexedDB旧记录失败:', err);
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
currentProject: updatedProject
|
||||
@@ -483,32 +512,9 @@ export const useAppStore = create<AppState>()(
|
||||
|
||||
// 清理旧记录以保持在限制内
|
||||
if (updatedProject.edits.length > MAX_HISTORY_ITEMS) {
|
||||
// 收集需要释放的Blob URLs
|
||||
const urlsToRevoke: string[] = [];
|
||||
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);
|
||||
}
|
||||
edit.outputAssetsBlobUrls.forEach(url => {
|
||||
if (url.startsWith('blob:')) {
|
||||
urlsToRevoke.push(url);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// 释放Blob URLs
|
||||
if (urlsToRevoke.length > 0) {
|
||||
set((innerState) => {
|
||||
urlsToRevoke.forEach(url => {
|
||||
URL.revokeObjectURL(url);
|
||||
const newBlobStore = new Map(innerState.blobStore);
|
||||
newBlobStore.delete(url);
|
||||
innerState = { ...innerState, blobStore: newBlobStore };
|
||||
});
|
||||
return innerState;
|
||||
});
|
||||
}
|
||||
// 不再清理Blob URLs,以确保参考图像不会被意外删除
|
||||
// 只记录信息
|
||||
console.log('编辑记录已达到限制,但Blob清理已禁用,参考图像将被永久保留');
|
||||
|
||||
// 清理数组
|
||||
updatedProject.edits.splice(0, updatedProject.edits.length - MAX_HISTORY_ITEMS);
|
||||
@@ -631,56 +637,20 @@ export const useAppStore = create<AppState>()(
|
||||
const generations = [...state.currentProject.generations];
|
||||
const edits = [...state.currentProject.edits];
|
||||
|
||||
// 收集需要释放的Blob URLs
|
||||
const urlsToRevoke: string[] = [];
|
||||
// 不再清理Blob URLs,以确保参考图像不会被意外删除
|
||||
// 只记录信息
|
||||
console.log('历史记录清理已禁用,参考图像将被永久保留');
|
||||
|
||||
// 如果生成记录超过限制,只保留最新的记录
|
||||
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:')) {
|
||||
urlsToRevoke.push(asset.blobUrl);
|
||||
}
|
||||
});
|
||||
gen.outputAssetsBlobUrls.forEach(url => {
|
||||
if (url.startsWith('blob:')) {
|
||||
urlsToRevoke.push(url);
|
||||
}
|
||||
});
|
||||
});
|
||||
generations.splice(0, generations.length - MAX_HISTORY_ITEMS);
|
||||
}
|
||||
|
||||
// 如果编辑记录超过限制,只保留最新的记录
|
||||
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);
|
||||
}
|
||||
edit.outputAssetsBlobUrls.forEach(url => {
|
||||
if (url.startsWith('blob:')) {
|
||||
urlsToRevoke.push(url);
|
||||
}
|
||||
});
|
||||
});
|
||||
edits.splice(0, edits.length - MAX_HISTORY_ITEMS);
|
||||
}
|
||||
|
||||
// 释放Blob URLs
|
||||
if (urlsToRevoke.length > 0) {
|
||||
set((innerState) => {
|
||||
urlsToRevoke.forEach(url => {
|
||||
URL.revokeObjectURL(url);
|
||||
const newBlobStore = new Map(innerState.blobStore);
|
||||
newBlobStore.delete(url);
|
||||
innerState = { ...innerState, blobStore: newBlobStore };
|
||||
});
|
||||
return innerState;
|
||||
});
|
||||
}
|
||||
|
||||
// 同时清理IndexedDB中的旧记录
|
||||
indexedDBService.cleanupOldRecords(MAX_HISTORY_ITEMS).catch(err => {
|
||||
console.error('清理IndexedDB旧记录失败:', err);
|
||||
@@ -698,92 +668,42 @@ export const useAppStore = create<AppState>()(
|
||||
|
||||
// 释放指定的Blob URLs
|
||||
revokeBlobUrls: (urls: string[]) => set((state) => {
|
||||
// 清理指定的Blob URL
|
||||
const newBlobStore = new Map(state.blobStore);
|
||||
urls.forEach(url => {
|
||||
if (state.blobStore.has(url)) {
|
||||
if (url.startsWith('blob:')) {
|
||||
URL.revokeObjectURL(url);
|
||||
const newBlobStore = new Map(state.blobStore);
|
||||
newBlobStore.delete(url);
|
||||
state = { ...state, blobStore: newBlobStore };
|
||||
}
|
||||
});
|
||||
return state;
|
||||
return { blobStore: newBlobStore };
|
||||
}),
|
||||
|
||||
// 释放所有Blob URLs
|
||||
cleanupAllBlobUrls: () => set((state) => {
|
||||
state.blobStore.forEach((_, url) => {
|
||||
URL.revokeObjectURL(url);
|
||||
// 清理所有Blob URL
|
||||
state.blobStore.forEach((blob, url) => {
|
||||
if (url.startsWith('blob:')) {
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
});
|
||||
return { ...state, blobStore: new Map() };
|
||||
return { blobStore: new Map() };
|
||||
}),
|
||||
|
||||
// 定期清理Blob URL
|
||||
scheduleBlobCleanup: () => {
|
||||
// 清理超过10分钟未使用的Blob
|
||||
const state = get();
|
||||
const now = Date.now();
|
||||
|
||||
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 });
|
||||
}
|
||||
});
|
||||
// 不再自动清理Blob URL,以确保参考图像不会被意外删除
|
||||
// 只有在用户明确请求清除会话时才清理
|
||||
console.log('Blob清理已禁用,参考图像将被永久保留');
|
||||
},
|
||||
|
||||
// 删除生成记录
|
||||
removeGeneration: (id) => set((state) => {
|
||||
if (!state.currentProject) return {};
|
||||
|
||||
// 收集需要释放的Blob URLs
|
||||
const urlsToRevoke: string[] = [];
|
||||
const generationToRemove = state.currentProject.generations.find(gen => gen.id === id);
|
||||
|
||||
if (generationToRemove) {
|
||||
// 收集要删除的生成记录中的Blob URLs
|
||||
generationToRemove.sourceAssets.forEach(asset => {
|
||||
if (asset.blobUrl.startsWith('blob:')) {
|
||||
urlsToRevoke.push(asset.blobUrl);
|
||||
}
|
||||
});
|
||||
generationToRemove.outputAssetsBlobUrls.forEach(url => {
|
||||
if (url.startsWith('blob:')) {
|
||||
urlsToRevoke.push(url);
|
||||
}
|
||||
});
|
||||
|
||||
// 释放Blob URLs
|
||||
if (urlsToRevoke.length > 0) {
|
||||
set((innerState) => {
|
||||
urlsToRevoke.forEach(url => {
|
||||
URL.revokeObjectURL(url);
|
||||
const newBlobStore = new Map(innerState.blobStore);
|
||||
newBlobStore.delete(url);
|
||||
innerState = { ...innerState, blobStore: newBlobStore };
|
||||
});
|
||||
return innerState;
|
||||
});
|
||||
}
|
||||
}
|
||||
// 不再清理Blob URLs,以确保参考图像不会被意外删除
|
||||
// 只记录信息
|
||||
console.log('生成记录删除操作已执行,但Blob清理已禁用,参考图像将被永久保留');
|
||||
|
||||
// 从项目中移除生成记录
|
||||
const updatedProject = {
|
||||
@@ -801,34 +721,9 @@ export const useAppStore = create<AppState>()(
|
||||
removeEdit: (id) => set((state) => {
|
||||
if (!state.currentProject) return {};
|
||||
|
||||
// 收集需要释放的Blob URLs
|
||||
const urlsToRevoke: string[] = [];
|
||||
const editToRemove = state.currentProject.edits.find(edit => edit.id === id);
|
||||
|
||||
if (editToRemove) {
|
||||
// 收集要删除的编辑记录中的Blob URLs
|
||||
if (editToRemove.maskReferenceAssetBlobUrl && editToRemove.maskReferenceAssetBlobUrl.startsWith('blob:')) {
|
||||
urlsToRevoke.push(editToRemove.maskReferenceAssetBlobUrl);
|
||||
}
|
||||
editToRemove.outputAssetsBlobUrls.forEach(url => {
|
||||
if (url.startsWith('blob:')) {
|
||||
urlsToRevoke.push(url);
|
||||
}
|
||||
});
|
||||
|
||||
// 释放Blob URLs
|
||||
if (urlsToRevoke.length > 0) {
|
||||
set((innerState) => {
|
||||
urlsToRevoke.forEach(url => {
|
||||
URL.revokeObjectURL(url);
|
||||
const newBlobStore = new Map(innerState.blobStore);
|
||||
newBlobStore.delete(url);
|
||||
innerState = { ...innerState, blobStore: newBlobStore };
|
||||
});
|
||||
return innerState;
|
||||
});
|
||||
}
|
||||
}
|
||||
// 不再清理Blob URLs,以确保参考图像不会被意外删除
|
||||
// 只记录信息
|
||||
console.log('编辑记录删除操作已执行,但Blob清理已禁用,参考图像将被永久保留');
|
||||
|
||||
// 从项目中移除编辑记录
|
||||
const updatedProject = {
|
||||
|
||||
@@ -42,6 +42,7 @@ export interface Edit {
|
||||
maskAssetId?: string;
|
||||
maskReferenceAsset?: Asset;
|
||||
instruction: string;
|
||||
sourceAssets?: Asset[]; // 添加参考图像字段
|
||||
outputAssets: Asset[];
|
||||
timestamp: number;
|
||||
uploadResults?: UploadResult[];
|
||||
|
||||
@@ -139,7 +139,7 @@ export async function compressImage(blob: Blob, quality: number = 0.8): Promise<
|
||||
URL.revokeObjectURL(url);
|
||||
// 调用原始的onload处理程序
|
||||
if (img.onload) {
|
||||
(img.onload as any).call(img);
|
||||
(img.onload as (() => void)).call(img);
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user