fix(Google Gemini Node): Determine the file extention from MIME type for image and video operations (#28616)

This commit is contained in:
RomanDavydchuk 2026-04-20 11:16:51 +03:00 committed by GitHub
parent 4070930e4c
commit 73659cb3e7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 223 additions and 17 deletions

View file

@ -1,8 +1,9 @@
import * as helpers from '@utils/helpers';
import { mockDeep } from 'jest-mock-extended';
import type { IExecuteFunctions, IBinaryData, INode } from 'n8n-workflow';
import { NodeOperationError } from 'n8n-workflow';
import * as helpers from '@utils/helpers';
import * as audio from './actions/audio';
import * as file from './actions/file';
import * as image from './actions/image';
@ -1645,6 +1646,51 @@ describe('GoogleGemini Node', () => {
);
});
it('should derive Gemini image filename from MIME type', async () => {
executeFunctionsMock.getNodeParameter.mockImplementation((parameter: string) => {
switch (parameter) {
case 'modelId':
return 'models/gemini-2.0-flash-preview-image-generation';
case 'prompt':
return 'A cute cat eating a dinosaur';
case 'options.binaryPropertyOutput':
return 'data';
default:
return undefined;
}
});
apiRequestMock.mockResolvedValue({
candidates: [
{
content: {
parts: [
{
inlineData: {
data: 'abcdefgh',
mimeType: 'image/jpeg',
},
},
],
},
},
],
});
executeFunctionsMock.helpers.prepareBinaryData.mockResolvedValue({
mimeType: 'image/jpeg',
fileName: 'image.jpg',
fileSize: '100',
data: 'abcdefgh',
});
await image.generate.execute.call(executeFunctionsMock, 0);
expect(executeFunctionsMock.helpers.prepareBinaryData).toHaveBeenCalledWith(
Buffer.from('abcdefgh', 'base64'),
'image.jpg',
'image/jpeg',
);
});
it('should generate multiple images using Imagen model', async () => {
executeFunctionsMock.getNodeParameter.mockImplementation((parameter: string) => {
switch (parameter) {
@ -1881,6 +1927,71 @@ describe('GoogleGemini Node', () => {
});
});
it('should derive video filename from MIME type', async () => {
executeFunctionsMock.getNodeParameter.mockImplementation((parameter: string) => {
switch (parameter) {
case 'modelId':
return 'models/veo-3.0-generate-002';
case 'prompt':
return 'Panning wide shot of a calico kitten sleeping in the sunshine';
case 'options':
return {
aspectRatio: '16:9',
personGeneration: 'dont_allow',
sampleCount: 1,
};
case 'options.binaryPropertyOutput':
return 'data';
case 'returnAs':
return 'video';
default:
return undefined;
}
});
executeFunctionsMock.getCredentials.mockResolvedValue({ apiKey: 'test-api-key' });
apiRequestMock
.mockResolvedValueOnce({
name: 'operations/123',
done: false,
})
.mockResolvedValueOnce({
name: 'operations/123',
done: true,
response: {
generateVideoResponse: {
generatedSamples: [
{
video: {
uri: 'https://example.com/video.mp4',
},
},
],
},
},
});
downloadFileMock.mockResolvedValue({
fileContent: Buffer.from('abcdefgh'),
mimeType: 'video/webm',
});
executeFunctionsMock.helpers.prepareBinaryData.mockResolvedValue({
mimeType: 'video/webm',
fileName: 'video.webm',
fileSize: '1000',
data: 'abcdefgh',
});
const promise = video.generate.execute.call(executeFunctionsMock, 0);
await jest.advanceTimersByTimeAsync(5000);
const result = await promise;
expect(result[0]?.binary?.data?.fileName).toBe('video.webm');
expect(executeFunctionsMock.helpers.prepareBinaryData).toHaveBeenCalledWith(
Buffer.from('abcdefgh'),
'video.webm',
'video/webm',
);
});
it('should not pass durationSeconds if not provided', async () => {
executeFunctionsMock.getNodeParameter.mockImplementation((parameter: string) => {
switch (parameter) {
@ -2003,5 +2114,64 @@ describe('GoogleGemini Node', () => {
),
);
});
describe('Video -> Download', () => {
it('should derive download filename from MIME type', async () => {
executeFunctionsMock.getNodeParameter.mockImplementation((parameter: string) => {
switch (parameter) {
case 'url':
return 'https://example.com/video.mp4';
case 'options.binaryPropertyOutput':
return 'data';
default:
return undefined;
}
});
executeFunctionsMock.getCredentials.mockResolvedValue({ apiKey: 'test-api-key' });
downloadFileMock.mockResolvedValue({
fileContent: Buffer.from('abcdefgh'),
mimeType: 'video/webm',
});
executeFunctionsMock.helpers.prepareBinaryData.mockResolvedValue({
mimeType: 'video/webm',
fileName: 'video.webm',
fileSize: '1000',
data: 'abcdefgh',
});
const result = await video.download.execute.call(executeFunctionsMock, 0);
expect(result).toEqual([
{
binary: {
data: {
mimeType: 'video/webm',
fileName: 'video.webm',
fileSize: '1000',
data: 'abcdefgh',
},
},
json: {
mimeType: 'video/webm',
fileName: 'video.webm',
fileSize: '1000',
},
pairedItem: { item: 0 },
},
]);
expect(downloadFileMock).toHaveBeenCalledWith(
'https://example.com/video.mp4',
'video/mp4',
{
key: 'test-api-key',
},
);
expect(executeFunctionsMock.helpers.prepareBinaryData).toHaveBeenCalledWith(
Buffer.from('abcdefgh'),
'video.webm',
'video/webm',
);
});
});
});
});

View file

@ -217,7 +217,9 @@ describe('Gemini Node image edit', () => {
const result = await execute.call(executeFunctions, 0);
expect(result[0]?.binary?.enhanced?.mimeType).toBe('image/jpeg');
expect(result[0]?.binary?.enhanced?.fileName).toBe('image.jpg');
expect(result[0]?.json?.mimeType).toBe('image/jpeg');
expect(result[0]?.json?.fileName).toBe('image.jpg');
});
it('should handle empty images array when no valid binary property names', async () => {

View file

@ -2,7 +2,7 @@ import type { IExecuteFunctions, INodeExecutionData, INodeProperties } from 'n8n
import { updateDisplayOptions } from 'n8n-workflow';
import type { GenerateContentResponse } from '../../helpers/interfaces';
import { uploadFile } from '../../helpers/utils';
import { getFilenameFromMimeType, uploadFile } from '../../helpers/utils';
import { apiRequest } from '../../transport';
import { modelRLC } from '../descriptions';
@ -210,12 +210,10 @@ export async function execute(this: IExecuteFunctions, i: number): Promise<INode
throw new Error('No image data returned from Gemini API');
}
const mimeType = imagePart.inlineData.mimeType;
const fileName = getFilenameFromMimeType(mimeType, 'image', 'png');
const bufferOut = Buffer.from(imagePart.inlineData.data, 'base64');
const binaryOut = await this.helpers.prepareBinaryData(
bufferOut,
'image.png',
imagePart.inlineData.mimeType,
);
const binaryOut = await this.helpers.prepareBinaryData(bufferOut, fileName, mimeType);
return {
binary: {
[outputKey]: binaryOut,

View file

@ -7,6 +7,7 @@ import {
type ImagenResponse,
Modality,
} from '../../helpers/interfaces';
import { getFilenameFromMimeType } from '../../helpers/utils';
import { apiRequest } from '../../transport';
import { modelRLC } from '../descriptions';
@ -89,12 +90,10 @@ export async function execute(this: IExecuteFunctions, i: number): Promise<INode
})) as GenerateContentResponse;
const promises = response.candidates.map(async (candidate) => {
const imagePart = candidate.content.parts.find((part) => 'inlineData' in part);
const mimeType = imagePart?.inlineData.mimeType;
const fileName = getFilenameFromMimeType(mimeType, 'image', 'png');
const buffer = Buffer.from(imagePart?.inlineData.data ?? '', 'base64');
const binaryData = await this.helpers.prepareBinaryData(
buffer,
'image.png',
imagePart?.inlineData.mimeType,
);
const binaryData = await this.helpers.prepareBinaryData(buffer, fileName, mimeType);
return {
binary: {
[binaryPropertyOutput]: binaryData,
@ -126,10 +125,11 @@ export async function execute(this: IExecuteFunctions, i: number): Promise<INode
})) as ImagenResponse;
const promises = response.predictions.map(async (prediction) => {
const fileName = getFilenameFromMimeType(prediction.mimeType, 'image', 'png');
const buffer = Buffer.from(prediction.bytesBase64Encoded ?? '', 'base64');
const binaryData = await this.helpers.prepareBinaryData(
buffer,
'image.png',
fileName,
prediction.mimeType,
);
return {

View file

@ -1,7 +1,7 @@
import type { IExecuteFunctions, INodeExecutionData, INodeProperties } from 'n8n-workflow';
import { updateDisplayOptions } from 'n8n-workflow';
import { downloadFile } from '../../helpers/utils';
import { downloadFile, getFilenameFromMimeType } from '../../helpers/utils';
const properties: INodeProperties[] = [
{
@ -50,7 +50,8 @@ export async function execute(this: IExecuteFunctions, i: number): Promise<INode
const { fileContent, mimeType } = await downloadFile.call(this, url, 'video/mp4', {
key: credentials.apiKey as string,
});
const binaryData = await this.helpers.prepareBinaryData(fileContent, 'video.mp4', mimeType);
const fileName = getFilenameFromMimeType(mimeType, 'video', 'mp4');
const binaryData = await this.helpers.prepareBinaryData(fileContent, fileName, mimeType);
return [
{
binary: { [binaryPropertyOutput]: binaryData },

View file

@ -2,7 +2,7 @@ import type { IExecuteFunctions, INodeExecutionData, INodeProperties } from 'n8n
import { NodeOperationError, updateDisplayOptions } from 'n8n-workflow';
import type { VeoResponse } from '../../helpers/interfaces';
import { downloadFile } from '../../helpers/utils';
import { downloadFile, getFilenameFromMimeType } from '../../helpers/utils';
import { apiRequest } from '../../transport';
import { modelRLC } from '../descriptions';
@ -188,7 +188,8 @@ export async function execute(this: IExecuteFunctions, i: number): Promise<INode
key: credentials.apiKey as string,
},
);
const binaryData = await this.helpers.prepareBinaryData(fileContent, 'video.mp4', mimeType);
const fileName = getFilenameFromMimeType(mimeType, 'video', 'mp4');
const binaryData = await this.helpers.prepareBinaryData(fileContent, fileName, mimeType);
return {
binary: { [binaryPropertyOutput]: binaryData },
json: {

View file

@ -7,6 +7,7 @@ import {
createFileSearchStore,
deleteFileSearchStore,
downloadFile,
getFilenameFromMimeType,
listFileSearchStores,
transferFile,
uploadFile,
@ -26,6 +27,26 @@ describe('GoogleGemini -> utils', () => {
jest.useFakeTimers({ advanceTimers: true });
});
describe('getFilenameFromMimeType', () => {
it('should derive filename extension from mime type', () => {
const fileName = getFilenameFromMimeType('image/jpeg', 'image', 'png');
expect(fileName).toBe('image.jpg');
});
it('should use fallback extension when mime type is unknown', () => {
const fileName = getFilenameFromMimeType('application/unknown', 'file', 'bin');
expect(fileName).toBe('file.bin');
});
it('should use fallback extension when mime type is undefined', () => {
const fileName = getFilenameFromMimeType(undefined, 'video', 'mp4');
expect(fileName).toBe('video.mp4');
});
});
describe('downloadFile', () => {
it('should download file', async () => {
mockExecuteFunctions.helpers.httpRequest.mockResolvedValue({

View file

@ -1,4 +1,5 @@
import axios from 'axios';
import { extension } from 'mime-types';
import type { IDataObject, IExecuteFunctions } from 'n8n-workflow';
import { NodeOperationError } from 'n8n-workflow';
import { Readable } from 'node:stream';
@ -35,6 +36,18 @@ interface UploadStreamConfig {
const CHUNK_SIZE = 256 * 1024;
export function getFilenameFromMimeType(
mimeType: string | undefined,
baseName: string,
fallbackExtension: string,
): string {
if (!mimeType) {
return `${baseName}.${fallbackExtension}`;
}
return `${baseName}.${extension(mimeType) || fallbackExtension}`;
}
export async function downloadFile(
this: IExecuteFunctions,
url: string,