Automating Blog Post Headers with Firefly Services
27 March 2024 | 6:00 pm

Yesterday I introduced you to Adobe's new offering, Firefly Services, and demonstrated a simple example of how to generate images from prompt using the REST APIs. Today I thought I'd share one of the little demos I've made with the API, and one specifically built to help out with my blog - generating headers.

My usual process for headers is to go to the Firefly website, enter a prompt, let it load, and then promptly change it to landscape and re-generate my prompt again. I always feel bad that the initial, square, images are essentially trashed. It occurred to me I could build a Node.js utility to generate the images at the exact right size and even quickly display them. Here's how I did it.

First, I designed the CLI so I can simply pass in a prompt. Here's how I handled that:

if(process.argv.length < 3) {	console.log(styleText('red', 'Usage: makeheader.js <<prompt>>'));	process.exit(1);} const prompt = process.argv[2];console.log(styleText('green', `Generating headers for: ${prompt}`));

Next, I authenticate, and create my images:

let token = await getFFAccessToken(FF_CLIENT_ID, FF_CLIENT_SECRET);let result = await textToImage(prompt, FF_CLIENT_ID, token);

I showed both of these methods yesterday, but my parameters for the Firefly API to generate images are slightly tweaked though. First, the authentication method again:

async function getFFAccessToken(id, secret) {	const params = new URLSearchParams();	params.append('grant_type', 'client_credentials');	params.append('client_id', id);	params.append('client_secret', secret);	params.append('scope', 'openid,AdobeID,session,additional_info,read_organizations,firefly_api,ff_apis');		let resp = await fetch('https://ims-na1.adobelogin.com/ims/token/v3', 		{ 			method: 'POST', 			body: params		}	);	let data = await resp.json();	return data.access_token;}

And here's the call to the text to image API:

async function textToImage(text, id, token) {	let body = {		"n":4,		"prompt":text,		"size":{			"width":"2304",			"height":"1792"		}	}	let req = await fetch('https://firefly-api.adobe.io/v2/images/generate', {		method:'POST',		headers: {			'X-Api-Key':id, 			'Authorization':`Bearer ${token}`,			'Content-Type':'application/json'		}, 		body: JSON.stringify(body)	});	let resp = await req.json();	return resp;}

Note two things here:

  • First, I set n to 4 so I get 4 results, not the default of 1.
  • My size is hard coded to the landscape size.

Ok, so that's the easy bit honestly. But I wanted to do something cool with the results. There is a really useful npm package called open that will open URLs and files. The result of the Firefly API call above will include 4 URLs and I could have simply opened all four of them in individual browser tabs, but I wanted one page where I could see them all, much like the Firefly website. While not directly supported by open yet, I got around it by generating a temporary HTML file locally:

let html = `<style>img {	max-width: 650px;}.results {	display: grid;	grid-template-columns: repeat(2, 50%);}</style><h2>Results for Prompt: ${prompt}</h2><div class="results">`;result.outputs.forEach(i => {	html += `<p><img src="${i.image.presignedUrl}"></p>`;});html += '</div>';let filename = `${uuid4()}.html`;fs.writeFileSync(filename, html, 'utf8');await open(filename, {	wait: true});fs.unlinkSync(filename);

So now what happens is, I run my prompt, and when it's done, I get an HTML page. Here's the result of using:

node makeheader "a somber, moody picture of a cat in painters clothes, standing before an easel, thinking about what to paint next"

Sample output.

And yes, I used the fourth image for this post. Here's the complete script, but you can also find it in my Firefly API repo: https://github.com/cfjedimaster/fireflyapi/tree/main/demos/makeheader

