You've already forked Nano-Banana-AI-Image-Editor
278 lines
9.2 KiB
TypeScript
278 lines
9.2 KiB
TypeScript
import { GoogleGenAI } from '@google/genai'
|
||
|
||
// 注意:在生产环境中,这应该通过后端代理处理
|
||
const API_KEY = import.meta.env.VITE_GEMINI_API_KEY || 'demo-key'
|
||
const genAI = new GoogleGenAI({ apiKey: API_KEY })
|
||
|
||
export interface GenerationRequest {
|
||
prompt: string
|
||
referenceImages?: string[] // base64数组
|
||
temperature?: number
|
||
seed?: number
|
||
}
|
||
|
||
export interface EditRequest {
|
||
instruction: string
|
||
originalImage: string // base64
|
||
referenceImages?: string[] // base64数组
|
||
maskImage?: string // base64
|
||
temperature?: number
|
||
seed?: number
|
||
}
|
||
|
||
export interface UsageMetadata {
|
||
totalTokenCount?: number
|
||
promptTokenCount?: number
|
||
candidatesTokenCount?: number
|
||
}
|
||
|
||
export interface SegmentationRequest {
|
||
image: string // base64
|
||
query: string // "像素(x,y)处的对象" 或 "红色汽车"
|
||
}
|
||
|
||
export class GeminiService {
|
||
async generateImage(request: GenerationRequest): Promise<{ images: string[]; usageMetadata?: any }> {
|
||
try {
|
||
const contents: any[] = [{ text: request.prompt }]
|
||
|
||
// 如果提供了参考图像则添加
|
||
if (request.referenceImages && request.referenceImages.length > 0) {
|
||
request.referenceImages.forEach(image => {
|
||
contents.push({
|
||
inlineData: {
|
||
mimeType: 'image/png',
|
||
data: image,
|
||
},
|
||
})
|
||
})
|
||
}
|
||
|
||
const response = await genAI.models.generateContent({
|
||
model: 'gemini-2.5-flash-image-preview',
|
||
contents,
|
||
})
|
||
|
||
// 检查是否有被禁止的内容
|
||
if (response.candidates && response.candidates.length > 0) {
|
||
const candidate = response.candidates[0]
|
||
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;
|
||
if (candidate.content && candidate.content.parts) {
|
||
for (const part of candidate.content.parts) {
|
||
if (part.inlineData) {
|
||
hasInlineData = true;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
// 如果没有inlineData,则抛出错误
|
||
if (!hasInlineData && candidate.content && candidate.content.parts && candidate.content.parts.length > 0) {
|
||
throw new Error(candidate.content.parts[0].text || '生成失败:未返回图像数据');
|
||
}
|
||
}
|
||
}
|
||
|
||
const images: string[] = []
|
||
|
||
// 检查响应是否存在以及是否有内容
|
||
if (response.candidates && response.candidates.length > 0 && response.candidates[0].content && response.candidates[0].content.parts) {
|
||
for (const part of response.candidates[0].content.parts) {
|
||
if (part.inlineData) {
|
||
images.push(part.inlineData.data)
|
||
}
|
||
}
|
||
}
|
||
|
||
// 获取usageMetadata(如果存在)
|
||
const usageMetadata = response.usageMetadata
|
||
|
||
return { images, usageMetadata }
|
||
} catch (error) {
|
||
console.error('生成图像时出错:', error)
|
||
if (error instanceof Error && error.message) {
|
||
throw error
|
||
}
|
||
throw new Error(`生成图像失败: ${error instanceof Error ? error.message : '未知错误'}`)
|
||
}
|
||
}
|
||
|
||
async editImage(request: EditRequest): Promise<{ images: string[]; usageMetadata?: any }> {
|
||
try {
|
||
const contents = [
|
||
{ text: this.buildEditPrompt(request) },
|
||
{
|
||
inlineData: {
|
||
mimeType: 'image/png',
|
||
data: request.originalImage,
|
||
},
|
||
},
|
||
]
|
||
|
||
// 如果提供了参考图像则添加
|
||
if (request.referenceImages && request.referenceImages.length > 0) {
|
||
request.referenceImages.forEach(image => {
|
||
contents.push({
|
||
inlineData: {
|
||
mimeType: 'image/png',
|
||
data: image,
|
||
},
|
||
})
|
||
})
|
||
}
|
||
|
||
if (request.maskImage) {
|
||
contents.push({
|
||
inlineData: {
|
||
mimeType: 'image/png',
|
||
data: request.maskImage,
|
||
},
|
||
})
|
||
}
|
||
|
||
const response = await genAI.models.generateContent({
|
||
model: 'gemini-2.5-flash-image-preview',
|
||
contents,
|
||
})
|
||
|
||
// 检查是否有被禁止的内容
|
||
if (response.candidates && response.candidates.length > 0) {
|
||
const candidate = response.candidates[0]
|
||
if (candidate.finishReason === 'PROHIBITED_CONTENT') {
|
||
throw new Error('内容被禁止:您的请求包含不允许的内容。请尝试其他提示。')
|
||
}
|
||
// 检查finishReason为STOP但没有inlineData的情况
|
||
if (candidate.finishReason === 'STOP') {
|
||
// 检查是否有inlineData
|
||
let hasInlineData = false;
|
||
if (candidate.content && candidate.content.parts) {
|
||
for (const part of candidate.content.parts) {
|
||
if (part.inlineData) {
|
||
hasInlineData = true;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
// 如果没有inlineData,则抛出错误
|
||
if (!hasInlineData && candidate.content && candidate.content.parts && candidate.content.parts.length > 0) {
|
||
throw new Error(candidate.content.parts[0].text || '编辑失败:未返回图像数据');
|
||
}
|
||
}
|
||
}
|
||
|
||
const images: string[] = []
|
||
|
||
// 检查响应是否存在以及是否有内容
|
||
if (response.candidates && response.candidates.length > 0 && response.candidates[0].content && response.candidates[0].content.parts) {
|
||
for (const part of response.candidates[0].content.parts) {
|
||
if (part.inlineData) {
|
||
images.push(part.inlineData.data)
|
||
}
|
||
}
|
||
}
|
||
|
||
// 获取usageMetadata(如果存在)
|
||
const usageMetadata = response.usageMetadata
|
||
|
||
return { images, usageMetadata }
|
||
} catch (error) {
|
||
console.error('编辑图像时出错:', error)
|
||
if (error instanceof Error && error.message) {
|
||
throw error
|
||
}
|
||
throw new Error(`编辑图像失败: ${error instanceof Error ? error.message : '未知错误'}`)
|
||
}
|
||
}
|
||
|
||
async segmentImage(request: SegmentationRequest): Promise<any> {
|
||
try {
|
||
const prompt = [
|
||
{
|
||
text: `分析此图像并为以下对象创建分割遮罩: ${request.query}
|
||
|
||
返回具有此确切结构的JSON对象:
|
||
{
|
||
"masks": [
|
||
{
|
||
"label": "分割对象的描述",
|
||
"box_2d": [x, y, width, height],
|
||
"mask": "base64编码的二进制遮罩图像"
|
||
}
|
||
]
|
||
}
|
||
|
||
仅分割请求的特定对象或区域。遮罩应该是二进制PNG,其中白色像素(255)表示选定区域,黑色像素(0)表示背景。`,
|
||
},
|
||
{
|
||
inlineData: {
|
||
mimeType: 'image/png',
|
||
data: request.image,
|
||
},
|
||
},
|
||
]
|
||
|
||
const response = await genAI.models.generateContent({
|
||
model: 'gemini-2.5-flash-image-preview',
|
||
contents: prompt,
|
||
})
|
||
|
||
// 检查是否有被禁止的内容
|
||
if (response.candidates && response.candidates.length > 0) {
|
||
const candidate = response.candidates[0]
|
||
if (candidate.finishReason === 'PROHIBITED_CONTENT') {
|
||
throw new Error('内容被禁止:您的请求包含不允许的内容。请尝试其他提示。')
|
||
}
|
||
// 检查finishReason为STOP但没有inlineData的情况
|
||
if (candidate.finishReason === 'STOP') {
|
||
// 检查是否有inlineData
|
||
let hasInlineData = false;
|
||
if (candidate.content && candidate.content.parts) {
|
||
for (const part of candidate.content.parts) {
|
||
if (part.inlineData) {
|
||
hasInlineData = true;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
// 如果没有inlineData,则抛出错误
|
||
if (!hasInlineData && candidate.content && candidate.content.parts && candidate.content.parts.length > 0) {
|
||
throw new Error(candidate.content.parts[0].text || '分割失败:未返回结果数据');
|
||
}
|
||
}
|
||
}
|
||
|
||
const responseText = response.candidates[0].content.parts[0].text
|
||
return JSON.parse(responseText)
|
||
} catch (error) {
|
||
console.error('分割图像时出错:', error)
|
||
if (error instanceof Error && error.message) {
|
||
throw error
|
||
}
|
||
throw new Error(`分割图像失败: ${error instanceof Error ? error.message : '未知错误'}`)
|
||
}
|
||
}
|
||
|
||
private buildEditPrompt(request: EditRequest): string {
|
||
const maskInstruction = request.maskImage ? '\n\n重要: 仅在遮罩图像显示白色像素(值255)的地方应用更改。完全不更改所有其他区域。精确遵守遮罩边界并在边缘保持无缝混合。' : ''
|
||
|
||
return `根据以下指令编辑此图像: ${request.instruction}
|
||
|
||
保持原始图像的光照、透视和整体构图。使更改看起来自然且无缝集成。${maskInstruction}
|
||
|
||
保持图像质量并确保编辑看起来专业且逼真。`
|
||
}
|
||
}
|
||
|
||
export const geminiService = new GeminiService()
|