From 87d79c9efb52c15cc6f1675328a3d4e0ef5d22a1 Mon Sep 17 00:00:00 2001 From: Jake Ranallo Date: Mon, 15 Sep 2025 11:20:17 +0200 Subject: [PATCH] feat(Gemini Node): Edit Image Using Nano Banana (#19105) Co-authored-by: Elias Meire --- .../actions/image/edit.operation.test.ts | 248 ++++++++++++++++++ .../actions/image/edit.operation.ts | 226 ++++++++++++++++ .../GoogleGemini/actions/image/index.ts | 12 +- 3 files changed, 484 insertions(+), 2 deletions(-) create mode 100644 packages/@n8n/nodes-langchain/nodes/vendors/GoogleGemini/actions/image/edit.operation.test.ts create mode 100644 packages/@n8n/nodes-langchain/nodes/vendors/GoogleGemini/actions/image/edit.operation.ts diff --git a/packages/@n8n/nodes-langchain/nodes/vendors/GoogleGemini/actions/image/edit.operation.test.ts b/packages/@n8n/nodes-langchain/nodes/vendors/GoogleGemini/actions/image/edit.operation.test.ts new file mode 100644 index 0000000000..94e62a7196 --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/vendors/GoogleGemini/actions/image/edit.operation.test.ts @@ -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; + mimeType?: string; + invalidApiResponse?: boolean; +} = {}): IExecuteFunctions => { + const executeFunctions = mockDeep(); + + executeFunctions.getCredentials + .calledWith('googlePalmApi') + .mockResolvedValue(mock()); + 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({ 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 }, + }, + ]); + }); +}); diff --git a/packages/@n8n/nodes-langchain/nodes/vendors/GoogleGemini/actions/image/edit.operation.ts b/packages/@n8n/nodes-langchain/nodes/vendors/GoogleGemini/actions/image/edit.operation.ts new file mode 100644 index 0000000000..9c25d70670 --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/vendors/GoogleGemini/actions/image/edit.operation.ts @@ -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; + + 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; + + 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; + + 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; + + if ( + !('content' in candidateObj) || + typeof candidateObj.content !== 'object' || + candidateObj.content === null + ) { + return false; + } + + const contentObj = candidateObj.content as Record; + + 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 { + 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); +} diff --git a/packages/@n8n/nodes-langchain/nodes/vendors/GoogleGemini/actions/image/index.ts b/packages/@n8n/nodes-langchain/nodes/vendors/GoogleGemini/actions/image/index.ts index bba470bc86..57065639b2 100644 --- a/packages/@n8n/nodes-langchain/nodes/vendors/GoogleGemini/actions/image/index.ts +++ b/packages/@n8n/nodes-langchain/nodes/vendors/GoogleGemini/actions/image/index.ts @@ -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, ];