mirror of
https://github.com/n8n-io/n8n
synced 2026-04-21 15:47:20 +00:00
fix(Google Gemini Node): Determine the file extention from MIME type for image and video operations (#28616)
This commit is contained in:
parent
4070930e4c
commit
73659cb3e7
8 changed files with 223 additions and 17 deletions
|
|
@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue