diff --git a/packages/@n8n/computer-use/package.json b/packages/@n8n/computer-use/package.json index 8c6e34c0a1f3e..5580a9ba4c495 100644 --- a/packages/@n8n/computer-use/package.json +++ b/packages/@n8n/computer-use/package.json @@ -39,11 +39,11 @@ "@jitsi/robotjs": "^0.6.21", "@modelcontextprotocol/sdk": "1.26.0", "@n8n/mcp-browser": "workspace:*", + "@napi-rs/image": "^1.12.0", "@vscode/ripgrep": "^1.17.1", "eventsource": "^3.0.6", "node-screenshots": "^0.2.8", "picocolors": "catalog:", - "sharp": "^0.34.5", "yargs-parser": "21.1.1", "zod": "catalog:", "zod-to-json-schema": "catalog:" diff --git a/packages/@n8n/computer-use/src/sharp.d.ts b/packages/@n8n/computer-use/src/sharp.d.ts deleted file mode 100644 index f346a068a5bdd..0000000000000 --- a/packages/@n8n/computer-use/src/sharp.d.ts +++ /dev/null @@ -1,18 +0,0 @@ -declare module 'sharp' { - interface Sharp { - resize(width: number, height?: number): Sharp; - png(): Sharp; - jpeg(options?: { quality?: number }): Sharp; - toBuffer(): Promise; - metadata(): Promise<{ width?: number; height?: number; format?: string }>; - } - - interface SharpOptions { - raw?: { width: number; height: number; channels: 1 | 2 | 3 | 4 }; - } - - function sharp(input?: Buffer | string, options?: SharpOptions): Sharp; - - // eslint-disable-next-line import-x/no-default-export - export default sharp; -} diff --git a/packages/@n8n/computer-use/src/tools/screenshot/screenshot.test.ts b/packages/@n8n/computer-use/src/tools/screenshot/screenshot.test.ts index 4b988991ea9ea..3093ab1224abb 100644 --- a/packages/@n8n/computer-use/src/tools/screenshot/screenshot.test.ts +++ b/packages/@n8n/computer-use/src/tools/screenshot/screenshot.test.ts @@ -5,11 +5,14 @@ import { screenshotTool, screenshotRegionTool } from './screenshot'; jest.mock('node-screenshots'); -const mockSharp = jest.fn(); -jest.mock('sharp', () => ({ +const mockFromRgbaPixels = jest.fn(); +jest.mock('@napi-rs/image', () => ({ __esModule: true, - // eslint-disable-next-line @typescript-eslint/no-unsafe-return - default: (...args: unknown[]) => mockSharp(...args), + // eslint-disable-next-line @typescript-eslint/naming-convention + Transformer: { + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + fromRgbaPixels: (...args: unknown[]) => mockFromRgbaPixels(...args), + }, })); const MockMonitor = Monitor as jest.MockedClass; @@ -75,13 +78,11 @@ function makeMockMonitor(opts: { } beforeEach(() => { - // sharp(buffer, opts)[.resize()].jpeg().toBuffer() → fake JPEG - const mockToBuffer = jest.fn().mockResolvedValue(Buffer.from('fake-jpeg')); - const mockJpeg = jest.fn().mockReturnValue({ toBuffer: mockToBuffer }); + const mockJpeg = jest.fn().mockResolvedValue(Buffer.from('fake-jpeg')); const mockResize = jest.fn(); const pipeline = { resize: mockResize, jpeg: mockJpeg }; mockResize.mockReturnValue(pipeline); - mockSharp.mockReturnValue(pipeline); + mockFromRgbaPixels.mockReturnValue(pipeline); }); describe('screen_screenshot tool', () => { @@ -136,7 +137,7 @@ describe('screen_screenshot tool', () => { await screenshotTool.execute({}, DUMMY_CONTEXT); - const pipeline = mockSharp.mock.results[0].value as { resize: jest.Mock }; + const pipeline = mockFromRgbaPixels.mock.results[0].value as { resize: jest.Mock }; expect(pipeline.resize).toHaveBeenCalledWith(1920, 1080); }); @@ -151,7 +152,7 @@ describe('screen_screenshot tool', () => { await screenshotTool.execute({}, DUMMY_CONTEXT); - const pipeline = mockSharp.mock.results[0].value as { resize: jest.Mock }; + const pipeline = mockFromRgbaPixels.mock.results[0].value as { resize: jest.Mock }; // No HiDPI resize, but LLM downscale kicks in (1920x1080 → 1024x576) expect(pipeline.resize).toHaveBeenCalledWith(1024, 576); }); @@ -252,7 +253,7 @@ describe('screen_screenshot_region tool', () => { await screenshotRegionTool.execute({ x: 100, y: 200, width: 400, height: 300 }, DUMMY_CONTEXT); // Cropped image (800×600 physical) must be resized to logical 400×300 - const pipeline = mockSharp.mock.results[0].value as { resize: jest.Mock }; + const pipeline = mockFromRgbaPixels.mock.results[0].value as { resize: jest.Mock }; expect(pipeline.resize).toHaveBeenCalledWith(400, 300); }); }); diff --git a/packages/@n8n/computer-use/src/tools/screenshot/screenshot.ts b/packages/@n8n/computer-use/src/tools/screenshot/screenshot.ts index c2fc92ff14a12..b9cd47f0e5ffb 100644 --- a/packages/@n8n/computer-use/src/tools/screenshot/screenshot.ts +++ b/packages/@n8n/computer-use/src/tools/screenshot/screenshot.ts @@ -19,8 +19,8 @@ async function toJpeg( logicalWidth?: number, logicalHeight?: number, ): Promise { - const { default: sharp } = await import('sharp'); - let pipeline = sharp(rawBuffer, { raw: { width, height, channels: 4 } }); + const { Transformer } = await import('@napi-rs/image'); + let pipeline = Transformer.fromRgbaPixels(rawBuffer, width, height); if (logicalWidth && logicalHeight && (width !== logicalWidth || height !== logicalHeight)) { pipeline = pipeline.resize(logicalWidth, logicalHeight); } @@ -32,7 +32,7 @@ async function toJpeg( const scale = maxDim / Math.max(w, h); pipeline = pipeline.resize(Math.round(w * scale), Math.round(h * scale)); } - return await pipeline.jpeg({ quality: 85 }).toBuffer(); + return await pipeline.jpeg(85); } export const screenshotTool: ToolDefinition = { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b7d5c87182111..961405d8c9eb5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1097,6 +1097,9 @@ importers: '@n8n/mcp-browser': specifier: workspace:* version: link:../mcp-browser + '@napi-rs/image': + specifier: ^1.12.0 + version: 1.12.0 '@vscode/ripgrep': specifier: ^1.17.1 version: 1.17.1 @@ -1109,9 +1112,6 @@ importers: picocolors: specifier: 'catalog:' version: 1.0.1 - sharp: - specifier: npm:empty-npm-package@1.0.0 - version: empty-npm-package@1.0.0 yargs-parser: specifier: 21.1.1 version: 21.1.1 @@ -8162,6 +8162,91 @@ packages: resolution: {integrity: sha512-nD6NGa4JbNYSZYsTnLGrqe9Kn/lCkA4ybXt8sx5ojDqZjr2i0TWAHxx/vhgfjX+i3hCdKWufxYwi7CfXqtITSA==} engines: {node: '>= 10'} + '@napi-rs/image-android-arm64@1.12.0': + resolution: {integrity: sha512-MAm8EHmtO47OZYsHgiMuP+nYZOEbNWbHjkoNfRS9wFJiRQ5p/pIlvdeWL9DqkSrjcgHjIJXLcrt94MMF1jXOuw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [android] + + '@napi-rs/image-darwin-arm64@1.12.0': + resolution: {integrity: sha512-NXDXy9qNmNdesKCTWMcKa9QHP74Ut75Lwi4psUzo5e7ptOeK6ACIanVPynnfWGMUXY4pTIXvGooLf5mbn6r7iQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@napi-rs/image-darwin-x64@1.12.0': + resolution: {integrity: sha512-577Ysv/m60Pmv2rahxf2c6ChfNwGyAs3Pyoopao6YxknXnq+M8/x8PW3f9Gxg9+a/nxxnFIYoMDkD1GeQRecXg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@napi-rs/image-freebsd-x64@1.12.0': + resolution: {integrity: sha512-WwQvpQ8FCpFjEHuI5nGKl67oPj4Yc5clbNrSy2wBzIcmuuOjfzGFi96MDZU6gQvRYZz+jbGa71+Fk3bMtbp8ew==} + engines: {node: '>= 10'} + cpu: [x64] + os: [freebsd] + + '@napi-rs/image-linux-arm-gnueabihf@1.12.0': + resolution: {integrity: sha512-UaIVUREI7q9NpR6mBNyGJ7D8S4vdQ65X2RoUmAv7f89ILvKobyyn8LFx8MqjPU5UpmWQhoU5nATQzKsYF70HoQ==} + engines: {node: '>= 10'} + cpu: [arm] + os: [linux] + + '@napi-rs/image-linux-arm64-gnu@1.12.0': + resolution: {integrity: sha512-QwZRPAJjtRAPtrtvn8slmtcMtAWLd15kRL6BxBaAA/VHR/sOzKxCbXbapzf/nHUJUsO7Gy3wQu0SKr7Wjp5K9w==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@napi-rs/image-linux-arm64-musl@1.12.0': + resolution: {integrity: sha512-1laZq0DdtuI6DBFkGRB0tSv/STo11qjGKfCVUndt3JbP4nidO4enRDjofBTu5ROFcxqSdIq/t7hYhlS+BeJRnw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@napi-rs/image-linux-x64-gnu@1.12.0': + resolution: {integrity: sha512-TaaITRqJXnRip1kV2o+M6dMFyQdKs8zlPHyxZU6sfbjyPfgtkliKXR6kxxug2MVCr7jXuAFdrJRCdfyAeK4MIQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@napi-rs/image-linux-x64-musl@1.12.0': + resolution: {integrity: sha512-QdvbOh+lekT+gKpYhINOHd4ruEYQHzEVhNqswZ2V3mWH5tbXS9ypjzzfZ1UVeHNfce3NooZ+XsZVGITX/KyUwQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@napi-rs/image-wasm32-wasi@1.12.0': + resolution: {integrity: sha512-tlLe2+4RdJfkT8DOGJ7KpUGYxlhvqaYCKuPoyO2x/LPnfF2kSKO9nLIdBhCNw7v61gfxIEpr2ccz01Mw8FeSjA==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + + '@napi-rs/image-win32-arm64-msvc@1.12.0': + resolution: {integrity: sha512-JlyIKiwhMRq0MUGIILaHeqS0Rc2kazdN9t0cjv7DrN5FLwTPmXFQyDsgGEW8oFJ08DKpbn6Dfy/5UogjII+hKg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@napi-rs/image-win32-ia32-msvc@1.12.0': + resolution: {integrity: sha512-nzg7bUJWkVz05zbsBmu4fZ5wj1KFyBbJwEV4OuV5qdz0IFAl37NEannO0yg0Yi9Amavgqmr5y7OmA2TiandpPA==} + engines: {node: '>= 10'} + cpu: [ia32] + os: [win32] + + '@napi-rs/image-win32-x64-msvc@1.12.0': + resolution: {integrity: sha512-tTS4k9Bep2bJbymuu523sp8MZDdUaTkjORWkiwOHPFULt2e+pgnCCQ6+gXJ7WAWXvdwKn06gfdMsC2DM8k5EtA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@napi-rs/image@1.12.0': + resolution: {integrity: sha512-hfW9EvlUj4R+RYu5Cgcm9/d/2eMTj3Dhypj0BD+snasawBpYwSfkPZx/6C1Hm2cVCbRT6zovGdSHUXwPLb29dw==} + engines: {node: '>= 10'} + '@napi-rs/wasm-runtime@0.2.11': resolution: {integrity: sha512-9DPkXtvHydrcOsopiYpUgPHpmj0HWZKMUnL2dZqpvC42lsratuBG06V5ipyno0fUek5VlFsNQ+AcFATSrJXgMA==} @@ -25525,7 +25610,7 @@ snapshots: '@currents/commit-info': 1.0.1-beta.0 async-retry: 1.3.3 axios: 1.15.0(debug@4.4.3) - axios-retry: 4.5.0(axios@1.15.0(debug@4.4.3)) + axios-retry: 4.5.0(axios@1.15.0) c12: 1.11.2(magicast@0.3.5) chalk: 4.1.2 commander: 12.1.0 @@ -27787,6 +27872,63 @@ snapshots: '@napi-rs/canvas-win32-x64-msvc': 0.1.70 optional: true + '@napi-rs/image-android-arm64@1.12.0': + optional: true + + '@napi-rs/image-darwin-arm64@1.12.0': + optional: true + + '@napi-rs/image-darwin-x64@1.12.0': + optional: true + + '@napi-rs/image-freebsd-x64@1.12.0': + optional: true + + '@napi-rs/image-linux-arm-gnueabihf@1.12.0': + optional: true + + '@napi-rs/image-linux-arm64-gnu@1.12.0': + optional: true + + '@napi-rs/image-linux-arm64-musl@1.12.0': + optional: true + + '@napi-rs/image-linux-x64-gnu@1.12.0': + optional: true + + '@napi-rs/image-linux-x64-musl@1.12.0': + optional: true + + '@napi-rs/image-wasm32-wasi@1.12.0': + dependencies: + '@napi-rs/wasm-runtime': 1.1.1 + optional: true + + '@napi-rs/image-win32-arm64-msvc@1.12.0': + optional: true + + '@napi-rs/image-win32-ia32-msvc@1.12.0': + optional: true + + '@napi-rs/image-win32-x64-msvc@1.12.0': + optional: true + + '@napi-rs/image@1.12.0': + optionalDependencies: + '@napi-rs/image-android-arm64': 1.12.0 + '@napi-rs/image-darwin-arm64': 1.12.0 + '@napi-rs/image-darwin-x64': 1.12.0 + '@napi-rs/image-freebsd-x64': 1.12.0 + '@napi-rs/image-linux-arm-gnueabihf': 1.12.0 + '@napi-rs/image-linux-arm64-gnu': 1.12.0 + '@napi-rs/image-linux-arm64-musl': 1.12.0 + '@napi-rs/image-linux-x64-gnu': 1.12.0 + '@napi-rs/image-linux-x64-musl': 1.12.0 + '@napi-rs/image-wasm32-wasi': 1.12.0 + '@napi-rs/image-win32-arm64-msvc': 1.12.0 + '@napi-rs/image-win32-ia32-msvc': 1.12.0 + '@napi-rs/image-win32-x64-msvc': 1.12.0 + '@napi-rs/wasm-runtime@0.2.11': dependencies: '@emnapi/core': 1.8.1 @@ -32929,11 +33071,6 @@ snapshots: axe-core@4.7.2: {} - axios-retry@4.5.0(axios@1.15.0(debug@4.4.3)): - dependencies: - axios: 1.15.0(debug@4.4.3) - is-retry-allowed: 2.2.0 - axios-retry@4.5.0(axios@1.15.0): dependencies: axios: 1.15.0