From 1e1ab5137c16fd8fb2f243f3f08d1602018db182 Mon Sep 17 00:00:00 2001 From: Maciej Komorowski Date: Wed, 17 Jun 2026 12:54:16 +0200 Subject: [PATCH] feat: add uploader.generate to generate and upload AI images Adds cloudinary.v2.uploader.generate(generateParams, options, callback), which generates an image from a text prompt and uploads the result in a single call. It calls the image generation API, then delegates to uploader.upload using the secure_url from the response, so the resolved value is the upload result and all standard upload options are honored. - New internal client call_generate_api (JSON + Basic Auth/OAuth) - New base_processing_api_url helper for the v2 processing endpoint - TypeScript types (GenerateImageOptions + generate overloads) - Tests mirroring existing conventions --- lib/api_client/call_generate_api.js | 32 ++++ lib/uploader.js | 38 ++++ lib/utils/index.js | 11 ++ lib/v2/uploader.js | 1 + .../integration/api/uploader/generate_spec.js | 168 ++++++++++++++++++ types/cloudinary_ts_spec.ts | 23 +++ types/index.d.ts | 23 +++ 7 files changed, 296 insertions(+) create mode 100644 lib/api_client/call_generate_api.js create mode 100644 test/integration/api/uploader/generate_spec.js diff --git a/lib/api_client/call_generate_api.js b/lib/api_client/call_generate_api.js new file mode 100644 index 00000000..0be079d6 --- /dev/null +++ b/lib/api_client/call_generate_api.js @@ -0,0 +1,32 @@ +const utils = require("../utils"); +const config = require("../config"); +const ensureOption = require('../utils/ensureOption').defaults(config()); +const execute_request = require("./execute_request"); + +const {ensurePresenceOf} = utils; + +function call_generate_api(method, uri, params, callback, options) { + ensurePresenceOf({ + method, + uri + }); + const api_url = utils.base_processing_api_url()(uri, options); + let auth = {}; + if (options.oauth_token || config().oauth_token) { + auth = { + oauth_token: ensureOption(options, "oauth_token") + }; + } else { + auth = { + key: ensureOption(options, "api_key"), + secret: ensureOption(options, "api_secret") + }; + } + options.content_type = 'json'; + + return execute_request(method, params, auth, api_url, callback, options); +} + +module.exports = { + call_generate_api +}; diff --git a/lib/uploader.js b/lib/uploader.js index 7f224ff9..a930ba16 100644 --- a/lib/uploader.js +++ b/lib/uploader.js @@ -15,6 +15,7 @@ const { URL } = require('url'); const Cache = require('./cache'); const utils = require("./utils"); const UploadStream = require('./upload_stream'); +const { call_generate_api } = require('./api_client/call_generate_api'); const config = require("./config"); const ensureOption = require('./utils/ensureOption').defaults(config()); @@ -58,6 +59,43 @@ exports.upload = function upload(file, callback, options = {}) { }); }; +/** + * Generates an image from a text prompt and uploads the generated image to your product environment. + * + * This is a convenience wrapper that performs two steps: it first calls the image generation API with the + * provided generation parameters, and then uploads the generated image - identified by the `secure_url` + * returned by the generation API - by calling {@link upload}. The value resolved by the returned promise (and + * passed to the callback) is the result of the upload, not of the generation request. Any options are forwarded + * as-is to the upload step. + * + * @param {Object} generate_params Parameters for the generation request. + * @param {String} generate_params.prompt The text description of the image to generate (required). + * @param {String} [generate_params.model_family] The model family to use. + * @param {String} [generate_params.quality_tier] The quality tier to use within the model family. + * @param {String} [generate_params.model_id] A specific model identifier, overriding model_family/quality_tier. + * @param {Number} [generate_params.width] Desired image width in pixels. + * @param {Number} [generate_params.height] Desired image height in pixels. + * @param {Number} [generate_params.seed] Seed for reproducible generation. + * @param {Function} callback Callback function, invoked with the upload result. + * @param {Object} options Configuration options forwarded to the upload step + * (for example upload_preset, tags, folder). + * + * @return {Promise} A promise resolving with the upload result of the generated image. + */ +exports.generate = function generate(generate_params, callback, options = {}) { + return call_generate_api("POST", ["generate", "image"], generate_params, undefined, options) + .then((result) => { + const assets = result && result.data && result.data.assets; + const secure_url = Array.isArray(assets) && assets.length ? assets[0].secure_url : undefined; + return exports.upload(secure_url, callback, options); + }, (error) => { + if (typeof callback === "function") { + callback(error && error.error != null ? error : { error }); + } + return Promise.reject(error); + }); +}; + exports.upload_large = function upload_large(path, callback, options = {}) { if ((path != null) && isRemoteUrl(path)) { // upload a remote file diff --git a/lib/utils/index.js b/lib/utils/index.js index d74c738f..e1bf59fd 100644 --- a/lib/utils/index.js +++ b/lib/utils/index.js @@ -1064,6 +1064,16 @@ function base_api_url(api_version) { }; } +function base_processing_api_url() { + return (path = [], options = {}) => { + let cloudinary = ensureOption(options, "upload_prefix", UPLOAD_PREFIX); + let cloud_name = ensureOption(options, "cloud_name"); + let encode_path = unencoded_path => encodeURIComponent(unencoded_path).replace("'", '%27'); + let encoded_path = Array.isArray(path) ? path.map(encode_path) : encode_path(path); + return [cloudinary, 'v2', 'processing', cloud_name].concat(encoded_path).join("/"); + }; +} + function api_url(action = 'upload', options = {}) { let resource_type = options.resource_type || "image"; return base_api_url_v1_1()([resource_type, action], options); @@ -1724,6 +1734,7 @@ exports.jsonArrayParam = jsonArrayParam; exports.download_folder = download_folder; exports.base_api_url_v1 = base_api_url_v1_1; exports.base_api_url_v2 = base_api_url_v2; +exports.base_processing_api_url = base_processing_api_url; exports.download_backedup_asset = download_backedup_asset; exports.compute_hash = compute_hash; exports.build_distribution_domain = build_distribution_domain; diff --git a/lib/v2/uploader.js b/lib/v2/uploader.js index 5c1bd6d1..e83a1300 100644 --- a/lib/v2/uploader.js +++ b/lib/v2/uploader.js @@ -6,6 +6,7 @@ v1_adapters(exports, uploader, { upload_stream: 0, unsigned_upload: 2, upload: 1, + generate: 1, upload_large_part: 0, upload_large: 1, upload_chunked: 1, diff --git a/test/integration/api/uploader/generate_spec.js b/test/integration/api/uploader/generate_spec.js new file mode 100644 index 00000000..f96a1c9a --- /dev/null +++ b/test/integration/api/uploader/generate_spec.js @@ -0,0 +1,168 @@ +const expect = require('expect.js'); +const sinon = require('sinon'); +const https = require('https'); +const { EventEmitter } = require('events'); +const cloudinary = require('../../../../cloudinary'); +const uploader = require('../../../../lib/uploader'); +const createTestConfig = require('../../../testUtils/createTestConfig'); + +const CLOUD_NAME = 'test-cloud'; +const SECURE_URL = 'https://res.cloudinary.com/test-cloud/image/upload/generated.png'; + +describe('uploader generate', function () { + let requestStub; + let uploadStub; + let capturedOptions; + let capturedBody; + + beforeEach(function () { + cloudinary.config(createTestConfig({ + cloud_name: CLOUD_NAME, + api_key: 'test-key', + api_secret: 'test-secret' + })); + capturedOptions = null; + capturedBody = null; + }); + + afterEach(function () { + if (requestStub && requestStub.restore) { + requestStub.restore(); + } + if (uploadStub && uploadStub.restore) { + uploadStub.restore(); + } + requestStub = null; + uploadStub = null; + }); + + // Stub https.request to emit a JSON response with the given status code and body. + function stubGenerateRequest(statusCode, responseBody) { + const mockResponse = new EventEmitter(); + mockResponse.statusCode = statusCode; + mockResponse.headers = {}; + + requestStub = sinon.stub(https, 'request').callsFake(function (options, callback) { + capturedOptions = options; + setTimeout(() => callback(mockResponse), 0); + + const mockRequest = new EventEmitter(); + mockRequest.write = sinon.stub().callsFake((data) => { + capturedBody = data; + }); + mockRequest.end = function () { + setTimeout(() => { + mockResponse.emit('data', JSON.stringify(responseBody)); + mockResponse.emit('end'); + }, 10); + }; + mockRequest.setTimeout = sinon.stub(); + + return mockRequest; + }); + } + + function generateSuccessBody(secure_url = SECURE_URL) { + return { + data: { + assets: [ + { + secure_url, + format: 'png', + width: 1024, + height: 768, + bytes: 2048576, + model: { family: 'flux', tier: 'premium', model_id: 'flux-2-pro' }, + created_at: '2026-04-21T14:30:00Z' + } + ] + }, + request_id: 'test-request-id' + }; + } + + it('should call the generate endpoint with the generation params as a JSON body', function () { + stubGenerateRequest(200, generateSuccessBody()); + // Prevent the upload step from issuing a real request. + uploadStub = sinon.stub(uploader, 'upload').resolves({ secure_url: SECURE_URL }); + + return cloudinary.v2.uploader.generate({ prompt: 'A man with a hat', model_family: 'flux' }).then(() => { + sinon.assert.calledWith(requestStub, sinon.match({ + pathname: sinon.match(new RegExp(`/v2/processing/${CLOUD_NAME}/generate/image`)), + method: sinon.match('POST') + })); + expect(capturedOptions.headers['Content-Type']).to.eql('application/json'); + const body = JSON.parse(capturedBody); + expect(body.prompt).to.eql('A man with a hat'); + expect(body.model_family).to.eql('flux'); + }); + }); + + it('should upload the generated image and resolve with the upload result', function () { + stubGenerateRequest(200, generateSuccessBody()); + const uploadResult = { public_id: 'generated', secure_url: SECURE_URL }; + uploadStub = sinon.stub(uploader, 'upload').resolves(uploadResult); + + const options = { upload_preset: 'my_preset', tags: ['generated'] }; + return cloudinary.v2.uploader.generate({ prompt: 'A man with a hat' }, options).then((result) => { + sinon.assert.calledWith(uploadStub, SECURE_URL); + // Options are forwarded to the upload step. + const forwardedOptions = uploadStub.firstCall.args[2]; + expect(forwardedOptions.upload_preset).to.eql('my_preset'); + expect(result).to.eql(uploadResult); + }); + }); + + it('should forward the callback to the upload step on success', function (done) { + stubGenerateRequest(200, generateSuccessBody()); + const uploadResult = { public_id: 'generated', secure_url: SECURE_URL }; + // Mimic the real upload by invoking the callback it receives. + uploadStub = sinon.stub(uploader, 'upload').callsFake((file, callback) => { + if (typeof callback === 'function') { + callback(uploadResult); + } + return Promise.resolve(uploadResult); + }); + + cloudinary.v2.uploader.generate({ prompt: 'A man with a hat' }, function (error, result) { + try { + expect(error).to.be(undefined); + expect(result).to.eql(uploadResult); + done(); + } catch (e) { + done(e); + } + }); + }); + + it('should not call upload and should reject when generation fails', function () { + stubGenerateRequest(400, { error: { message: 'missing parameters' } }); + uploadStub = sinon.stub(uploader, 'upload').resolves({}); + + return cloudinary.v2.uploader.generate({ prompt: '' }).then(() => { + throw new Error('Expected generate to reject'); + }, (error) => { + sinon.assert.notCalled(uploadStub); + expect(error).to.be.ok(); + }); + }); + + it('should invoke the callback with the error when generation fails', function (done) { + stubGenerateRequest(400, { error: { message: 'missing parameters' } }); + uploadStub = sinon.stub(uploader, 'upload').resolves({}); + + cloudinary.v2.uploader.generate({ prompt: '' }, function (error, result) { + try { + expect(error).to.be.ok(); + expect(error.message).to.eql('missing parameters'); + expect(result).to.be(undefined); + sinon.assert.notCalled(uploadStub); + done(); + } catch (e) { + done(e); + } + }).catch(() => { + // Swallow the rejected promise; assertions are made in the callback. + }); + }); +}); diff --git a/types/cloudinary_ts_spec.ts b/types/cloudinary_ts_spec.ts index d046009d..02d2f1c4 100644 --- a/types/cloudinary_ts_spec.ts +++ b/types/cloudinary_ts_spec.ts @@ -900,6 +900,29 @@ cloudinary.v2.uploader.upload("ftp://user1:mypass@ftp.example.com/sample.jpg", console.log(result, error); }); +// $ExpectType Promise +cloudinary.v2.uploader.generate({prompt: "A man with a hat"}, + function (error, result) { + console.log(result, error); + }); + +// $ExpectType Promise +cloudinary.v2.uploader.generate( + { + prompt: "A photorealistic sunset over a mountain lake", + model_family: "flux", + quality_tier: "premium", + width: 1024, + height: 768 + }, + { + upload_preset: "my_preset", + tags: ["generated"] + }, + function (error, result) { + console.log(result, error); + }); + // $ExpectType UploadStream | Promise cloudinary.v2.uploader.upload_large("my_large_video.mp4", { diff --git a/types/index.d.ts b/types/index.d.ts index 1164bce7..5dbd0051 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -502,6 +502,25 @@ declare module 'cloudinary' { [futureKey: string]: any; } + export interface GenerateImageOptions { + /** The text description of the image to generate. */ + prompt: string; + /** The model family to use. */ + model_family?: string; + /** The quality tier to use within the model family. */ + quality_tier?: string; + /** A specific model identifier, overriding model_family/quality_tier. */ + model_id?: string; + /** Desired image width in pixels. */ + width?: number; + /** Desired image height in pixels. */ + height?: number; + /** Seed for reproducible generation. */ + seed?: number; + + [futureKey: string]: any; + } + export interface UploadApiOptions { access_mode?: AccessMode; allowed_formats?: Array | Array; @@ -1406,6 +1425,10 @@ declare module 'cloudinary' { function upload(file: string, callback?: UploadResponseCallback): Promise; + function generate(params: GenerateImageOptions, options?: UploadApiOptions, callback?: UploadResponseCallback): Promise; + + function generate(params: GenerateImageOptions, callback?: UploadResponseCallback): Promise; + function upload_chunked(path: string, options?: UploadApiOptions, callback?: UploadResponseCallback): UploadStream; function upload_chunked(path: string, callback?: UploadResponseCallback): UploadStream;