// Requires Node 21.7.0process.loadEnvFile();import { styleText } from 'node:util';import { v4 as uuid4 } from 'uuid';import open from 'open';import fs from 'fs';const FF_CLIENT_ID = process.env.FF_CLIENT_ID;const FF_CLIENT_SECRET = process.env.FF_CLIENT_SECRET;async function getFFAccessToken(id, secret) {	const params = new URLSearchParams();	params.append('grant_type', 'client_credentials');	params.append('client_id', id);	params.append('client_secret', secret);	params.append('scope', 'openid,AdobeID,session,additional_info,read_organizations,firefly_api,ff_apis');		let resp = await fetch('https://ims-na1.adobelogin.com/ims/token/v3', 		{ 			method: 'POST', 			body: params		}	);	let data = await resp.json();	return data.access_token;}async function textToImage(text, id, token) {	let body = {		"n":4,		"prompt":text,		"size":{			"width":"2304",			"height":"1792"		}	}	let req = await fetch('https://firefly-api.adobe.io/v2/images/generate', {		method:'POST',		headers: {			'X-Api-Key':id, 			'Authorization':`Bearer ${token}`,			'Content-Type':'application/json'		}, 		body: JSON.stringify(body)	});	let resp = await req.json();	return resp;}if(process.argv.length < 3) {	console.log(styleText('red', 'Usage: makeheader.js <<prompt>>'));	process.exit(1);} const prompt = process.argv[2];console.log(styleText('green', `Generating headers for: ${prompt}`));let token = await getFFAccessToken(FF_CLIENT_ID, FF_CLIENT_SECRET);let result = await textToImage(prompt, FF_CLIENT_ID, token);console.log(styleText('green', 'Results generated - creating preview...'));let html = `<style>img {	max-width: 650px;}.results {	display: grid;	grid-template-columns: repeat(2, 50%);}</style><h2>Results for Prompt: ${prompt}</h2><div class="results">`;result.outputs.forEach(i => {	html += `<p><img src="${i.image.presignedUrl}"></p>`;});html += '</div>';let filename = `${uuid4()}.html`;fs.writeFileSync(filename, html, 'utf8');await open(filename, {	wait: true});fs.unlinkSync(filename);

Automate Generative Image APIs with Firefly Services
26 March 2024 | 6:00 pm

Adobe Summit is currently happening in Vegas and while there's a lot of cool stuff being announced, I'm most excited about the launch of Firefly Services. This suite of APIs encompasses the Photoshop and Lightroom APIs I've discussed before, as well as a whole new suite of APIs for Firefly itself. Best of all, the APIs are dang easy to use. I've been building demos and samples over the past few weeks, and while I'm obviously biased, they're truly a pleasure to use. Before I go further, do know that while the docs and such are all out in the open, there isn't a free trial. Yet.

Basics #

First, some quick basics that are probably assumptions, but, you know what they say about assumptions.

  1. Authentication is required and consists of a client ID and secret value, much like the Photoshop API and Acrobat Services. You exchange this for an access token that can be used for subsequent calls.

  2. The Firefly APIs, when working with media, require you to upload the resource to an API endpoint first. This is different from the Photoshop API which requires cloud storage. This will be made more consistent in the future.

  3. Results are provided via a cloud storage URL that you can download, or use in further calls.

  4. This is all done via REST calls in whatever language, or low-code platform, you wish.

Features #

