feat(Gemini Node): Edit Image Using Nano Banana (#19105)

Co-authored-by: Elias Meire <elias@meire.dev>
This commit is contained in:
Jake Ranallo
2025-09-15 11:20:17 +02:00
committed by GitHub
parent f9e78ea9bc
commit 87d79c9efb
3 changed files with 484 additions and 2 deletions

View 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 },
},
]);
});
});

View 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);
}

View File

@@ -1,9 +1,10 @@
import type { INodeProperties } from 'n8n-workflow'; import type { INodeProperties } from 'n8n-workflow';
import * as analyze from './analyze.operation'; import * as analyze from './analyze.operation';
import * as edit from './edit.operation';
import * as generate from './generate.operation'; import * as generate from './generate.operation';
export { analyze, generate }; export { analyze, generate, edit };
export const description: INodeProperties[] = [ export const description: INodeProperties[] = [
{ {
@@ -15,7 +16,7 @@ export const description: INodeProperties[] = [
{ {
name: 'Analyze Image', name: 'Analyze Image',
value: 'analyze', value: 'analyze',
action: 'Analyze image', action: 'Analyze an image',
description: 'Take in images and answer questions about them', description: 'Take in images and answer questions about them',
}, },
{ {
@@ -24,6 +25,12 @@ export const description: INodeProperties[] = [
action: 'Generate an image', action: 'Generate an image',
description: 'Creates an image from a text prompt', 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', default: 'generate',
displayOptions: { displayOptions: {
@@ -33,5 +40,6 @@ export const description: INodeProperties[] = [
}, },
}, },
...analyze.description, ...analyze.description,
...edit.description,
...generate.description, ...generate.description,
]; ];