mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-16 09:36:44 +00:00
feat(Gemini Node): Edit Image Using Nano Banana (#19105)
Co-authored-by: Elias Meire <elias@meire.dev>
This commit is contained in:
248
packages/@n8n/nodes-langchain/nodes/vendors/GoogleGemini/actions/image/edit.operation.test.ts
vendored
Normal file
248
packages/@n8n/nodes-langchain/nodes/vendors/GoogleGemini/actions/image/edit.operation.test.ts
vendored
Normal file
@@ -0,0 +1,248 @@
|
||||
import { mock, mockDeep } from 'jest-mock-extended';
|
||||
import type { IBinaryData, ICredentialDataDecryptedObject, IExecuteFunctions } from 'n8n-workflow';
|
||||
|
||||
import { execute } from './edit.operation';
|
||||
|
||||
const getMockedExecuteFunctions = ({
|
||||
prompt = 'add bananas',
|
||||
outputProperty = 'edited',
|
||||
images = { values: [{ binaryPropertyName: 'data' }] },
|
||||
outputBuffer = Buffer.from('edited image'),
|
||||
mimeType = 'image/png',
|
||||
invalidApiResponse = false,
|
||||
}: {
|
||||
prompt?: string;
|
||||
outputProperty?: string;
|
||||
images?: { values: Array<{ binaryPropertyName: string }> };
|
||||
outputBuffer?: Buffer<ArrayBuffer>;
|
||||
mimeType?: string;
|
||||
invalidApiResponse?: boolean;
|
||||
} = {}): IExecuteFunctions => {
|
||||
const executeFunctions = mockDeep<IExecuteFunctions>();
|
||||
|
||||
executeFunctions.getCredentials
|
||||
.calledWith('googlePalmApi')
|
||||
.mockResolvedValue(mock<ICredentialDataDecryptedObject>());
|
||||
executeFunctions.getNodeParameter.calledWith('prompt', 0).mockReturnValue(prompt);
|
||||
executeFunctions.getNodeParameter
|
||||
.calledWith('options.binaryPropertyOutput', 0)
|
||||
.mockReturnValue(outputProperty);
|
||||
executeFunctions.getNodeParameter.calledWith('images', 0).mockReturnValue(images);
|
||||
executeFunctions.helpers.assertBinaryData.mockReturnValue(mock<IBinaryData>({ mimeType }));
|
||||
executeFunctions.helpers.getBinaryDataBuffer.mockImplementation(async (_index, propertyName) =>
|
||||
Buffer.from(`${propertyName} data`),
|
||||
);
|
||||
executeFunctions.helpers.prepareBinaryData.mockImplementation(async (buffer, filename, mime) => ({
|
||||
data: (buffer as Buffer).toString('base64'),
|
||||
fileName: filename ?? 'image.png',
|
||||
fileSize: (buffer as Buffer).length.toString(),
|
||||
mimeType: mime ?? mimeType,
|
||||
}));
|
||||
|
||||
executeFunctions.helpers.httpRequest
|
||||
.calledWith(
|
||||
expect.objectContaining({
|
||||
url: expect.stringContaining('/upload/v1beta/files'),
|
||||
}),
|
||||
)
|
||||
.mockResolvedValue({
|
||||
headers: { 'x-goog-upload-url': 'https://mock-upload-url.com' },
|
||||
});
|
||||
|
||||
executeFunctions.helpers.httpRequestWithAuthentication
|
||||
.calledWith(
|
||||
'googlePalmApi',
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({ 'X-Goog-Upload-Protocol': expect.any(String) }),
|
||||
}),
|
||||
)
|
||||
.mockResolvedValue({
|
||||
headers: { 'x-goog-upload-url': 'https://mock-upload-url.com' },
|
||||
});
|
||||
|
||||
executeFunctions.helpers.httpRequestWithAuthentication
|
||||
.calledWith(
|
||||
'googlePalmApi',
|
||||
expect.objectContaining({
|
||||
url: expect.stringContaining('generateContent'),
|
||||
}),
|
||||
)
|
||||
.mockResolvedValue(
|
||||
invalidApiResponse
|
||||
? { invalid: 'response' }
|
||||
: {
|
||||
candidates: [
|
||||
{
|
||||
content: {
|
||||
parts: [{ inlineData: { data: outputBuffer.toString('base64'), mimeType } }],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
executeFunctions.helpers.httpRequest
|
||||
.calledWith(
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
url: expect.stringContaining('mock-upload-url'),
|
||||
}),
|
||||
)
|
||||
.mockResolvedValue({
|
||||
file: { name: 'files/test', uri: 'mockFileUri', mimeType, state: 'ACTIVE' },
|
||||
});
|
||||
|
||||
return executeFunctions;
|
||||
};
|
||||
|
||||
describe('Gemini Node image edit', () => {
|
||||
it('should edit an image successfully', async () => {
|
||||
const executeFunctions = getMockedExecuteFunctions();
|
||||
|
||||
const result = await execute.call(executeFunctions, 0);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
binary: {
|
||||
edited: {
|
||||
data: 'ZWRpdGVkIGltYWdl',
|
||||
fileName: 'image.png',
|
||||
fileSize: '12',
|
||||
mimeType: 'image/png',
|
||||
},
|
||||
},
|
||||
json: { fileName: 'image.png', fileSize: '12', mimeType: 'image/png', data: undefined },
|
||||
pairedItem: { item: 0 },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle custom output property name', async () => {
|
||||
const executeFunctions = getMockedExecuteFunctions({
|
||||
prompt: 'edit this image',
|
||||
outputProperty: 'custom_output',
|
||||
});
|
||||
|
||||
const result = await execute.call(executeFunctions, 0);
|
||||
|
||||
expect(result[0].binary).toHaveProperty('custom_output');
|
||||
expect(result[0].binary).not.toHaveProperty('edited');
|
||||
});
|
||||
|
||||
it('should handle multiple images', async () => {
|
||||
const multiImageConfig = {
|
||||
values: [{ binaryPropertyName: 'image1' }, { binaryPropertyName: 'image2' }],
|
||||
};
|
||||
|
||||
const executeFunctions = getMockedExecuteFunctions({
|
||||
prompt: 'combine images',
|
||||
outputProperty: 'combined',
|
||||
images: multiImageConfig,
|
||||
outputBuffer: Buffer.from('combined image'),
|
||||
});
|
||||
|
||||
const result = await execute.call(executeFunctions, 0);
|
||||
|
||||
expect(result[0].binary).toHaveProperty('combined');
|
||||
});
|
||||
|
||||
it('should throw error for invalid images parameter', async () => {
|
||||
const executeFunctions = getMockedExecuteFunctions(
|
||||
{ prompt: 'test prompt', outputProperty: 'edited', images: { values: 'invalid' as never } }, // Invalid format
|
||||
);
|
||||
|
||||
await expect(execute.call(executeFunctions, 0)).rejects.toThrow(
|
||||
'Invalid images parameter format',
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error when no image data returned from API', async () => {
|
||||
const executeFunctions = getMockedExecuteFunctions(
|
||||
{
|
||||
prompt: 'test prompt',
|
||||
outputProperty: 'edited',
|
||||
images: { values: [{ binaryPropertyName: 'data' }] },
|
||||
outputBuffer: Buffer.from(''),
|
||||
}, // Empty buffer to trigger error
|
||||
);
|
||||
|
||||
await expect(execute.call(executeFunctions, 0)).rejects.toThrow(
|
||||
'No image data returned from Gemini API',
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error for invalid API response format', async () => {
|
||||
const executeFunctions = getMockedExecuteFunctions({
|
||||
prompt: 'test prompt',
|
||||
outputProperty: 'edited',
|
||||
images: { values: [{ binaryPropertyName: 'data' }] },
|
||||
invalidApiResponse: true,
|
||||
});
|
||||
|
||||
await expect(execute.call(executeFunctions, 0)).rejects.toThrow(
|
||||
'Invalid response format from Gemini API',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle empty prompt', async () => {
|
||||
const executeFunctions = getMockedExecuteFunctions({ prompt: '' });
|
||||
|
||||
const result = await execute.call(executeFunctions, 0);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
binary: {
|
||||
edited: {
|
||||
data: 'ZWRpdGVkIGltYWdl',
|
||||
fileName: 'image.png',
|
||||
fileSize: '12',
|
||||
mimeType: 'image/png',
|
||||
},
|
||||
},
|
||||
json: { fileName: 'image.png', fileSize: '12', mimeType: 'image/png', data: undefined },
|
||||
pairedItem: { item: 0 },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle different MIME types', async () => {
|
||||
const executeFunctions = getMockedExecuteFunctions({
|
||||
prompt: 'enhance image',
|
||||
outputProperty: 'enhanced',
|
||||
images: { values: [{ binaryPropertyName: 'data' }] },
|
||||
outputBuffer: Buffer.from('enhanced jpeg'),
|
||||
mimeType: 'image/jpeg',
|
||||
});
|
||||
|
||||
const result = await execute.call(executeFunctions, 0);
|
||||
|
||||
expect(result[0]?.binary?.enhanced?.mimeType).toBe('image/jpeg');
|
||||
expect(result[0]?.json?.mimeType).toBe('image/jpeg');
|
||||
});
|
||||
|
||||
it('should handle empty images array when no valid binary property names', async () => {
|
||||
const executeFunctions = getMockedExecuteFunctions({
|
||||
prompt: 'test prompt',
|
||||
outputProperty: 'edited',
|
||||
images: { values: [{ binaryPropertyName: '' }] },
|
||||
outputBuffer: Buffer.from('no image response'),
|
||||
});
|
||||
|
||||
const result = await execute.call(executeFunctions, 0);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
binary: {
|
||||
edited: {
|
||||
data: 'bm8gaW1hZ2UgcmVzcG9uc2U=',
|
||||
fileName: 'image.png',
|
||||
fileSize: '17',
|
||||
mimeType: 'image/png',
|
||||
},
|
||||
},
|
||||
json: { fileName: 'image.png', fileSize: '17', mimeType: 'image/png', data: undefined },
|
||||
pairedItem: { item: 0 },
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
226
packages/@n8n/nodes-langchain/nodes/vendors/GoogleGemini/actions/image/edit.operation.ts
vendored
Normal file
226
packages/@n8n/nodes-langchain/nodes/vendors/GoogleGemini/actions/image/edit.operation.ts
vendored
Normal file
@@ -0,0 +1,226 @@
|
||||
import type { IExecuteFunctions, INodeExecutionData, INodeProperties } from 'n8n-workflow';
|
||||
import { updateDisplayOptions } from 'n8n-workflow';
|
||||
|
||||
import type { GenerateContentResponse } from '../../helpers/interfaces';
|
||||
import { uploadFile } from '../../helpers/utils';
|
||||
import { apiRequest } from '../../transport';
|
||||
|
||||
interface ImagesParameter {
|
||||
values?: Array<{ binaryPropertyName?: string }>;
|
||||
}
|
||||
|
||||
function isImagesParameter(param: unknown): param is ImagesParameter {
|
||||
if (typeof param !== 'object' || param === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const paramObj = param as Record<string, unknown>;
|
||||
|
||||
if (!('values' in paramObj)) {
|
||||
return true; // values is optional
|
||||
}
|
||||
|
||||
if (!Array.isArray(paramObj.values)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return paramObj.values.every((item: unknown) => {
|
||||
if (typeof item !== 'object' || item === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const itemObj = item as Record<string, unknown>;
|
||||
|
||||
if (!('binaryPropertyName' in itemObj)) {
|
||||
return true; // binaryPropertyName is optional
|
||||
}
|
||||
|
||||
return (
|
||||
typeof itemObj.binaryPropertyName === 'string' || itemObj.binaryPropertyName === undefined
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function isGenerateContentResponse(response: unknown): response is GenerateContentResponse {
|
||||
if (typeof response !== 'object' || response === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const responseObj = response as Record<string, unknown>;
|
||||
|
||||
if (!('candidates' in responseObj) || !Array.isArray(responseObj.candidates)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return responseObj.candidates.every((candidate: unknown) => {
|
||||
if (typeof candidate !== 'object' || candidate === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const candidateObj = candidate as Record<string, unknown>;
|
||||
|
||||
if (
|
||||
!('content' in candidateObj) ||
|
||||
typeof candidateObj.content !== 'object' ||
|
||||
candidateObj.content === null
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const contentObj = candidateObj.content as Record<string, unknown>;
|
||||
|
||||
return 'parts' in contentObj && Array.isArray(contentObj.parts);
|
||||
});
|
||||
}
|
||||
|
||||
const properties: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'Prompt',
|
||||
name: 'prompt',
|
||||
type: 'string',
|
||||
placeholder: 'e.g. combine the first image with the second image',
|
||||
description: 'Instruction describing how to edit the image',
|
||||
default: '',
|
||||
typeOptions: {
|
||||
rows: 2,
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Images',
|
||||
name: 'images',
|
||||
type: 'fixedCollection',
|
||||
placeholder: 'Add Image',
|
||||
typeOptions: {
|
||||
multipleValues: true,
|
||||
multipleValueButtonText: 'Add Image',
|
||||
},
|
||||
default: { values: [{ binaryPropertyName: 'data' }] },
|
||||
description: 'Add one or more binary fields to include images with your prompt',
|
||||
options: [
|
||||
{
|
||||
displayName: 'Image',
|
||||
name: 'values',
|
||||
values: [
|
||||
{
|
||||
displayName: 'Binary Field Name',
|
||||
name: 'binaryPropertyName',
|
||||
type: 'string',
|
||||
default: 'data',
|
||||
placeholder: 'e.g. data',
|
||||
description: 'The name of the binary field containing the image data',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
displayName: 'Options',
|
||||
name: 'options',
|
||||
placeholder: 'Add Option',
|
||||
type: 'collection',
|
||||
default: {},
|
||||
options: [
|
||||
{
|
||||
displayName: 'Put Output in Field',
|
||||
name: 'binaryPropertyOutput',
|
||||
type: 'string',
|
||||
default: 'edited',
|
||||
hint: 'The name of the output field to put the binary file data in',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const displayOptions = {
|
||||
show: {
|
||||
operation: ['edit'],
|
||||
resource: ['image'],
|
||||
},
|
||||
};
|
||||
|
||||
export const description = updateDisplayOptions(displayOptions, properties);
|
||||
|
||||
export async function execute(this: IExecuteFunctions, i: number): Promise<INodeExecutionData[]> {
|
||||
const prompt = this.getNodeParameter('prompt', i, '');
|
||||
const binaryPropertyOutput = this.getNodeParameter('options.binaryPropertyOutput', i, 'edited');
|
||||
const outputKey = typeof binaryPropertyOutput === 'string' ? binaryPropertyOutput : 'data';
|
||||
|
||||
// Collect image binary field names from collection
|
||||
const imagesParam = this.getNodeParameter('images', i, {
|
||||
values: [{ binaryPropertyName: 'data' }],
|
||||
});
|
||||
|
||||
if (!isImagesParameter(imagesParam)) {
|
||||
throw new Error('Invalid images parameter format');
|
||||
}
|
||||
|
||||
const imagesUi = imagesParam.values ?? [];
|
||||
const imageFieldNames = imagesUi
|
||||
.map((v) => v.binaryPropertyName)
|
||||
.filter((n): n is string => Boolean(n));
|
||||
|
||||
// Upload all images and gather fileData parts
|
||||
const fileParts = [] as Array<{ fileData: { fileUri: string; mimeType: string } }>;
|
||||
for (const fieldName of imageFieldNames) {
|
||||
const bin = this.helpers.assertBinaryData(i, fieldName);
|
||||
const buf = await this.helpers.getBinaryDataBuffer(i, fieldName);
|
||||
const uploaded = await uploadFile.call(this, buf, bin.mimeType);
|
||||
fileParts.push({ fileData: { fileUri: uploaded.fileUri, mimeType: uploaded.mimeType } });
|
||||
}
|
||||
|
||||
const model = 'models/gemini-2.5-flash-image-preview';
|
||||
const generationConfig = {
|
||||
responseModalities: ['IMAGE'],
|
||||
};
|
||||
|
||||
const body = {
|
||||
contents: [
|
||||
{
|
||||
role: 'user',
|
||||
parts: [...fileParts, { text: prompt }],
|
||||
},
|
||||
],
|
||||
generationConfig,
|
||||
};
|
||||
|
||||
const response: unknown = await apiRequest.call(
|
||||
this,
|
||||
'POST',
|
||||
`/v1beta/${model}:generateContent`,
|
||||
{
|
||||
body,
|
||||
},
|
||||
);
|
||||
|
||||
if (!isGenerateContentResponse(response)) {
|
||||
throw new Error('Invalid response format from Gemini API');
|
||||
}
|
||||
|
||||
const promises = response.candidates.map(async (candidate) => {
|
||||
const imagePart = candidate.content.parts.find((part) => 'inlineData' in part);
|
||||
|
||||
// Check if imagePart exists and has inlineData with actual data
|
||||
if (!imagePart?.inlineData?.data) {
|
||||
throw new Error('No image data returned from Gemini API');
|
||||
}
|
||||
|
||||
const bufferOut = Buffer.from(imagePart.inlineData.data, 'base64');
|
||||
const binaryOut = await this.helpers.prepareBinaryData(
|
||||
bufferOut,
|
||||
'image.png',
|
||||
imagePart.inlineData.mimeType,
|
||||
);
|
||||
return {
|
||||
binary: {
|
||||
[outputKey]: binaryOut,
|
||||
},
|
||||
json: {
|
||||
...binaryOut,
|
||||
data: undefined,
|
||||
},
|
||||
pairedItem: { item: i },
|
||||
};
|
||||
});
|
||||
|
||||
return await Promise.all(promises);
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
import type { INodeProperties } from 'n8n-workflow';
|
||||
|
||||
import * as analyze from './analyze.operation';
|
||||
import * as edit from './edit.operation';
|
||||
import * as generate from './generate.operation';
|
||||
|
||||
export { analyze, generate };
|
||||
export { analyze, generate, edit };
|
||||
|
||||
export const description: INodeProperties[] = [
|
||||
{
|
||||
@@ -15,7 +16,7 @@ export const description: INodeProperties[] = [
|
||||
{
|
||||
name: 'Analyze Image',
|
||||
value: 'analyze',
|
||||
action: 'Analyze image',
|
||||
action: 'Analyze an image',
|
||||
description: 'Take in images and answer questions about them',
|
||||
},
|
||||
{
|
||||
@@ -24,6 +25,12 @@ export const description: INodeProperties[] = [
|
||||
action: 'Generate an image',
|
||||
description: 'Creates an image from a text prompt',
|
||||
},
|
||||
{
|
||||
name: 'Edit Image',
|
||||
value: 'edit',
|
||||
action: 'Edit an image',
|
||||
description: 'Upload one or more images and apply edits based on a prompt',
|
||||
},
|
||||
],
|
||||
default: 'generate',
|
||||
displayOptions: {
|
||||
@@ -33,5 +40,6 @@ export const description: INodeProperties[] = [
|
||||
},
|
||||
},
|
||||
...analyze.description,
|
||||
...edit.description,
|
||||
...generate.description,
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user