Currently, the following endpoints are supported (and again, Firefly "Services" refers to the gen AI stuff, Photoshop, Lightroom, and more, I'm focusing on the generative stuff for this post):

  • Upload - used to upload images that are referenced by other methods
  • Text to Image - what you see in the website - you take a prompt and get images. Like the website, there's a crap ton of tuning options, including using a reference image which would make use of the upload method described above.
  • Generative Expand - take an image and use AI to expand it. Basically, it expands an image with what it thinks makes sense around the existing image. Can use a prompt to help control what's added.
  • Generative Fill - same idea, but fills in an area instead. Can also take a prompt.

Check out the reference for full docs.

Demo! #

Ok, so I said it was easy, how easy is it?

First, grab your credentials, in this case from the environment:

/* Set our creds based on environment variables.*/const CLIENT_ID = process.env.CLIENT_ID;const CLIENT_SECRET = process.env.CLIENT_SECRET;

Second, exchange this for an access token. This is very similar to all the other Adobe IDs with the main exception being the scope:

async function getAccessToken(id, secret) {    const params = new URLSearchParams();    params.append('grant_type', 'client_credentials');    params.append('client_id', id);    params.append('client_secret', secret);    params.append('scope', 'openid,AdobeID,session,additional_info,read_organizations,firefly_api,ff_apis');        let resp = await fetch('https://ims-na1.adobelogin.com/ims/token/v3',         {             method: 'POST',             body: params        }    );    let data = await resp.json();    return data.access_token;}let token = await getAccessToken(CLIENT_ID, CLIENT_SECRET);

Cool, now let's make an image. The text to image API has a lot of options, but you can get by with the minimum of just a prompt. I also want to get a bunch of options so I'll change the default limit of 1 to 4:

{    "prompt":"a cat riding a unicorn headed into the sunset, dramatic pose",    "n":4}

A basic wrapper function could look like so:

async function textToImage(prompt, id, token) {    let body = {        "n":4,        prompt    }    let req = await fetch('https://firefly-api.adobe.io/v2/images/generate', {        method:'POST',        headers: {            'X-Api-Key':id,             'Authorization':`Bearer ${token}`,            'Content-Type':'application/json'        },         body: JSON.stringify(body)    });    return await req.json();}

This returns, if everything went well, a JSON packet containing links to the results. Here's an example where I reduced it to one result to keep the length down:

{	"version": "2.10.2",	"size": {			"width": 2048,			"height": 2048	},	"predictedContentClass": "art",	"outputs": [			{					"seed": 1613067352,					"image": {							"id": "03a221df-98a2-4597-ac2d-3dc1c9b42507",							"presignedUrl": "https://pre-signed-firefly-prod.s3.amazonaws.com/images/03a221df-98a2-4597-ac2d-3dc1c9b42507?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIARDA3TX66LLPDOIWV%2F20240326%2Fus-west-2%2Fs3%2Faws4_request&X-Amz-Date=20240326T192500Z&X-Amz-Expires=3600&X-Amz-SignedHeaders=host&X-Amz-Signature=84dc28624662af7215de720514ee803eed404df2cf10de803b38c878e0ff62c7"					}			}	]}

And that's it. You then just need to write some code to download the bits:

async function downloadFile(url, filePath) {    let res = await fetch(url);    const body = Readable.fromWeb(res.body);    const download_write_stream = fs.createWriteStream(filePath);    return await finished(body.pipe(download_write_stream));}

Here's one example from the prompt used above, and remember, there are numerous options I could have tweaked, and the prompt could have been more descriptive.

Cat on a unicorn

Getting Started #

Most of the code above was taken from the excellent intro guide from the Firefly docs that includes both a Node and Python version and I'm not saying it's excellent because I wrote it. No, wait, I am. Ok, it's pretty good I think. Check it out for a complete file showing the 'prompt to image' process.

I've also got a repo, https://github.com/cfjedimaster/fireflyapi, of demos and scripts, but keep in mind it's a bit messy in there. I've got some cool demos I'll be sharing soon.

Finally, check out the developer homepage for Firefly Services as well!


Spam APIs in 2024
25 March 2024 | 6:00 pm

I enjoy building API demos so I generally keep an eye out for interesting APIs to play with. A few weeks ago it occurred to me that I had not seen anyone talking about or sharing information about Spam APIs. I may be showing my age a bit, but it feels like spam was a much larger issue back in the early days. It was something you always heard about and worried about but not so much anymore. Much like nuclear war.

Duck and cover

I did a bit of digging and it turns out Chris Coyler had similar thoughts 4 years ago: "Spam Detection APIs". I thought I'd check out a few myself and share the results. Here, in no particular order, are the APIs I tried.

Test Data #

Before I looked into any APIs, I gathered a bit of test data. I found five examples of 'good' text and five of 'bad'. I copied from emails in my inbox, spam, and my wife's as well. On the 'bad' side I avoided the over the top sexual ones for obvious reasons, but I did copy one that was a bit risque. I put these test strings in an external JS file:

export let tests = {  good:[],  bad:[]}

After the initialization, I then just did a bunch of copying and pasting. Below is one example from the good side, and one from the bad.

tests.good.push(`Hello All!Thank you all for joining us today, we hope you enjoyed the workshop! For anyone that wasn't able to make it of would like to refer back this link will take you to a recording of the event:https://drive.google.com/file/d/1h1IH7ns-3ywxi00Y6cDl_TpYG7v6Y2gF/view?usp=sharingBest Regards,OTU GDSC`);tests.bad.push(`💪 If you're looking for a lady to be in a relationship with, I could be your lady . I'm Maya, and I'm ready to match with a local dude who understands how to romantic with a girl like me. ❣ 👉Connect with me here👈 .`);

Here's the entire data set if your curious:

.gist { overflow: auto;}.gist .blob-wrapper.data { max-height: 400px; overflow: auto;}

OOPSpam #

The first API I tried was from OOPSpam. They get credit for having a free trial without needing a credit card, but had an incredibly (imo) small trial of 40 API calls. Obviously, a free trial should have limits but I was really surprised to see how small it was. In my testing I ensured I saved the results as at most, I'd be able to do 3 complete tests of the ten items. (I say 3 as I knew I'd be doing one or two individual tests before testing the entire data set.) If you do pay for their service, their pricing page shows a good set of ranges, and to their credit, if the lowest level is too much, they ask you to reach out for something customized to your needs. I've got some experience with APIs that have bad "cliffs" (free goes up to X, the first pay tier is some number WAY over X, and folks in the middle are kinda screwed) so that was good to see.

