You've already forked Nano-Banana-AI-Image-Editor
阶段性提交
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",
|
||||
|
||||
11
src/App.tsx
11
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';
|
||||
@@ -65,15 +65,6 @@ function AppContent() {
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
// 定期清理未使用的Blob URL
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
useAppStore.getState().scheduleBlobCleanup();
|
||||
}, 60000); // 每分钟清理一次
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
// 控制预览窗口的显示和隐藏动画
|
||||
useEffect(() => {
|
||||
|
||||
@@ -4,7 +4,6 @@ import { Button } from './ui/Button';
|
||||
import { History, Download, Image as ImageIcon, Trash2 } from 'lucide-react';
|
||||
import { cn } from '../utils/cn';
|
||||
import { ImagePreviewModal } from './ImagePreviewModal';
|
||||
import * as indexedDBService from '../services/indexedDBService';
|
||||
import { useIndexedDBListener } from '../hooks/useIndexedDBListener';
|
||||
import { DayPicker } from 'react-day-picker';
|
||||
import zhCN from 'react-day-picker/dist/locale/zh-CN';
|
||||
@@ -13,9 +12,8 @@ export const HistoryPanel: React.FC<{
|
||||
setHoveredImage: (image: {url: string, title: string, width?: number, height?: number} | null) => void,
|
||||
setPreviewPosition?: (position: {x: number, y: number} | null) => void
|
||||
}> = ({ setHoveredImage, setPreviewPosition }) => {
|
||||
const {
|
||||
const {
|
||||
currentProject,
|
||||
canvasImage,
|
||||
selectedGenerationId,
|
||||
selectedEditId,
|
||||
selectGeneration,
|
||||
@@ -23,7 +21,6 @@ export const HistoryPanel: React.FC<{
|
||||
showHistory,
|
||||
setShowHistory,
|
||||
setCanvasImage,
|
||||
selectedTool,
|
||||
removeGeneration,
|
||||
removeEdit
|
||||
} = useAppStore();
|
||||
@@ -68,18 +65,28 @@ export const HistoryPanel: React.FC<{
|
||||
const [startDate, setStartDate] = useState<string>(() => {
|
||||
// 初始化时默认显示今天的记录
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
return today.toISOString().split('T')[0];
|
||||
});
|
||||
const [endDate, setEndDate] = useState<string>(() => {
|
||||
// 初始化时默认显示今天的记录
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
return today.toISOString().split('T')[0];
|
||||
});
|
||||
const [searchTerm, setSearchTerm] = useState<string>('');
|
||||
const [showDatePicker, setShowDatePicker] = useState(false); // 控制日期选择器的显示
|
||||
const [dateRange, setDateRange] = useState<{ from: Date | undefined; to: Date | undefined }>({
|
||||
from: new Date(new Date().setHours(0, 0, 0, 0)),
|
||||
to: new Date(new Date().setHours(0, 0, 0, 0))
|
||||
from: (() => {
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
return today;
|
||||
})(),
|
||||
to: (() => {
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
return today;
|
||||
})()
|
||||
});
|
||||
|
||||
// 分页状态
|
||||
@@ -87,17 +94,18 @@ export const HistoryPanel: React.FC<{
|
||||
const itemsPerPage = 20; // 减少每页显示的项目数
|
||||
|
||||
// 获取当前图像尺寸
|
||||
const [imageDimensions, setImageDimensions] = React.useState<{ width: number; height: number } | null>(null);
|
||||
// const [imageDimensions, setImageDimensions] = React.useState<{ width: number; height: number } | null>(null);
|
||||
|
||||
// 当currentProject为空时,使用dbGenerations和dbEdits作为备选
|
||||
const displayGenerations = currentProject ? currentProject.generations : dbGenerations;
|
||||
const displayEdits = currentProject ? currentProject.edits : dbEdits;
|
||||
|
||||
// 筛选记录的函数
|
||||
const filterRecords = useCallback((records: any[], isGeneration: boolean) => {
|
||||
const filterRecords = useCallback((records: Array<{timestamp: number, prompt?: string, instruction?: string}>, isGeneration: boolean) => {
|
||||
return records.filter(record => {
|
||||
// 日期筛选 - 检查记录日期是否在筛选范围内
|
||||
const recordDate = new Date(record.timestamp);
|
||||
recordDate.setHours(0, 0, 0, 0); // 设置为当天的开始
|
||||
const recordDateStr = recordDate.toISOString().split('T')[0]; // 获取日期部分 YYYY-MM-DD
|
||||
|
||||
// 检查是否在日期范围内
|
||||
@@ -108,10 +116,10 @@ export const HistoryPanel: React.FC<{
|
||||
if (searchTerm) {
|
||||
if (isGeneration) {
|
||||
// 生成记录按提示词搜索
|
||||
return record.prompt.toLowerCase().includes(searchTerm.toLowerCase());
|
||||
return record.prompt?.toLowerCase().includes(searchTerm.toLowerCase()) || false;
|
||||
} else {
|
||||
// 编辑记录按指令搜索
|
||||
return record.instruction.toLowerCase().includes(searchTerm.toLowerCase());
|
||||
return record.instruction?.toLowerCase().includes(searchTerm.toLowerCase()) || false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -123,17 +131,17 @@ export const HistoryPanel: React.FC<{
|
||||
const filteredGenerations = useMemo(() => filterRecords(displayGenerations, true), [displayGenerations, filterRecords]);
|
||||
const filteredEdits = useMemo(() => filterRecords(displayEdits, false), [displayEdits, filterRecords]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (canvasImage) {
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
setImageDimensions({ width: img.width, height: img.height });
|
||||
};
|
||||
img.src = canvasImage;
|
||||
} else {
|
||||
setImageDimensions(null);
|
||||
}
|
||||
}, [canvasImage]);
|
||||
// React.useEffect(() => {
|
||||
// if (canvasImage) {
|
||||
// const img = new Image();
|
||||
// img.onload = () => {
|
||||
// setImageDimensions({ width: img.width, height: img.height });
|
||||
// };
|
||||
// img.src = canvasImage;
|
||||
// } else {
|
||||
// setImageDimensions(null);
|
||||
// }
|
||||
// }, [canvasImage]);
|
||||
|
||||
// 当项目变化时,解码Blob图像
|
||||
useEffect(() => {
|
||||
@@ -196,8 +204,8 @@ export const HistoryPanel: React.FC<{
|
||||
if (generationOrEdit.uploadResults && generationOrEdit.uploadResults[index]) {
|
||||
const uploadResult = generationOrEdit.uploadResults[index];
|
||||
if (uploadResult.success && uploadResult.url) {
|
||||
// 添加参数以降低图片质量
|
||||
return `${uploadResult.url}?x-oss-process=image/quality,q_30`; // 降低质量到30%
|
||||
// 返回原始链接,不添加任何参数
|
||||
return uploadResult.url;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
@@ -469,6 +477,13 @@ export const HistoryPanel: React.FC<{
|
||||
selected={dateRange}
|
||||
onSelect={(range) => {
|
||||
if (range) {
|
||||
// 确保日期时间设置为当天的开始
|
||||
if (range.from) {
|
||||
range.from.setHours(0, 0, 0, 0);
|
||||
}
|
||||
if (range.to) {
|
||||
range.to.setHours(0, 0, 0, 0);
|
||||
}
|
||||
setDateRange(range);
|
||||
// 更新字符串格式的日期用于筛选
|
||||
if (range.from) {
|
||||
@@ -511,7 +526,8 @@ export const HistoryPanel: React.FC<{
|
||||
className="text-xs p-1.5 rounded-l-none h-7 border-gray-200 text-gray-600 hover:bg-gray-100 card"
|
||||
onClick={() => {
|
||||
// 重置为显示今天的记录
|
||||
const today = new Date(new Date().setHours(0, 0, 0, 0));
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const todayStr = today.toISOString().split('T')[0];
|
||||
setStartDate(todayStr);
|
||||
setEndDate(todayStr);
|
||||
@@ -581,7 +597,7 @@ export const HistoryPanel: React.FC<{
|
||||
sortedGenerations.some(gen => gen.id === record.id)
|
||||
);
|
||||
|
||||
return paginatedGenerations.map((generation, index) => {
|
||||
return paginatedGenerations.map((generation: {id: string, sourceAssets?: Array<{url: string}>, outputAssets?: Array<{url: string}>}) => {
|
||||
// 计算全局索引用于显示编号
|
||||
const globalIndex = allRecords.findIndex(record => record.id === generation.id);
|
||||
|
||||
@@ -596,23 +612,57 @@ export const HistoryPanel: React.FC<{
|
||||
)}
|
||||
onClick={() => {
|
||||
selectGeneration(generation.id);
|
||||
// 设置画布图像为第一个输出资产
|
||||
if (generation.outputAssets && generation.outputAssets.length > 0) {
|
||||
// 设置画布图像为参考图像,如果没有参考图像则使用第一个输出资产
|
||||
let imageUrl = null;
|
||||
|
||||
// 首先尝试获取参考图像
|
||||
if (generation.sourceAssets && generation.sourceAssets.length > 0) {
|
||||
// 参考图像在uploadResults中从索引outputAssets.length开始
|
||||
const outputAssetsCount = generation.outputAssets?.length || 0;
|
||||
const uploadResultIndex = outputAssetsCount; // 第一个参考图像
|
||||
const uploadedUrl = getUploadedImageUrl(generation, uploadResultIndex);
|
||||
if (uploadedUrl) {
|
||||
imageUrl = uploadedUrl;
|
||||
} else if (generation.sourceAssets[0].url) {
|
||||
imageUrl = generation.sourceAssets[0].url;
|
||||
}
|
||||
}
|
||||
|
||||
// 如果没有参考图像,则使用生成结果图像
|
||||
if (!imageUrl && generation.outputAssets && generation.outputAssets.length > 0) {
|
||||
const asset = generation.outputAssets[0];
|
||||
if (asset.url) {
|
||||
setCanvasImage(asset.url);
|
||||
const uploadedUrl = getUploadedImageUrl(generation, 0);
|
||||
imageUrl = uploadedUrl || asset.url;
|
||||
}
|
||||
}
|
||||
|
||||
if (imageUrl) {
|
||||
setCanvasImage(imageUrl);
|
||||
}
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
// 设置当前悬停的记录
|
||||
setHoveredRecord({type: 'generation', id: generation.id});
|
||||
|
||||
// 优先使用上传后的远程链接,如果没有则使用原始链接
|
||||
let imageUrl = getUploadedImageUrl(generation, 0);
|
||||
if (!imageUrl && generation.outputAssets && generation.outputAssets.length > 0) {
|
||||
imageUrl = generation.outputAssets[0].url;
|
||||
}
|
||||
// 优先显示参考图像,如果没有参考图像则显示生成结果图像
|
||||
let imageUrl = null;
|
||||
|
||||
// 首先尝试获取参考图像
|
||||
if (generation.sourceAssets && generation.sourceAssets.length > 0) {
|
||||
// 参考图像在uploadResults中从索引outputAssets.length开始
|
||||
const outputAssetsCount = generation.outputAssets?.length || 0;
|
||||
const uploadResultIndex = outputAssetsCount; // 第一个参考图像
|
||||
imageUrl = getUploadedImageUrl(generation, uploadResultIndex) ||
|
||||
(generation.sourceAssets[0].url ? generation.sourceAssets[0].url : null);
|
||||
}
|
||||
|
||||
// 如果没有参考图像,则使用生成结果图像
|
||||
if (!imageUrl) {
|
||||
imageUrl = getUploadedImageUrl(generation, 0) ||
|
||||
(generation.outputAssets && generation.outputAssets.length > 0 && generation.outputAssets[0].url ? generation.outputAssets[0].url : null);
|
||||
}
|
||||
|
||||
if (imageUrl) {
|
||||
// 创建图像对象以获取尺寸
|
||||
const img = new Image();
|
||||
@@ -719,9 +769,23 @@ export const HistoryPanel: React.FC<{
|
||||
}}
|
||||
>
|
||||
{(() => {
|
||||
// 优先使用上传后的远程链接
|
||||
const imageUrl = getUploadedImageUrl(generation, 0) ||
|
||||
(generation.outputAssets && generation.outputAssets.length > 0 && generation.outputAssets[0].url ? generation.outputAssets[0].url : null);
|
||||
// 优先显示参考图像,如果没有参考图像则显示生成结果图像
|
||||
let imageUrl = null;
|
||||
|
||||
// 首先尝试获取参考图像
|
||||
if (generation.sourceAssets && generation.sourceAssets.length > 0) {
|
||||
// 参考图像在uploadResults中从索引outputAssets.length开始
|
||||
const outputAssetsCount = generation.outputAssets?.length || 0;
|
||||
const uploadResultIndex = outputAssetsCount; // 第一个参考图像
|
||||
imageUrl = getUploadedImageUrl(generation, uploadResultIndex) ||
|
||||
(generation.sourceAssets[0].url ? generation.sourceAssets[0].url : null);
|
||||
}
|
||||
|
||||
// 如果没有参考图像,则使用生成结果图像
|
||||
if (!imageUrl) {
|
||||
imageUrl = getUploadedImageUrl(generation, 0) ||
|
||||
(generation.outputAssets && generation.outputAssets.length > 0 && generation.outputAssets[0].url ? generation.outputAssets[0].url : null);
|
||||
}
|
||||
|
||||
if (imageUrl) {
|
||||
return <img src={imageUrl} alt="生成的变体" className="w-full h-full object-cover" />;
|
||||
@@ -748,12 +812,34 @@ export const HistoryPanel: React.FC<{
|
||||
className="h-8 w-8 bg-white/90 hover:bg-white text-gray-700 rounded-full shadow-md"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
// 下载图像
|
||||
const imageUrl = getUploadedImageUrl(generation, 0) ||
|
||||
(generation.outputAssets && generation.outputAssets.length > 0 && generation.outputAssets[0].url ? generation.outputAssets[0].url : null);
|
||||
// 下载图像 - 优先下载参考图像,如果没有则下载生成结果图像
|
||||
let imageUrl = null;
|
||||
|
||||
// 首先尝试获取参考图像
|
||||
if (generation.sourceAssets && generation.sourceAssets.length > 0) {
|
||||
// 参考图像在uploadResults中从索引outputAssets.length开始
|
||||
const outputAssetsCount = generation.outputAssets?.length || 0;
|
||||
const uploadResultIndex = outputAssetsCount; // 第一个参考图像
|
||||
imageUrl = getUploadedImageUrl(generation, uploadResultIndex) ||
|
||||
(generation.sourceAssets[0].url ? generation.sourceAssets[0].url : null);
|
||||
}
|
||||
|
||||
// 如果没有参考图像,则使用生成结果图像
|
||||
if (!imageUrl) {
|
||||
imageUrl = getUploadedImageUrl(generation, 0) ||
|
||||
(generation.outputAssets && generation.outputAssets.length > 0 && generation.outputAssets[0].url ? generation.outputAssets[0].url : null);
|
||||
}
|
||||
|
||||
if (imageUrl) {
|
||||
// 使用Promise来处理异步操作
|
||||
fetch(imageUrl)
|
||||
// 使用fetch获取图像数据并创建Blob URL以确保正确下载
|
||||
// 添加更多缓存控制头以绕过CDN缓存
|
||||
fetch(imageUrl, {
|
||||
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);
|
||||
@@ -767,6 +853,14 @@ export const HistoryPanel: React.FC<{
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('下载图像失败:', error);
|
||||
// 如果fetch失败,回退到直接使用a标签
|
||||
const link = document.createElement('a');
|
||||
link.href = imageUrl;
|
||||
link.download = `nano-banana-${Date.now()}.png`;
|
||||
link.target = '_blank';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
});
|
||||
}
|
||||
}}
|
||||
@@ -809,7 +903,7 @@ export const HistoryPanel: React.FC<{
|
||||
sortedEdits.some(edit => edit.id === record.id)
|
||||
);
|
||||
|
||||
return paginatedEdits.map((edit, index) => {
|
||||
return paginatedEdits.map((edit: {id: string, sourceAssets?: Array<{url: string}>, outputAssets?: Array<{url: string}>}) => {
|
||||
// 计算全局索引用于显示编号
|
||||
const globalIndex = allRecords.findIndex(record => record.id === edit.id);
|
||||
|
||||
@@ -825,22 +919,55 @@ export const HistoryPanel: React.FC<{
|
||||
onClick={() => {
|
||||
selectEdit(edit.id);
|
||||
selectGeneration(null);
|
||||
// 设置画布图像为第一个输出资产
|
||||
if (edit.outputAssets && edit.outputAssets.length > 0) {
|
||||
// 设置画布图像为参考图像,如果没有参考图像则使用第一个输出资产
|
||||
let imageUrl = null;
|
||||
|
||||
// 首先尝试获取参考图像
|
||||
if (edit.sourceAssets && edit.sourceAssets.length > 0) {
|
||||
// 参考图像在uploadResults中从索引outputAssets.length开始
|
||||
const outputAssetsCount = edit.outputAssets?.length || 0;
|
||||
const uploadResultIndex = outputAssetsCount; // 第一个参考图像
|
||||
const uploadedUrl = getUploadedImageUrl(edit, uploadResultIndex);
|
||||
if (uploadedUrl) {
|
||||
imageUrl = uploadedUrl;
|
||||
} else if (edit.sourceAssets[0].url) {
|
||||
imageUrl = edit.sourceAssets[0].url;
|
||||
}
|
||||
}
|
||||
|
||||
// 如果没有参考图像,则使用编辑结果图像
|
||||
if (!imageUrl && edit.outputAssets && edit.outputAssets.length > 0) {
|
||||
const asset = edit.outputAssets[0];
|
||||
if (asset.url) {
|
||||
setCanvasImage(asset.url);
|
||||
const uploadedUrl = getUploadedImageUrl(edit, 0);
|
||||
imageUrl = uploadedUrl || asset.url;
|
||||
}
|
||||
}
|
||||
|
||||
if (imageUrl) {
|
||||
setCanvasImage(imageUrl);
|
||||
}
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
// 设置当前悬停的记录
|
||||
setHoveredRecord({type: 'edit', id: edit.id});
|
||||
|
||||
// 优先使用上传后的远程链接,如果没有则使用原始链接
|
||||
let imageUrl = getUploadedImageUrl(edit, 0);
|
||||
if (!imageUrl && edit.outputAssets && edit.outputAssets.length > 0) {
|
||||
imageUrl = edit.outputAssets[0].url;
|
||||
// 优先显示参考图像,如果没有参考图像则显示编辑结果图像
|
||||
let imageUrl = null;
|
||||
|
||||
// 首先尝试获取参考图像
|
||||
if (edit.sourceAssets && edit.sourceAssets.length > 0) {
|
||||
// 参考图像在uploadResults中从索引outputAssets.length开始
|
||||
const outputAssetsCount = edit.outputAssets?.length || 0;
|
||||
const uploadResultIndex = outputAssetsCount; // 第一个参考图像
|
||||
imageUrl = getUploadedImageUrl(edit, uploadResultIndex) ||
|
||||
(edit.sourceAssets[0].url ? edit.sourceAssets[0].url : null);
|
||||
}
|
||||
|
||||
// 如果没有参考图像,则使用编辑结果图像
|
||||
if (!imageUrl) {
|
||||
imageUrl = getUploadedImageUrl(edit, 0) ||
|
||||
(edit.outputAssets && edit.outputAssets.length > 0 && edit.outputAssets[0].url ? edit.outputAssets[0].url : null);
|
||||
}
|
||||
|
||||
if (imageUrl) {
|
||||
@@ -889,9 +1016,23 @@ export const HistoryPanel: React.FC<{
|
||||
}}
|
||||
>
|
||||
{(() => {
|
||||
// 优先使用上传后的远程链接
|
||||
const imageUrl = getUploadedImageUrl(edit, 0) ||
|
||||
(edit.outputAssets && edit.outputAssets.length > 0 && edit.outputAssets[0].url ? edit.outputAssets[0].url : null);
|
||||
// 优先显示参考图像,如果没有参考图像则显示编辑结果图像
|
||||
let imageUrl = null;
|
||||
|
||||
// 首先尝试获取参考图像
|
||||
if (edit.sourceAssets && edit.sourceAssets.length > 0) {
|
||||
// 参考图像在uploadResults中从索引outputAssets.length开始
|
||||
const outputAssetsCount = edit.outputAssets?.length || 0;
|
||||
const uploadResultIndex = outputAssetsCount; // 第一个参考图像
|
||||
imageUrl = getUploadedImageUrl(edit, uploadResultIndex) ||
|
||||
(edit.sourceAssets[0].url ? edit.sourceAssets[0].url : null);
|
||||
}
|
||||
|
||||
// 如果没有参考图像,则使用编辑结果图像
|
||||
if (!imageUrl) {
|
||||
imageUrl = getUploadedImageUrl(edit, 0) ||
|
||||
(edit.outputAssets && edit.outputAssets.length > 0 && edit.outputAssets[0].url ? edit.outputAssets[0].url : null);
|
||||
}
|
||||
|
||||
if (imageUrl) {
|
||||
return <img src={imageUrl} alt="编辑的变体" className="w-full h-full object-cover" />;
|
||||
@@ -918,12 +1059,34 @@ export const HistoryPanel: React.FC<{
|
||||
className="h-8 w-8 bg-white/90 hover:bg-white text-gray-700 rounded-full shadow-md"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
// 下载图像
|
||||
const imageUrl = getUploadedImageUrl(edit, 0) ||
|
||||
(edit.outputAssets && edit.outputAssets.length > 0 && edit.outputAssets[0].url ? edit.outputAssets[0].url : null);
|
||||
// 下载图像 - 优先下载参考图像,如果没有则下载编辑结果图像
|
||||
let imageUrl = null;
|
||||
|
||||
// 首先尝试获取参考图像
|
||||
if (edit.sourceAssets && edit.sourceAssets.length > 0) {
|
||||
// 参考图像在uploadResults中从索引outputAssets.length开始
|
||||
const outputAssetsCount = edit.outputAssets?.length || 0;
|
||||
const uploadResultIndex = outputAssetsCount; // 第一个参考图像
|
||||
imageUrl = getUploadedImageUrl(edit, uploadResultIndex) ||
|
||||
(edit.sourceAssets[0].url ? edit.sourceAssets[0].url : null);
|
||||
}
|
||||
|
||||
// 如果没有参考图像,则使用编辑结果图像
|
||||
if (!imageUrl) {
|
||||
imageUrl = getUploadedImageUrl(edit, 0) ||
|
||||
(edit.outputAssets && edit.outputAssets.length > 0 && edit.outputAssets[0].url ? edit.outputAssets[0].url : null);
|
||||
}
|
||||
|
||||
if (imageUrl) {
|
||||
// 使用Promise来处理异步操作
|
||||
fetch(imageUrl)
|
||||
// 使用fetch获取图像数据并创建Blob URL以确保正确下载
|
||||
// 添加更多缓存控制头以绕过CDN缓存
|
||||
fetch(imageUrl, {
|
||||
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);
|
||||
@@ -937,6 +1100,14 @@ export const HistoryPanel: React.FC<{
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('下载图像失败:', error);
|
||||
// 如果fetch失败,回退到直接使用a标签
|
||||
const link = document.createElement('a');
|
||||
link.href = imageUrl;
|
||||
link.download = `nano-banana-${Date.now()}.png`;
|
||||
link.target = '_blank';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
});
|
||||
}
|
||||
}}
|
||||
@@ -1080,7 +1251,7 @@ export const HistoryPanel: React.FC<{
|
||||
{gen.outputAssets.length} 个生成结果
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{gen.outputAssets.slice(0, 4).map((asset: any, index: number) => {
|
||||
{gen.outputAssets.slice(0, 4).map((asset: {id: string, url?: string, blobUrl?: string, width: number, height: number}, index: number) => {
|
||||
// 获取上传后的远程链接(如果存在)
|
||||
const uploadedUrl = gen.uploadResults && gen.uploadResults[index] && gen.uploadResults[index].success
|
||||
? `${gen.uploadResults[index].url}?x-oss-process=image/quality,q_30`
|
||||
@@ -1169,20 +1340,14 @@ export const HistoryPanel: React.FC<{
|
||||
{gen.sourceAssets.length} 个参考图像
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{gen.sourceAssets.slice(0, 4).map((asset: any, index: number) => {
|
||||
// 获取上传后的远程链接(如果存在)
|
||||
// 参考图像在uploadResults中从索引outputAssets.length开始
|
||||
// 但由于gen可能是轻量级记录,我们需要从dbGenerations中获取完整的记录
|
||||
const fullGen = dbGenerations.find(item => item.id === gen.id) || gen;
|
||||
const outputAssetsCount = fullGen.outputAssets?.length || 0;
|
||||
|
||||
// 确保索引在有效范围内
|
||||
const uploadResultIndex = outputAssetsCount + index;
|
||||
const uploadedUrl = fullGen.uploadResults && fullGen.uploadResults[uploadResultIndex] && fullGen.uploadResults[uploadResultIndex].success
|
||||
? `${fullGen.uploadResults[uploadResultIndex].url}?x-oss-process=image/quality,q_30`
|
||||
{gen.sourceAssets.slice(0, 4).map((asset: {id: string, url?: string, blobUrl?: string, width: number, height: number}, index: number) => {
|
||||
// 优先使用上传后的远程链接,如果没有则使用asset中的URL
|
||||
// 参考图像在uploadResults中从索引1开始(图像2字段)
|
||||
const uploadResultIndex = 1 + index;
|
||||
const uploadedUrl = gen.uploadResults && gen.uploadResults[uploadResultIndex] && gen.uploadResults[uploadResultIndex].success
|
||||
? gen.uploadResults[uploadResultIndex].url
|
||||
: null;
|
||||
|
||||
// 如果没有上传的URL,则使用asset中的URL
|
||||
const displayUrl = uploadedUrl || asset.url || asset.blobUrl;
|
||||
|
||||
// 如果URL是blob:开头但已失效,尝试重新创建
|
||||
@@ -1320,10 +1485,10 @@ export const HistoryPanel: React.FC<{
|
||||
{selectedEdit.outputAssets.length} 个编辑结果
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{selectedEdit.outputAssets.slice(0, 4).map((asset: any, index: number) => {
|
||||
{selectedEdit.outputAssets.slice(0, 4).map((asset: {id: string, url?: string, blobUrl?: string, width: number, height: number}, index: number) => {
|
||||
// 获取上传后的远程链接(如果存在)
|
||||
const uploadedUrl = selectedEdit.uploadResults && selectedEdit.uploadResults[index] && selectedEdit.uploadResults[index].success
|
||||
? `${selectedEdit.uploadResults[index].url}?x-oss-process=image/quality,q_30`
|
||||
? selectedEdit.uploadResults[index].url
|
||||
: null;
|
||||
|
||||
// 如果没有上传的URL,则使用asset中的URL
|
||||
@@ -1401,6 +1566,96 @@ export const HistoryPanel: React.FC<{
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 编辑参考图像 */}
|
||||
{selectedEdit.sourceAssets && selectedEdit.sourceAssets.length > 0 && (
|
||||
<div className="pt-2 border-t border-gray-200">
|
||||
<h5 className="text-xs font-medium text-gray-500 mb-2">编辑参考图像</h5>
|
||||
<div className="text-xs text-gray-600 mb-2">
|
||||
{selectedEdit.sourceAssets.length} 个参考图像
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{selectedEdit.sourceAssets.slice(0, 4).map((asset: {id: string, url?: string, blobUrl?: string, width: number, height: number}, index: number) => {
|
||||
// 优先使用上传后的远程链接,如果没有则使用asset中的URL
|
||||
// 参考图像在uploadResults中从索引1开始(图像2字段)
|
||||
const uploadResultIndex = 1 + index;
|
||||
const uploadedUrl = selectedEdit.uploadResults && selectedEdit.uploadResults[uploadResultIndex] && selectedEdit.uploadResults[uploadResultIndex].success
|
||||
? selectedEdit.uploadResults[uploadResultIndex].url
|
||||
: null;
|
||||
|
||||
const displayUrl = uploadedUrl || asset.url || asset.blobUrl;
|
||||
|
||||
// 如果URL是blob:开头但已失效,尝试重新创建
|
||||
if (displayUrl && displayUrl.startsWith('blob:')) {
|
||||
// 检查blob是否仍然有效
|
||||
const img = new Image();
|
||||
img.onerror = () => {
|
||||
// Blob URL可能已失效,尝试重新创建
|
||||
import('../store/useAppStore').then((module) => {
|
||||
const useAppStore = module.useAppStore;
|
||||
const blob = useAppStore.getState().getBlob(displayUrl);
|
||||
if (blob) {
|
||||
const newUrl = URL.createObjectURL(blob);
|
||||
// 更新显示
|
||||
const imgElement = document.querySelector(`img[src="${displayUrl}"]`);
|
||||
if (imgElement) {
|
||||
imgElement.src = newUrl;
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
img.src = displayUrl;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={asset.id}
|
||||
className="relative w-16 h-16 rounded border overflow-hidden cursor-pointer hover:ring-2 hover:ring-yellow-400"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setPreviewModal({
|
||||
open: true,
|
||||
imageUrl: displayUrl,
|
||||
title: `编辑参考图像 ${index + 1}`,
|
||||
description: `${asset.width} × ${asset.height}`
|
||||
});
|
||||
}}
|
||||
>
|
||||
{displayUrl ? (
|
||||
<img
|
||||
src={displayUrl}
|
||||
alt={`编辑参考图像 ${index + 1}`}
|
||||
className="w-full h-full object-cover"
|
||||
onError={(e) => {
|
||||
// 如果图像加载失败,尝试重新创建Blob URL
|
||||
if (displayUrl.startsWith('blob:')) {
|
||||
import('../store/useAppStore').then((module) => {
|
||||
const useAppStore = module.useAppStore;
|
||||
const blob = useAppStore.getState().getBlob(displayUrl);
|
||||
if (blob) {
|
||||
const newUrl = URL.createObjectURL(blob);
|
||||
(e.target as HTMLImageElement).src = newUrl;
|
||||
}
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full bg-gray-100 flex items-center justify-center">
|
||||
<ImageIcon className="h-4 w-4 text-gray-400" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{selectedEdit.sourceAssets.length > 4 && (
|
||||
<div className="w-16 h-16 rounded border flex items-center justify-center bg-gray-100 text-xs text-gray-500">
|
||||
+{selectedEdit.sourceAssets.length - 4}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 原始生成参考 */}
|
||||
{parentGen && (
|
||||
<div className="pt-2 border-t border-gray-200">
|
||||
@@ -1415,18 +1670,14 @@ export const HistoryPanel: React.FC<{
|
||||
原始参考图像:
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{parentGen.sourceAssets.slice(0, 4).map((asset: any, index: number) => {
|
||||
// 获取上传后的远程链接(如果存在)
|
||||
// 参考图像在uploadResults中从索引outputAssets.length开始
|
||||
const outputAssetsCount = parentGen.outputAssets?.length || 0;
|
||||
|
||||
// 确保索引在有效范围内
|
||||
const uploadResultIndex = outputAssetsCount + index;
|
||||
{parentGen.sourceAssets.slice(0, 4).map((asset: {id: string, url?: string, blobUrl?: string, width: number, height: number}, index: number) => {
|
||||
// 优先使用上传后的远程链接,如果没有则使用asset中的URL
|
||||
// 参考图像在uploadResults中从索引1开始(图像2字段)
|
||||
const uploadResultIndex = 1 + index;
|
||||
const uploadedUrl = parentGen.uploadResults && parentGen.uploadResults[uploadResultIndex] && parentGen.uploadResults[uploadResultIndex].success
|
||||
? `${parentGen.uploadResults[uploadResultIndex].url}?x-oss-process=image/quality,q_30`
|
||||
? parentGen.uploadResults[uploadResultIndex].url
|
||||
: null;
|
||||
|
||||
// 如果没有上传的URL,则使用asset中的URL
|
||||
const displayUrl = uploadedUrl || asset.url || asset.blobUrl;
|
||||
|
||||
// 如果URL是blob:开头但已失效,尝试重新创建
|
||||
|
||||
@@ -2,25 +2,19 @@ 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,
|
||||
setCanvasPan,
|
||||
brushStrokes,
|
||||
addBrushStroke,
|
||||
clearBrushStrokes,
|
||||
showMasks,
|
||||
setShowMasks,
|
||||
selectedTool,
|
||||
isGenerating,
|
||||
brushSize,
|
||||
setBrushSize,
|
||||
showHistory,
|
||||
showPromptPanel
|
||||
} = useAppStore();
|
||||
@@ -50,7 +44,7 @@ export const ImageCanvas: React.FC = () => {
|
||||
}
|
||||
}, 0);
|
||||
}
|
||||
}, []);
|
||||
}, [setCanvasZoom]);
|
||||
|
||||
// 加载图像
|
||||
useEffect(() => {
|
||||
@@ -60,7 +54,7 @@ export const ImageCanvas: React.FC = () => {
|
||||
console.log('开始加载图像,URL:', canvasImage);
|
||||
|
||||
img = new window.Image();
|
||||
let isCancelled = false;
|
||||
const isCancelled = false;
|
||||
|
||||
img.onload = () => {
|
||||
// 检查是否已取消
|
||||
@@ -195,7 +189,7 @@ export const ImageCanvas: React.FC = () => {
|
||||
image.src = '';
|
||||
}
|
||||
};
|
||||
}, [canvasImage]); // 只依赖canvasImage,避免其他依赖引起循环
|
||||
}, [canvasImage, image, setCanvasZoom, stageSize.height, stageSize.width]); // 添加所有依赖项
|
||||
|
||||
// 处理舞台大小调整
|
||||
useEffect(() => {
|
||||
@@ -212,7 +206,7 @@ export const ImageCanvas: React.FC = () => {
|
||||
updateSize();
|
||||
window.addEventListener('resize', updateSize);
|
||||
return () => window.removeEventListener('resize', updateSize);
|
||||
}, []);
|
||||
}, [showPromptPanel, showHistory]);
|
||||
|
||||
// 监听面板状态变化以调整画布大小
|
||||
useEffect(() => {
|
||||
@@ -243,14 +237,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();
|
||||
@@ -269,11 +262,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();
|
||||
@@ -351,17 +343,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;
|
||||
@@ -369,64 +361,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;
|
||||
}
|
||||
}
|
||||
@@ -463,14 +412,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;
|
||||
@@ -478,68 +430,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;
|
||||
@@ -547,59 +463,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);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -647,7 +523,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,101 @@
|
||||
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;
|
||||
}> = ({ image, index, onRemove }) => {
|
||||
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="relative">
|
||||
<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 {
|
||||
@@ -32,8 +120,7 @@ export const PromptComposer: React.FC = () => {
|
||||
setCanvasImage,
|
||||
showPromptPanel,
|
||||
setShowPromptPanel,
|
||||
clearBrushStrokes,
|
||||
addBlob
|
||||
clearBrushStrokes
|
||||
} = useAppStore();
|
||||
|
||||
const { generate, cancelGeneration } = useImageGeneration();
|
||||
@@ -45,12 +132,27 @@ export const PromptComposer: React.FC = () => {
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
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,6 +165,23 @@ 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();
|
||||
@@ -75,17 +194,9 @@ export const PromptComposer: React.FC = () => {
|
||||
const response = await fetch(img);
|
||||
if (response.ok) {
|
||||
const blob = await response.blob();
|
||||
// 重新添加到AppStore
|
||||
const newUrl = useAppStore.getState().addBlob(blob);
|
||||
referenceImageBlobs.push(blob);
|
||||
// 更新uploadedImages中的URL
|
||||
const index = uploadedImages.indexOf(img);
|
||||
if (index !== -1) {
|
||||
const newImages = [...uploadedImages];
|
||||
newImages[index] = newUrl;
|
||||
useAppStore.getState().clearUploadedImages();
|
||||
newImages.forEach(imageUrl => useAppStore.getState().addUploadedImage(imageUrl));
|
||||
}
|
||||
} else {
|
||||
console.warn('无法重新获取参考图像:', img);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('无法重新获取参考图像:', img, error);
|
||||
@@ -102,9 +213,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
|
||||
});
|
||||
@@ -116,28 +231,36 @@ 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}`;
|
||||
|
||||
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);
|
||||
}
|
||||
// 如果没有画布图像,则设置为画布图像
|
||||
if (!canvasImage) {
|
||||
setCanvasImage(blobUrl);
|
||||
setCanvasImage(imageUrl);
|
||||
}
|
||||
} else if (selectedTool === 'mask') {
|
||||
// 遮罩模式下,立即设置为画布图像
|
||||
clearUploadedImages();
|
||||
addUploadedImage(blobUrl);
|
||||
setCanvasImage(blobUrl);
|
||||
// 遮罩模式下,将图像添加为参考图像而不是清除现有图像
|
||||
// 只有在没有画布图像时才设置为画布图像
|
||||
if (!canvasImage) {
|
||||
setCanvasImage(imageUrl);
|
||||
}
|
||||
// 不清除现有的上传图像,而是将新图像添加为参考图像(如果还有空间)
|
||||
if (uploadedImages.length < 2) {
|
||||
addUploadedImage(imageUrl);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('上传图像失败:', error);
|
||||
@@ -171,7 +294,7 @@ export const PromptComposer: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleClearSession = () => {
|
||||
const handleClearSession = async () => {
|
||||
setCurrentPrompt('');
|
||||
clearUploadedImages();
|
||||
clearEditReferenceImages();
|
||||
@@ -180,6 +303,14 @@ 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);
|
||||
}
|
||||
};
|
||||
|
||||
const tools = [
|
||||
@@ -324,25 +455,13 @@ 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)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
@@ -423,7 +542,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}
|
||||
@@ -493,7 +612,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));
|
||||
}
|
||||
});
|
||||
@@ -209,11 +222,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 +256,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,7 +298,10 @@ 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);
|
||||
@@ -284,24 +316,15 @@ export const useImageEditing = () => {
|
||||
referenceImageBlobs.push(blob);
|
||||
// 重新添加到AppStore
|
||||
const newUrl = useAppStore.getState().addBlob(blob);
|
||||
// 更新editReferenceImages中的URL
|
||||
const index = editReferenceImages.indexOf(img);
|
||||
if (index !== -1) {
|
||||
const { removeEditReferenceImage, addEditReferenceImage } = useAppStore.getState();
|
||||
removeEditReferenceImage(index);
|
||||
// 重新添加所有图像以保持顺序
|
||||
const currentImages = [...editReferenceImages];
|
||||
currentImages[index] = newUrl;
|
||||
// 清空并重新添加
|
||||
for (let i = 0; i < currentImages.length; i++) {
|
||||
if (i < 2) { // 最多2张参考图像
|
||||
addEditReferenceImage(currentImages[i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
// 更新editReferenceImages中的URL(但不立即修改状态)
|
||||
updatedReferenceImageUrls[i] = newUrl;
|
||||
} else {
|
||||
// 即使无法重新获取,也要保留原始URL并在下次尝试时重新获取
|
||||
console.warn('无法重新获取参考图像,将在下次尝试时重新获取:', img);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('无法重新获取参考图像:', img, error);
|
||||
} catch {
|
||||
// 即使出现错误,也要保留原始URL并在下次尝试时重新获取
|
||||
console.warn('无法重新获取参考图像,将在下次尝试时重新获取:', img, error);
|
||||
}
|
||||
}
|
||||
} else if (img.includes('base64,')) {
|
||||
@@ -311,8 +334,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 {
|
||||
@@ -321,11 +344,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;
|
||||
@@ -426,6 +466,7 @@ export const useImageEditing = () => {
|
||||
maskImage: maskImageBlob,
|
||||
temperature,
|
||||
seed,
|
||||
abortSignal: abortControllerRef.current.signal
|
||||
}
|
||||
|
||||
const result = await geminiService.editImage(request)
|
||||
@@ -444,7 +485,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);
|
||||
|
||||
@@ -458,7 +499,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));
|
||||
}
|
||||
});
|
||||
@@ -499,7 +540,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));
|
||||
}
|
||||
});
|
||||
@@ -517,7 +558,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) {
|
||||
@@ -527,7 +568,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) => {
|
||||
@@ -552,7 +593,7 @@ export const useImageEditing = () => {
|
||||
console.log(`${uploadResults.length}张编辑后的图像全部上传成功`);
|
||||
addToast('编辑后的图像已成功上传', 'success', 3000);
|
||||
}
|
||||
} catch (error) {
|
||||
} catch {
|
||||
console.error('上传编辑后的图像时出错:', error);
|
||||
addToast('编辑后的图像上传失败', 'error', 5000);
|
||||
uploadResults = undefined;
|
||||
@@ -566,12 +607,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,
|
||||
@@ -598,11 +671,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 {
|
||||
@@ -135,7 +136,7 @@ interface AppState {
|
||||
}
|
||||
|
||||
// 限制历史记录数量
|
||||
const MAX_HISTORY_ITEMS = 50;
|
||||
const MAX_HISTORY_ITEMS = 1000;
|
||||
|
||||
export const useAppStore = create<AppState>()(
|
||||
devtools(
|
||||
@@ -179,18 +180,64 @@ 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)
|
||||
};
|
||||
}),
|
||||
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]
|
||||
@@ -342,43 +389,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
|
||||
@@ -472,32 +494,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);
|
||||
@@ -528,56 +527,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);
|
||||
@@ -594,110 +557,35 @@ export const useAppStore = create<AppState>()(
|
||||
}),
|
||||
|
||||
// 释放指定的Blob URLs
|
||||
revokeBlobUrls: (urls: string[]) => set((state) => {
|
||||
urls.forEach(url => {
|
||||
if (state.blobStore.has(url)) {
|
||||
URL.revokeObjectURL(url);
|
||||
const newBlobStore = new Map(state.blobStore);
|
||||
newBlobStore.delete(url);
|
||||
state = { ...state, blobStore: newBlobStore };
|
||||
}
|
||||
});
|
||||
revokeBlobUrls: () => set((state) => {
|
||||
// 不再自动清理Blob URL,以确保参考图像不会被意外删除
|
||||
// 只有在用户明确请求清除会话时才清理
|
||||
console.log('Blob清理已禁用,参考图像将被永久保留');
|
||||
return state;
|
||||
}),
|
||||
|
||||
// 释放所有Blob URLs
|
||||
cleanupAllBlobUrls: () => set((state) => {
|
||||
state.blobStore.forEach((_, url) => {
|
||||
URL.revokeObjectURL(url);
|
||||
});
|
||||
return { ...state, blobStore: new Map() };
|
||||
// 不再自动清理Blob URL,以确保参考图像不会被意外删除
|
||||
// 只有在用户明确请求清除会话时才清理
|
||||
console.log('Blob清理已禁用,参考图像将被永久保留');
|
||||
return state;
|
||||
}),
|
||||
|
||||
// 定期清理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);
|
||||
|
||||
// 检查是否是历史记录中的参考图像
|
||||
const isUsedAsReference = state.currentProject && (
|
||||
state.currentProject.generations.some(gen =>
|
||||
gen.sourceAssets.some(asset => asset.blobUrl === url)
|
||||
) ||
|
||||
state.currentProject.edits.some(edit =>
|
||||
(edit.maskReferenceAssetBlobUrl === url)
|
||||
)
|
||||
);
|
||||
|
||||
// 检查是否是当前编辑操作中的参考图像
|
||||
const isUsedInCurrentEdit = state.editReferenceImages.includes(url);
|
||||
|
||||
// 检查是否是当前生成操作中的参考图像
|
||||
const isUsedInCurrentGeneration = state.uploadedImages.includes(url);
|
||||
|
||||
// 如果Blob没有被使用,则清理它
|
||||
// 但保留仍在作为参考图像使用的Blob
|
||||
if (!isUsedInProject && !isUsedInCanvas && !isUsedInUploads && !isUsedInEdits && !isUsedAsReference && !isUsedInCurrentEdit && !isUsedInCurrentGeneration) {
|
||||
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 = {
|
||||
@@ -715,34 +603,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