You've already forked Nano-Banana-AI-Image-Editor
新增 生成过程中可以中断;
新增 生成结果上传到OSS; 新增 历史记录使用上传后的图片;
This commit is contained in:
8
.env.example
Normal file
8
.env.example
Normal file
@@ -0,0 +1,8 @@
|
||||
# 访问令牌
|
||||
VITE_ACCESS_TOKEN=your_access_token_here
|
||||
|
||||
# Gemini API密钥
|
||||
VITE_GEMINI_API_KEY=your_gemini_api_key_here
|
||||
|
||||
# 远程资源路径
|
||||
VITE_UPLOAD_ASSET_URL=''
|
||||
@@ -39,6 +39,18 @@ export const HistoryPanel: React.FC = () => {
|
||||
const generations = currentProject?.generations || [];
|
||||
const edits = currentProject?.edits || [];
|
||||
|
||||
// 获取上传后的图片链接
|
||||
const getUploadedImageUrl = (generationOrEdit: any, index: number) => {
|
||||
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_50`;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// 获取当前图像尺寸
|
||||
const [imageDimensions, setImageDimensions] = React.useState<{ width: number; height: number } | null>(null);
|
||||
|
||||
@@ -184,6 +196,13 @@ export const HistoryPanel: React.FC = () => {
|
||||
>
|
||||
{generation.outputAssetsBlobUrls && generation.outputAssetsBlobUrls.length > 0 ? (
|
||||
(() => {
|
||||
// 首先尝试使用上传后的图片链接
|
||||
const uploadedUrl = getUploadedImageUrl(generation, 0);
|
||||
if (uploadedUrl) {
|
||||
return <img src={uploadedUrl} alt="生成的变体" className="w-full h-full object-cover" />;
|
||||
}
|
||||
|
||||
// 如果没有上传链接,则使用原来的Blob URL
|
||||
const blobUrl = generation.outputAssetsBlobUrls[0];
|
||||
const decodedUrl = decodedImages[blobUrl];
|
||||
if (decodedUrl) {
|
||||
@@ -239,6 +258,13 @@ export const HistoryPanel: React.FC = () => {
|
||||
>
|
||||
{edit.outputAssetsBlobUrls && edit.outputAssetsBlobUrls.length > 0 ? (
|
||||
(() => {
|
||||
// 首先尝试使用上传后的图片链接
|
||||
const uploadedUrl = getUploadedImageUrl(edit, 0);
|
||||
if (uploadedUrl) {
|
||||
return <img src={uploadedUrl} alt="编辑的变体" className="w-full h-full object-cover" />;
|
||||
}
|
||||
|
||||
// 如果没有上传链接,则使用原来的Blob URL
|
||||
const blobUrl = edit.outputAssetsBlobUrls[0];
|
||||
const decodedUrl = decodedImages[blobUrl];
|
||||
if (decodedUrl) {
|
||||
@@ -319,6 +345,35 @@ export const HistoryPanel: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 上传结果 */}
|
||||
{gen.uploadResults && gen.uploadResults.length > 0 && (
|
||||
<div>
|
||||
<h5 className="text-xs font-medium text-gray-500 mb-2">上传结果</h5>
|
||||
<div className="space-y-1">
|
||||
{gen.uploadResults.map((result, index) => (
|
||||
<div key={index} className="text-xs">
|
||||
<div className="flex justify-between">
|
||||
<span>图像 {index + 1}:</span>
|
||||
<span className={result.success ? 'text-green-600' : 'text-red-600'}>
|
||||
{result.success ? '成功' : '失败'}
|
||||
</span>
|
||||
</div>
|
||||
{result.success && result.url && (
|
||||
<div className="text-blue-600 truncate">
|
||||
{result.url.split('/').pop()}
|
||||
</div>
|
||||
)}
|
||||
{result.error && (
|
||||
<div className="text-red-600 truncate">
|
||||
{result.error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 参考图像信息 */}
|
||||
{gen.sourceAssets.length > 0 && (
|
||||
<div>
|
||||
@@ -355,6 +410,35 @@ export const HistoryPanel: React.FC = () => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 上传结果 */}
|
||||
{selectedEdit.uploadResults && selectedEdit.uploadResults.length > 0 && (
|
||||
<div>
|
||||
<h5 className="text-xs font-medium text-gray-500 mb-2">上传结果</h5>
|
||||
<div className="space-y-1">
|
||||
{selectedEdit.uploadResults.map((result, index) => (
|
||||
<div key={index} className="text-xs">
|
||||
<div className="flex justify-between">
|
||||
<span>图像 {index + 1}:</span>
|
||||
<span className={result.success ? 'text-green-600' : 'text-red-600'}>
|
||||
{result.success ? '成功' : '失败'}
|
||||
</span>
|
||||
</div>
|
||||
{result.success && result.url && (
|
||||
<div className="text-blue-600 truncate">
|
||||
{result.url.split('/').pop()}
|
||||
</div>
|
||||
)}
|
||||
{result.error && (
|
||||
<div className="text-red-600 truncate">
|
||||
{result.error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 原始生成参考 */}
|
||||
{parentGen && (
|
||||
<div>
|
||||
|
||||
@@ -34,8 +34,8 @@ export const PromptComposer: React.FC = () => {
|
||||
clearBrushStrokes,
|
||||
} = useAppStore();
|
||||
|
||||
const { generate } = useImageGeneration();
|
||||
const { edit } = useImageEditing();
|
||||
const { generate, cancelGeneration } = useImageGeneration();
|
||||
const { edit, cancelEdit } = useImageEditing();
|
||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||
const [showClearConfirm, setShowClearConfirm] = useState(false);
|
||||
const [showHintsModal, setShowHintsModal] = useState(false);
|
||||
@@ -327,23 +327,26 @@ export const PromptComposer: React.FC = () => {
|
||||
|
||||
|
||||
{/* 生成按钮 */}
|
||||
{isGenerating ? (
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={() => selectedTool === 'generate' ? cancelGeneration() : cancelEdit()}
|
||||
className="flex-1 h-14 text-base font-medium bg-red-500 hover:bg-red-600"
|
||||
>
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-gray-900 mr-2" />
|
||||
中断
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
onClick={handleGenerate}
|
||||
disabled={isGenerating || !currentPrompt.trim()}
|
||||
disabled={!currentPrompt.trim()}
|
||||
className="w-full h-14 text-base font-medium"
|
||||
>
|
||||
{isGenerating ? (
|
||||
<>
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-gray-900 mr-2" />
|
||||
生成中...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Wand2 className="h-4 w-4 mr-2" />
|
||||
{selectedTool === 'generate' ? '生成' : '应用编辑'}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* 高级控制 */}
|
||||
<div>
|
||||
|
||||
@@ -1,23 +1,37 @@
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { geminiService, GenerationRequest, EditRequest } from '../services/geminiService';
|
||||
import { useAppStore } from '../store/useAppStore';
|
||||
import { generateId } from '../utils/imageUtils';
|
||||
import { Generation, Edit, Asset } from '../types';
|
||||
import { useToast } from '../components/ToastContext';
|
||||
import React from 'react'
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { geminiService, GenerationRequest, EditRequest } from '../services/geminiService'
|
||||
import { useAppStore } from '../store/useAppStore'
|
||||
import { generateId } from '../utils/imageUtils'
|
||||
import { Generation, Edit, Asset } from '../types'
|
||||
import { useToast } from '../components/ToastContext'
|
||||
import { uploadImages } from '../services/uploadService'
|
||||
|
||||
export const useImageGeneration = () => {
|
||||
const { addGeneration, setIsGenerating, setCanvasImage } = useAppStore();
|
||||
const { addToast } = useToast();
|
||||
const { addGeneration, setIsGenerating, setCanvasImage } = useAppStore()
|
||||
const { addToast } = useToast()
|
||||
|
||||
// 创建中断标志引用
|
||||
const isCancelledRef = React.useRef(false)
|
||||
|
||||
const generateMutation = useMutation({
|
||||
mutationFn: async (request: GenerationRequest) => {
|
||||
const images = await geminiService.generateImage(request);
|
||||
return images;
|
||||
// 重置中断标志
|
||||
isCancelledRef.current = false
|
||||
|
||||
const images = await geminiService.generateImage(request)
|
||||
|
||||
// 检查是否已中断
|
||||
if (isCancelledRef.current) {
|
||||
throw new Error('生成已中断')
|
||||
}
|
||||
|
||||
return images
|
||||
},
|
||||
onMutate: () => {
|
||||
setIsGenerating(true);
|
||||
setIsGenerating(true)
|
||||
},
|
||||
onSuccess: (images, request) => {
|
||||
onSuccess: async (images, request) => {
|
||||
if (images.length > 0) {
|
||||
const outputAssets: Asset[] = images.map((base64, index) => ({
|
||||
id: generateId(),
|
||||
@@ -29,6 +43,34 @@ export const useImageGeneration = () => {
|
||||
checksum: base64.slice(0, 32) // 简单校验和
|
||||
}));
|
||||
|
||||
// 获取accessToken
|
||||
const accessToken = import.meta.env.VITE_ACCESS_TOKEN || '';
|
||||
let uploadResults: any[] | undefined;
|
||||
|
||||
// 上传生成的图像
|
||||
if (accessToken) {
|
||||
try {
|
||||
const imageUrls = outputAssets.map(asset => asset.url);
|
||||
uploadResults = await uploadImages(imageUrls, accessToken);
|
||||
|
||||
// 检查上传结果
|
||||
const failedUploads = uploadResults.filter(r => !r.success);
|
||||
if (failedUploads.length > 0) {
|
||||
console.warn(`${failedUploads.length}张图像上传失败`);
|
||||
addToast(`${failedUploads.length}张图像上传失败`, 'warning', 5000);
|
||||
} else {
|
||||
console.log(`${uploadResults.length}张图像全部上传成功`);
|
||||
addToast('图像已成功上传', 'success', 3000);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('上传图像时出错:', error);
|
||||
addToast('图像上传失败', 'error', 5000);
|
||||
uploadResults = undefined;
|
||||
}
|
||||
} else {
|
||||
console.warn('未找到accessToken,跳过上传');
|
||||
}
|
||||
|
||||
const generation: Generation = {
|
||||
id: generateId(),
|
||||
prompt: request.prompt,
|
||||
@@ -48,7 +90,8 @@ export const useImageGeneration = () => {
|
||||
})) : [],
|
||||
outputAssets,
|
||||
modelVersion: 'gemini-2.5-flash-image-preview',
|
||||
timestamp: Date.now()
|
||||
timestamp: Date.now(),
|
||||
uploadResults: uploadResults
|
||||
};
|
||||
|
||||
addGeneration(generation);
|
||||
@@ -56,136 +99,134 @@ export const useImageGeneration = () => {
|
||||
}
|
||||
setIsGenerating(false);
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('生成失败:', error);
|
||||
const errorMessage = error instanceof Error ? error.message : '未知错误';
|
||||
const errorDetails = error instanceof Error ? error.stack : undefined;
|
||||
addToast(`图像生成失败: ${errorMessage}`, 'error', 5000, errorDetails);
|
||||
setIsGenerating(false);
|
||||
onError: error => {
|
||||
console.error('生成失败:', error)
|
||||
const errorMessage = error instanceof Error ? error.message : '未知错误'
|
||||
const errorDetails = error instanceof Error ? error.stack : undefined
|
||||
addToast(`图像生成失败: ${errorMessage}`, 'error', 5000, errorDetails)
|
||||
setIsGenerating(false)
|
||||
},
|
||||
})
|
||||
|
||||
const cancelGeneration = () => {
|
||||
isCancelledRef.current = true
|
||||
setIsGenerating(false)
|
||||
addToast('生成已中断', 'info', 3000)
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
generate: generateMutation.mutate,
|
||||
isGenerating: generateMutation.isPending,
|
||||
error: generateMutation.error
|
||||
};
|
||||
};
|
||||
error: generateMutation.error,
|
||||
cancelGeneration,
|
||||
}
|
||||
}
|
||||
|
||||
export const useImageEditing = () => {
|
||||
const {
|
||||
addEdit,
|
||||
setIsGenerating,
|
||||
setCanvasImage,
|
||||
canvasImage,
|
||||
editReferenceImages,
|
||||
brushStrokes,
|
||||
selectedGenerationId,
|
||||
seed,
|
||||
temperature,
|
||||
uploadedImages
|
||||
} = useAppStore();
|
||||
const { addEdit, setIsGenerating, setCanvasImage, canvasImage, editReferenceImages, brushStrokes, selectedGenerationId, seed, temperature, uploadedImages } = useAppStore()
|
||||
|
||||
const { addToast } = useToast();
|
||||
const { addToast } = useToast()
|
||||
|
||||
// 创建中断标志引用
|
||||
const isCancelledRef = React.useRef(false)
|
||||
|
||||
const editMutation = useMutation({
|
||||
mutationFn: async (instruction: string) => {
|
||||
// 重置中断标志
|
||||
isCancelledRef.current = false
|
||||
|
||||
// 如果可用,始终使用画布图像作为主要目标,否则使用第一张上传的图像
|
||||
const sourceImage = canvasImage || uploadedImages[0];
|
||||
if (!sourceImage) throw new Error('没有要编辑的图像');
|
||||
const sourceImage = canvasImage || uploadedImages[0]
|
||||
if (!sourceImage) throw new Error('没有要编辑的图像')
|
||||
|
||||
// 将画布图像转换为base64
|
||||
const base64Image = sourceImage.includes('base64,')
|
||||
? sourceImage.split('base64,')[1]
|
||||
: sourceImage;
|
||||
const base64Image = sourceImage.includes('base64,') ? sourceImage.split('base64,')[1] : sourceImage
|
||||
|
||||
// 获取用于样式指导的参考图像
|
||||
let referenceImages = editReferenceImages
|
||||
.filter(img => img.includes('base64,'))
|
||||
.map(img => img.split('base64,')[1]);
|
||||
let referenceImages = editReferenceImages.filter(img => img.includes('base64,')).map(img => img.split('base64,')[1])
|
||||
|
||||
let maskImage: string | undefined;
|
||||
let maskedReferenceImage: string | undefined;
|
||||
let maskImage: string | undefined
|
||||
let maskedReferenceImage: string | undefined
|
||||
|
||||
// 如果存在画笔描边,则从描边创建遮罩
|
||||
if (brushStrokes.length > 0) {
|
||||
// 创建临时图像以获取实际尺寸
|
||||
const tempImg = new Image();
|
||||
tempImg.src = sourceImage;
|
||||
await new Promise<void>((resolve) => {
|
||||
tempImg.onload = () => resolve();
|
||||
});
|
||||
const tempImg = new Image()
|
||||
tempImg.src = sourceImage
|
||||
await new Promise<void>(resolve => {
|
||||
tempImg.onload = () => resolve()
|
||||
})
|
||||
|
||||
// 创建具有确切图像尺寸的遮罩画布
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d')!;
|
||||
canvas.width = tempImg.width;
|
||||
canvas.height = tempImg.height;
|
||||
const canvas = document.createElement('canvas')
|
||||
const ctx = canvas.getContext('2d')!
|
||||
canvas.width = tempImg.width
|
||||
canvas.height = tempImg.height
|
||||
|
||||
// 用黑色填充(未遮罩区域)
|
||||
ctx.fillStyle = 'black';
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
ctx.fillStyle = 'black'
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height)
|
||||
|
||||
// 绘制白色描边(遮罩区域)
|
||||
ctx.strokeStyle = 'white';
|
||||
ctx.lineCap = 'round';
|
||||
ctx.lineJoin = 'round';
|
||||
ctx.strokeStyle = 'white'
|
||||
ctx.lineCap = 'round'
|
||||
ctx.lineJoin = 'round'
|
||||
|
||||
brushStrokes.forEach(stroke => {
|
||||
if (stroke.points.length >= 4) {
|
||||
ctx.lineWidth = stroke.brushSize;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(stroke.points[0], stroke.points[1]);
|
||||
ctx.lineWidth = stroke.brushSize
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(stroke.points[0], stroke.points[1])
|
||||
|
||||
for (let i = 2; i < stroke.points.length; i += 2) {
|
||||
ctx.lineTo(stroke.points[i], stroke.points[i + 1]);
|
||||
ctx.lineTo(stroke.points[i], stroke.points[i + 1])
|
||||
}
|
||||
ctx.stroke();
|
||||
ctx.stroke()
|
||||
}
|
||||
});
|
||||
})
|
||||
|
||||
// 将遮罩转换为base64
|
||||
const maskDataUrl = canvas.toDataURL('image/png');
|
||||
maskImage = maskDataUrl.split('base64,')[1];
|
||||
const maskDataUrl = canvas.toDataURL('image/png')
|
||||
maskImage = maskDataUrl.split('base64,')[1]
|
||||
|
||||
// 创建遮罩参考图像(带遮罩叠加的原始图像)
|
||||
const maskedCanvas = document.createElement('canvas');
|
||||
const maskedCtx = maskedCanvas.getContext('2d')!;
|
||||
maskedCanvas.width = tempImg.width;
|
||||
maskedCanvas.height = tempImg.height;
|
||||
const maskedCanvas = document.createElement('canvas')
|
||||
const maskedCtx = maskedCanvas.getContext('2d')!
|
||||
maskedCanvas.width = tempImg.width
|
||||
maskedCanvas.height = tempImg.height
|
||||
|
||||
// 绘制原始图像
|
||||
maskedCtx.drawImage(tempImg, 0, 0);
|
||||
maskedCtx.drawImage(tempImg, 0, 0)
|
||||
|
||||
// 绘制带透明度的遮罩叠加
|
||||
maskedCtx.globalCompositeOperation = 'source-over';
|
||||
maskedCtx.globalAlpha = 0.4;
|
||||
maskedCtx.fillStyle = '#A855F7';
|
||||
maskedCtx.globalCompositeOperation = 'source-over'
|
||||
maskedCtx.globalAlpha = 0.4
|
||||
maskedCtx.fillStyle = '#A855F7'
|
||||
|
||||
brushStrokes.forEach(stroke => {
|
||||
if (stroke.points.length >= 4) {
|
||||
maskedCtx.lineWidth = stroke.brushSize;
|
||||
maskedCtx.strokeStyle = '#A855F7';
|
||||
maskedCtx.lineCap = 'round';
|
||||
maskedCtx.lineJoin = 'round';
|
||||
maskedCtx.beginPath();
|
||||
maskedCtx.moveTo(stroke.points[0], stroke.points[1]);
|
||||
maskedCtx.lineWidth = stroke.brushSize
|
||||
maskedCtx.strokeStyle = '#A855F7'
|
||||
maskedCtx.lineCap = 'round'
|
||||
maskedCtx.lineJoin = 'round'
|
||||
maskedCtx.beginPath()
|
||||
maskedCtx.moveTo(stroke.points[0], stroke.points[1])
|
||||
|
||||
for (let i = 2; i < stroke.points.length; i += 2) {
|
||||
maskedCtx.lineTo(stroke.points[i], stroke.points[i + 1]);
|
||||
maskedCtx.lineTo(stroke.points[i], stroke.points[i + 1])
|
||||
}
|
||||
maskedCtx.stroke();
|
||||
maskedCtx.stroke()
|
||||
}
|
||||
});
|
||||
})
|
||||
|
||||
maskedCtx.globalAlpha = 1;
|
||||
maskedCtx.globalCompositeOperation = 'source-over';
|
||||
maskedCtx.globalAlpha = 1
|
||||
maskedCtx.globalCompositeOperation = 'source-over'
|
||||
|
||||
const maskedDataUrl = maskedCanvas.toDataURL('image/png');
|
||||
maskedReferenceImage = maskedDataUrl.split('base64,')[1];
|
||||
const maskedDataUrl = maskedCanvas.toDataURL('image/png')
|
||||
maskedReferenceImage = maskedDataUrl.split('base64,')[1]
|
||||
|
||||
// 将遮罩图像作为参考添加到模型中
|
||||
referenceImages = [maskedReferenceImage, ...referenceImages];
|
||||
referenceImages = [maskedReferenceImage, ...referenceImages]
|
||||
}
|
||||
|
||||
const request: EditRequest = {
|
||||
@@ -194,16 +235,22 @@ export const useImageEditing = () => {
|
||||
referenceImages: referenceImages.length > 0 ? referenceImages : undefined,
|
||||
maskImage,
|
||||
temperature,
|
||||
seed
|
||||
};
|
||||
seed,
|
||||
}
|
||||
|
||||
const images = await geminiService.editImage(request);
|
||||
return { images, maskedReferenceImage };
|
||||
const images = await geminiService.editImage(request)
|
||||
|
||||
// 检查是否已中断
|
||||
if (isCancelledRef.current) {
|
||||
throw new Error('编辑已中断')
|
||||
}
|
||||
|
||||
return { images, maskedReferenceImage }
|
||||
},
|
||||
onMutate: () => {
|
||||
setIsGenerating(true);
|
||||
setIsGenerating(true)
|
||||
},
|
||||
onSuccess: ({ images, maskedReferenceImage }, instruction) => {
|
||||
onSuccess: async ({ images, maskedReferenceImage }, instruction) => {
|
||||
if (images.length > 0) {
|
||||
const outputAssets: Asset[] = images.map((base64, index) => ({
|
||||
id: generateId(),
|
||||
@@ -226,6 +273,34 @@ export const useImageEditing = () => {
|
||||
checksum: maskedReferenceImage.slice(0, 32)
|
||||
} : undefined;
|
||||
|
||||
// 获取accessToken
|
||||
const accessToken = import.meta.env.VITE_ACCESS_TOKEN || '';
|
||||
let uploadResults: any[] | undefined;
|
||||
|
||||
// 上传编辑后的图像
|
||||
if (accessToken) {
|
||||
try {
|
||||
const imageUrls = outputAssets.map(asset => asset.url);
|
||||
uploadResults = await uploadImages(imageUrls, accessToken);
|
||||
|
||||
// 检查上传结果
|
||||
const failedUploads = uploadResults.filter(r => !r.success);
|
||||
if (failedUploads.length > 0) {
|
||||
console.warn(`${failedUploads.length}张编辑后的图像上传失败`);
|
||||
addToast(`${failedUploads.length}张编辑后的图像上传失败`, 'warning', 5000);
|
||||
} else {
|
||||
console.log(`${uploadResults.length}张编辑后的图像全部上传成功`);
|
||||
addToast('编辑后的图像已成功上传', 'success', 3000);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('上传编辑后的图像时出错:', error);
|
||||
addToast('编辑后的图像上传失败', 'error', 5000);
|
||||
uploadResults = undefined;
|
||||
}
|
||||
} else {
|
||||
console.warn('未找到accessToken,跳过上传');
|
||||
}
|
||||
|
||||
const edit: Edit = {
|
||||
id: generateId(),
|
||||
parentGenerationId: selectedGenerationId || '',
|
||||
@@ -233,7 +308,8 @@ export const useImageEditing = () => {
|
||||
maskReferenceAsset,
|
||||
instruction,
|
||||
outputAssets,
|
||||
timestamp: Date.now()
|
||||
timestamp: Date.now(),
|
||||
uploadResults: uploadResults
|
||||
};
|
||||
|
||||
addEdit(edit);
|
||||
@@ -246,18 +322,25 @@ export const useImageEditing = () => {
|
||||
}
|
||||
setIsGenerating(false);
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('编辑失败:', error);
|
||||
const errorMessage = error instanceof Error ? error.message : '未知错误';
|
||||
const errorDetails = error instanceof Error ? error.stack : undefined;
|
||||
addToast(`图像编辑失败: ${errorMessage}`, 'error', 5000, errorDetails);
|
||||
setIsGenerating(false);
|
||||
onError: error => {
|
||||
console.error('编辑失败:', error)
|
||||
const errorMessage = error instanceof Error ? error.message : '未知错误'
|
||||
const errorDetails = error instanceof Error ? error.stack : undefined
|
||||
addToast(`图像编辑失败: ${errorMessage}`, 'error', 5000, errorDetails)
|
||||
setIsGenerating(false)
|
||||
},
|
||||
})
|
||||
|
||||
const cancelEdit = () => {
|
||||
isCancelledRef.current = true
|
||||
setIsGenerating(false)
|
||||
addToast('编辑已中断', 'info', 3000)
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
edit: editMutation.mutate,
|
||||
isEditing: editMutation.isPending,
|
||||
error: editMutation.error
|
||||
};
|
||||
};
|
||||
error: editMutation.error,
|
||||
cancelEdit,
|
||||
}
|
||||
}
|
||||
|
||||
103
src/services/uploadService.ts
Normal file
103
src/services/uploadService.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
// src/services/uploadService.ts
|
||||
import { UploadResult } from '../types'
|
||||
|
||||
// 上传接口URL
|
||||
const UPLOAD_URL = 'https://api.pandorastudio.cn/auth/OSSupload'
|
||||
|
||||
/**
|
||||
* 将base64图像数据上传到指定接口
|
||||
* @param base64Data - base64编码的图像数据
|
||||
* @param accessToken - 访问令牌
|
||||
* @returns 上传结果
|
||||
*/
|
||||
export const uploadImage = async (base64Data: string, accessToken: string): Promise<{ success: boolean; url?: string; error?: string }> => {
|
||||
try {
|
||||
// 将base64数据转换为Blob
|
||||
const byteString = atob(base64Data.split(',')[1])
|
||||
const mimeString = base64Data.split(',')[0].split(':')[1].split(';')[0]
|
||||
const ab = new ArrayBuffer(byteString.length)
|
||||
const ia = new Uint8Array(ab)
|
||||
for (let i = 0; i < byteString.length; i++) {
|
||||
ia[i] = byteString.charCodeAt(i)
|
||||
}
|
||||
const blob = new Blob([ab], { type: mimeString })
|
||||
|
||||
// 创建FormData对象
|
||||
const formData = new FormData()
|
||||
formData.append('file', blob, 'generated-image.png')
|
||||
|
||||
// 发送POST请求
|
||||
const response = await fetch(UPLOAD_URL, {
|
||||
method: 'POST',
|
||||
headers: { accessToken },
|
||||
body: formData,
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
throw new Error(`上传失败: ${response.status} ${response.statusText} - ${errorText}`)
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
// 根据返回格式处理结果: {"code": 200,"msg": "上传成功","data": "9ecbaa0a0.jpg"}
|
||||
if (result.code === 200) {
|
||||
// 使用环境变量中的VITE_UPLOAD_ASSET_URL作为前缀
|
||||
const uploadAssetUrl = import.meta.env.VITE_UPLOAD_ASSET_URL || ''
|
||||
const fullUrl = uploadAssetUrl ? `${uploadAssetUrl}/${result.data}` : result.data
|
||||
return { success: true, url: fullUrl, error: undefined }
|
||||
} else {
|
||||
throw new Error(`上传失败: ${result.msg}`)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('上传图像时出错:', error)
|
||||
return { success: false, url: undefined, error: error instanceof Error ? error.message : String(error) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传多个图像
|
||||
* @param base64Images - base64编码的图像数组
|
||||
* @param accessToken - 访问令牌
|
||||
* @returns 上传结果数组
|
||||
*/
|
||||
export const uploadImages = async (base64Images: string[], accessToken: string): Promise<UploadResult[]> => {
|
||||
try {
|
||||
const results: UploadResult[] = []
|
||||
|
||||
for (let i = 0; i < base64Images.length; i++) {
|
||||
const base64Data = base64Images[i]
|
||||
try {
|
||||
const uploadResult = await uploadImage(base64Data, accessToken)
|
||||
const result: UploadResult = {
|
||||
success: uploadResult.success,
|
||||
url: uploadResult.url,
|
||||
error: uploadResult.error,
|
||||
timestamp: Date.now(),
|
||||
}
|
||||
results.push(result)
|
||||
console.log(`第${i + 1}张图像上传${uploadResult.success ? '成功' : '失败'}:`, uploadResult)
|
||||
} catch (error) {
|
||||
const result: UploadResult = {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
timestamp: Date.now(),
|
||||
}
|
||||
results.push(result)
|
||||
console.error(`第${i + 1}张图像上传失败:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
// 检查是否有任何上传失败
|
||||
const failedUploads = results.filter(r => !r.success)
|
||||
if (failedUploads.length > 0) {
|
||||
console.warn(`${failedUploads.length}张图像上传失败`)
|
||||
} else {
|
||||
console.log(`所有${results.length}张图像上传成功`)
|
||||
}
|
||||
|
||||
return results
|
||||
} catch (error) {
|
||||
console.error('批量上传图像时出错:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { create } from 'zustand';
|
||||
import { devtools, persist } from 'zustand/middleware';
|
||||
import { Project, Generation, Edit, SegmentationMask, BrushStroke } from '../types';
|
||||
import { Project, Generation, Edit, SegmentationMask, BrushStroke, UploadResult } from '../types';
|
||||
import { generateId } from '../utils/imageUtils';
|
||||
|
||||
// 定义不包含图像数据的轻量级项目结构
|
||||
@@ -25,6 +25,7 @@ interface LightweightProject {
|
||||
outputAssetsBlobUrls: string[];
|
||||
modelVersion: string;
|
||||
timestamp: number;
|
||||
uploadResults?: UploadResult[];
|
||||
}>;
|
||||
edits: Array<{
|
||||
id: string;
|
||||
@@ -36,6 +37,7 @@ interface LightweightProject {
|
||||
// 存储输出资产的Blob URL
|
||||
outputAssetsBlobUrls: string[];
|
||||
timestamp: number;
|
||||
uploadResults?: UploadResult[];
|
||||
}>;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
@@ -281,7 +283,8 @@ export const useAppStore = create<AppState>()(
|
||||
sourceAssets,
|
||||
outputAssetsBlobUrls,
|
||||
modelVersion: generation.modelVersion,
|
||||
timestamp: generation.timestamp
|
||||
timestamp: generation.timestamp,
|
||||
uploadResults: generation.uploadResults
|
||||
};
|
||||
|
||||
const updatedProject = state.currentProject ? {
|
||||
@@ -368,7 +371,8 @@ export const useAppStore = create<AppState>()(
|
||||
maskReferenceAssetBlobUrl,
|
||||
instruction: edit.instruction,
|
||||
outputAssetsBlobUrls,
|
||||
timestamp: edit.timestamp
|
||||
timestamp: edit.timestamp,
|
||||
uploadResults: edit.uploadResults
|
||||
};
|
||||
|
||||
if (!state.currentProject) return {};
|
||||
|
||||
@@ -8,6 +8,13 @@ export interface Asset {
|
||||
checksum: string;
|
||||
}
|
||||
|
||||
export interface UploadResult {
|
||||
success: boolean;
|
||||
url?: string;
|
||||
error?: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export interface Generation {
|
||||
id: string;
|
||||
prompt: string;
|
||||
@@ -20,6 +27,7 @@ export interface Generation {
|
||||
modelVersion: string;
|
||||
timestamp: number;
|
||||
costEstimate?: number;
|
||||
uploadResults?: UploadResult[];
|
||||
}
|
||||
|
||||
export interface Edit {
|
||||
@@ -30,6 +38,7 @@ export interface Edit {
|
||||
instruction: string;
|
||||
outputAssets: Asset[];
|
||||
timestamp: number;
|
||||
uploadResults?: UploadResult[];
|
||||
}
|
||||
|
||||
export interface Project {
|
||||
|
||||
Reference in New Issue
Block a user