I went to their docs and was happy with how easy it looked to be. Their API for spam checking only requires the content, but can additionally use the IP address and email of the person who created the content. It's a simple API, but oddly they use xhr in their JavaScript demos which hasn't been a recommended way of doing network calls in quite some time.

That being said, it wasn't hard to rewrite it in fetch:

async function checkSpam(s) {	let body = {		content: s	}	let resp = await fetch("https://api.oopspam.com/v1/spamdetection", {		method:'POST',		headers: {			'X-Api-Key':KEY,			'content-type':'application/json'		},		body:JSON.stringify(body)	});		return await resp.json();}

I built up a simple script that loaded in my test data, ran each test, and saved the result to the file system. All of my API tests used this format so I'll only share this once:

import { tests } from './inputdata.js';import fs from 'fs';const KEY = 'my key is more secret than your key...';console.log(`There are ${tests.good.length} good tests and ${tests.bad.length} bad tests.`);let totalResults = [];async function checkSpam(s) {	let body = {		content: s	}	let resp = await fetch("https://api.oopspam.com/v1/spamdetection", {		method:'POST',		headers: {			'X-Api-Key':KEY,			'content-type':'application/json'		},		body:JSON.stringify(body)	});		return await resp.json();}for(let good of tests.good) {	let result = await checkSpam(good);	totalResults.push({		type:'good', 		input:good,		results:result	});}for(let bad of tests.bad) {	let result = await checkSpam(bad);	totalResults.push({		type:'bad', 		input:bad,		results:result	});}fs.writeFileSync('./oopsspam_results.json', JSON.stringify(totalResults, null, '\t'), 'utf8');console.log('Done with tests.');

Good results look like so:

