| const { fromBuffer } = require('file-type'); |
| const FormData = require("form-data"); |
| const crypto = require("node:crypto"); |
| const axios = require("axios"); |
| const Jimp = require("jimp"); |
|
|
| class GridPlus { |
| constructor() { |
| this.ins = axios.create({ |
| baseURL: 'https://api.grid.plus/v1', |
| headers: { |
| 'user-agent': 'Mozilla/5.0 (Android 15; Mobile; SM-F958; rv:130.0) Gecko/130.0 Firefox/130.0', |
| 'X-AppID': '808645', |
| 'X-Platform': 'h5', |
| 'X-Version': '8.9.7', |
| 'X-SessionToken': '', |
| 'X-UniqueID': this.uid(), |
| 'X-GhostID': this.uid(), |
| 'X-DeviceID': this.uid(), |
| 'X-MCC': 'id-ID', |
| sig: `XX${this.uid()+this.uid()}` |
| } |
| }) |
| } |
| |
| uid() { |
| return crypto.randomUUID().replace(/-/g, '') |
| } |
| |
| form(dt) { |
| const form = new FormData(); |
| Object.entries(dt).forEach(([key, value]) => { |
| form.append(key, String(value)); |
| }); |
| return form |
| } |
| |
| async upload(buff, method) { |
| try { |
| if (!Buffer.isBuffer(buff)) throw 'data is not buffer!'; |
| const { mime, ext } = (await fromBuffer(buff)) || {}; |
| const d = await this.ins.post('/ai/web/nologin/getuploadurl', this.form({ |
| ext, method |
| })).then(i => i.data); |
| await axios.put(d.data.upload_url, buff, { |
| headers: { |
| 'content-type': mime |
| } |
| }); |
| return d.data.img_url; |
| } catch(e) { |
| throw new Error('An error occurred while uploading the image'); |
| } |
| } |
| |
| async task({ path, data, sl = () => false }) { |
| const [start, interval, timeout] = [ |
| Date.now(), 3000, 60000 |
| ]; |
| return new Promise(async (resolve, reject) => { |
| const check = async () => { |
| if (Date.now() - start > timeout) { |
| return reject(new Error(`Polling timed out for task`)); |
| }; |
| try { |
| const dt = await this.ins({ |
| url: path, |
| method: data ? 'POST' : 'GET', |
| ...(data ? { data } : {}) |
| }); |
| if (!!dt.data.errmsg?.trim()) { |
| reject(new Error(`Something a error with message: ${dt.data.errmsg}`)); |
| }; |
| if (!!sl(dt.data)) { |
| return resolve(dt.data); |
| }; |
| setTimeout(check, interval); |
| } catch (error) { |
| reject(error); |
| } |
| }; |
| check(); |
| }); |
| } |
| |
| async edit(buff, prompt) { |
| try { |
| const up = await this.upload(buff, 'wn_aistyle_nano'); |
| const dn = await this.ins.post('/ai/nano/upload', this.form({ |
| prompt, url: up |
| })).then(i => i.data); |
| if (!dn.task_id) throw 'taskId not found on request'; |
| const res = await this.task({ |
| path: `/ai/nano/get_result/${dn.task_id}`, |
| sl: (dt) => dt.code === 0 && !!dt.image_url, |
| }); |
| return res.image_url |
| } catch(e) { |
| throw new Error('Something error, message: ' + e.message); |
| } |
| } |
| } |
|
|
| async function createGrid(imageUrls) { |
| const imageBuffers = await Promise.all( |
| imageUrls.map(url => axios.get(url, { responseType: 'arraybuffer' }).then(r => Buffer.from(r.data))) |
| ); |
|
|
| const images = await Promise.all( |
| imageBuffers.map(async buffer => { |
| const img = await Jimp.default ? Jimp.default.read(buffer) : new Promise((resolve, reject) => { |
| new Jimp(buffer, (err, image) => { |
| if (err) reject(err); |
| else resolve(image); |
| }); |
| }); |
| return img; |
| }) |
| ); |
|
|
| const count = images.length; |
| const targetSize = 1024; |
| |
| let cols, rows; |
| if (count === 1) { |
| const img = images[0]; |
| await img.resize(targetSize, targetSize); |
| return await img.getBufferAsync(Jimp.MIME_JPEG); |
| } else if (count === 2) { |
| cols = 2; |
| rows = 1; |
| } else if (count === 3) { |
| cols = 2; |
| rows = 2; |
| } else { |
| cols = 2; |
| rows = 2; |
| } |
|
|
| const cellSize = Math.floor(targetSize / cols); |
| const gridHeight = cellSize * rows; |
| const grid = await new Jimp(targetSize, gridHeight, 0xFFFFFFFF); |
|
|
| for (let i = 0; i < count && i < 4; i++) { |
| const col = i % cols; |
| const row = Math.floor(i / cols); |
| const x = col * cellSize; |
| const y = row * cellSize; |
| |
| const resized = await images[i].cover(cellSize, cellSize); |
| grid.composite(resized, x, y); |
| } |
|
|
| return await grid.getBufferAsync(Jimp.MIME_JPEG); |
| } |
|
|
| const handler = async (req, res) => { |
| try { |
| const { prompt, img_url1, img_url2, img_url3, img_url4, key } = req.query; |
|
|
| if (!key) { |
| return res.status(400).json({ |
| success: false, |
| error: 'Missing required parameter: key' |
| }); |
| } |
|
|
| if (!prompt) { |
| return res.status(400).json({ |
| success: false, |
| error: 'Missing required parameter: prompt' |
| }); |
| } |
|
|
| const imageUrls = [img_url1, img_url2, img_url3, img_url4].filter(Boolean); |
|
|
| if (imageUrls.length === 0) { |
| return res.status(400).json({ |
| success: false, |
| error: 'At least one image URL is required (img_url1)' |
| }); |
| } |
|
|
| if (imageUrls.length > 4) { |
| return res.status(400).json({ |
| success: false, |
| error: 'Maximum 4 images allowed' |
| }); |
| } |
|
|
| const imageBuffer = await createGrid(imageUrls); |
| |
| const gridPlus = new GridPlus(); |
| const result = await gridPlus.edit(imageBuffer, prompt); |
|
|
| return res.json({ |
| author: "Herza", |
| success: true, |
| data: { |
| prompt: prompt, |
| image_url: result, |
| original_urls: imageUrls, |
| image_count: imageUrls.length |
| }, |
| timestamp: new Date().toISOString() |
| }); |
|
|
| } catch (error) { |
| res.status(500).json({ |
| success: false, |
| error: error.message, |
| timestamp: new Date().toISOString() |
| }); |
| } |
| }; |
|
|
| module.exports = { |
| name: 'Nano Banana', |
| description: 'AI Image Editing using Grid Plus - Edit 1-4 images with text prompts', |
| type: 'GET', |
| routes: ['api/AI/nanobanana'], |
| tags: ['ai', 'image', 'editing', 'nanobanana', 'multi-image'], |
| parameters: ['prompt', 'img_url1', 'img_url2', 'img_url3', 'img_url4', 'key'], |
| limit: 5, |
| enabled: true, |
| main: ['AI'], |
| handler |
| }; |