{"Score": 2,"Details": {	"isContentSpam": "nospam",	"numberOfSpamWords": 0,	"spamWords": []}

And here's a bad result:

{"Score": 3,"Details": {	"isContentSpam": "spam",	"numberOfSpamWords": 7,	"spamWords": [		"buy",		"now",		"buy",		"collect",		"for you",		"buy",		"unsubscribe"	]}

Seems very clear. Their endpoint docs show additional options that let you block disposable emails and even entire languages and countries.

So how did it do?

Of the five 'good' samples, two were flagged as spam. Of the five 'bad' samples, three were correctly flagged. I wouldn't call that great, but maybe with tweaking using the optional arguments it could be better. Of course, with the tiny free trial it may be hard to test and see if it's going to work well for you though. (Since they ask folks to reach out who want something below the lowest priced tier, it may be worth reaching out for additional free trial credits too.)

APILayer Spam Check API #

Next up is the Spam Check API from APILayer. They've got a real generous free tier (three thousand calls a month) and then pretty cheap plans above that. Their API is rather simple - you pass the body and can optionally specify a threshold value that determines how strict the checking is.

Here is the wrapper function I wrote for them. I'm not specifying a threshold so it defaults to 5, in the middle of the 1 to 10 range with 1 basically considering everything spam.

async function checkSpam(s) {	let resp = await fetch('https://api.apilayer.com/spamchecker', {		method:'post',		headers:{			'apikey':KEY		},		body:s	});	return await resp.json();}

At the default threshold of 5, every single item was marked as not spam. At 2.3 (I picked that as their sample used it), everything was spam. Threshold 4 also marked everything as not spam. Ditto for 3.5.

I then realized the result from the API was returning the score for the input, which is good as you can see how 'close' it is to your threshold, but every single input had the exact same result. I know my inputs aren't terribly long, but that just seems wrong. I'd probably avoid this one. Here's a sample result:

{	"result": "The received message is considered spam with a score of 2.5",	"score": 2.5,	"is_spam": true,	"text": "\nHarbor Freight\nCongratulations !\n\n\nBRAND NEW MILWAUKEE DRILL SET\n\nthis email is our official letter for your Confirmation.\n\n\nCongratulations! You've been chosen to Get an exclusive reward! Your Name came up for a BRAND NEW MILWAUKEE DRILL SET\n\nFrom Harbor Freight !\nCONTINUE FOR FREE»\n "}

I'll also note that this API was the slowest, by a wide margin, of the three I tested. It took about 4 seconds to run each test.

Akismet #

Last but not least is the Akismet API. While focused on Wordpress, it can be used for general purposes as well. Pricing seems fair and while there isn't a real "free trial" or "tier", you can select the Personal "name your price" tier and select 0. It will prompt you to confirm you will only use it on a non-commercial site though.

Their API can be a bit complex. You need to specify the blog you are using and I initially thought they were requiring a Wordpress blog, but I used my own and it worked fine. It also requires the IP address of the person creating the content. For my tests I just used my IP and I think that may have unfairly hurt the results, so keep that in mind when looking at the stats below. You can specify a wide range of optional arguments as well as the content type. While the docs talk about blog comments and that's probably the main use, it's absolutely not the only use.

Here's my implementation and note that the IP is hard coded which is not something you would use in production:

async function checkSpam(s) {	let params = new URLSearchParams();	params.append('api_key', KEY);	params.append('blog', 'https://www.raymondcamden.com');	params.append('user_ip', '76.72.11.67');	params.append('comment_type', 'comment');	params.append('comment_content', s);	let resp = await fetch('https://rest.akismet.com/1.1/comment-check', {		method:'post',		headers:{		},		body:params	});	return await resp.json();}

How did it do? Of the five good inputs, all were marked correctly. Of the five bad inputs, only one was marked correctly.

Now, that sounds bad, but I honestly feel like additional arguments provided by the API would have greatly helped. Out of all the APIs, this was the quickest. Honestly, my gut tells me this is probably the best of the options I tested and I'd start here.

Honorable Mention - Postmark #

I did quickly test Postmark which is free and also easy to use, but is not meant for 'general purpose' spam checking as it expects the full text of an email, including headers and such. If you are only looking to test email, this may be a great option.

Photo by Hannes Johnson on Unsplash

"Duck and cover" photo by James Vaughan



More News from this Feed See Full Web Site