An Online Mastodon Archive Viewer
21 July 2024 | 6:00 pm

Here's a quick write-up of something I actually built a week or so ago... and forgot to share on the blog. Instead, I shared a way to integrate your Mastodon with Eleventy. In that post, I mentioned how the account Fedi.Tips was looking for ways to let people view their archived Mastodon data. I followed their guide on getting my export and started work on a simple viewer.

If you don't care about the "how", and have your archive ready, just head on over to https://tootviewer.netlify.app and upload your zip.

The Archive #

When you get your Mastodon archive, it will be a zip file containing the following:

  • actor.json: A file related to the user, yourself probably.
  • avatar.jpg: You're current profile picture.
  • bookmarks.json: A list of your bookmarks. (I don't use this feature myself.)
  • header.jpg: The header image in your profile.
  • likes.json: A list of your liked toots.
  • media_attachments: All media you have attached to posts. There is a file subdirectory and then subdirectories numerically named with further subdirectories many levels deep with the actual data.
  • outbox.json: Finally, a JSON file of your toots.

Screenshot of archive unzipped

I decided to focus mainly on actor.json and outbox.json, figuring toots would be the most critical thing folks would want to examine.

The App #

At a high level, the application consists of:

  • Aline.js to help with the architecture.
  • Shoelace for visual components
  • JSZip for JavaScript parsing of the zip file. Honestly, I didn't need this, I could have asked users to unzip first, but I wanted it to be as easy as possible.

I'm not going to over every line of code (I'll share a link to the repo at the end), but here are some interesting bits.

Ingesting the Data #

There re two ways to select your archive, either via a simple input[type=file] or drag and drop. I covered how to do this in-depth here: "Using Drag/Drop in Alpine.js with PDF Embed"

Once you have a handle to the file, I then begin the process of parsing the zip in a function named loadZip. I begin with a bit of validation:

this.status = 'Checking zip file.';let zip = new JSZip();let zipContents = await zip.loadAsync(file);let names = Object.keys(zipContents.files);if(!this.validateArchive(names)) {	this.status = 'This does not appear to be a valid Mastodon archive.';	return;} 

The validateArchive function does simple sanity checking on the entries of the zip:

validateArchive(names) {	/*	our 'rules' for valid archive are:	must have outbox.json and actor.json	this could be improved 	*/	return names.includes('outbox.json') && names.includes('actor.json');}

Going back to loadZip, I can then read my two files (again, I'm only concerned with toots and your profile):

// read in actor and outbox for processingthis.actorData = JSON.parse((await zipContents.files['actor.json'].async('text')));	this.messageData = JSON.parse((await zipContents.files['outbox.json'].async('text'))).orderedItems.filter(m => m.type === 'Create');

Notice that I do a filter on your toots to focus on items related to writing toots. The data also seemed to include things like announcements of accounts I followed and such, so this filter removed any noise.

Finally, I store all of this in localStorage. This made testing a heck of a lot easier as I could reload and skip uploading the zip, but if I do decide to work with media, I'll need to switch to IndexedDB instead.

The UI #

The UI is still very much a work in progress, but I focused on showing your profile first:

Screenshot of profile

You'll notice I did not render your avatar and header. I absolutely could have, and heck, may add that in a few minutes as I literally just thought of a nice way of doing that and storing it in local storage.

Beneath the profile view is the most important bit, your toots:

Toot view

You'll notice on top there is a quick filter and pagination. I've got nearly two thousand toots so pagination was required and the search lets me find anything by keyword. As I mentioned, I'm not handling media yet so you won't see attached pictures in the view here, but each individual toot is linked to the original for quick view in the native Mastodon view.

The Code, and What's Next #

If you've got ideas or suggestions, please head over to the repo (https://github.com/cfjedimaster/tootviewer) and let me know. I'm open to any PRs as well. As I said above, I think I'll quickly add your profile pics to the UI, but outside of that, I think the main thing I want to tackle is supporting media in the toot display and perhaps a general media browser. (You may remember posting a particular picture but have forgotten what you typed about the picture.) I hope this helps folks out!


Caching Input with Google Gemini
19 July 2024 | 6:00 pm

A little over a month ago, Google announced multiple updates to their GenAI platform. I made a note of it for research later and finally got time to look at one aspect - context caching.

When you send prompts to a GenAI system, your input is tokenized for analysis. While not a "one token per word" relation, basically the bigger the input (context) the more the cost (tokens). The process of converting your input into tokens takes time, especially when dealing with large media, for example, a video. Google introduced a "Context caching" system that helps improve the performance of your queries. As the docs suggest, this is really suited for cases where you've got a large initial input (a video, text file) and then follow up with multiple questions related to the content.

At this time, speed improvements aren't really baked in, but cost improvements definitely are. If you imagine a prompt based on a video for example, your cost will be X let's say, where X is the token count of your text-based prompt and the video. For cached data and Gemini, the cost is instead: "Token count of your prompt, and a reduced charge for your cached content". Honestly, this was a bit hard to grok at first, but a big thank you to Vishal Dharmadhikari at Google for patiently explaining it to me.

You can see current cost details here:

Current prices

The docs do a good job of explaining how to use it, but I really wanted a demo I could run locally to see it in action, and to create a test where I could compare timings to see how much the cache helped.

Caveats #

Again, this is documented, but honestly, I missed them both.

  • You must use a specific version of a model. In other words, not gemini-1.5-pro but rather gemini-1.5-pro-001.
  • Gemini has a free tier in which you can create a key in a project that has no credit card. This feature is not available in the free tier. I found the error message a bit hard to grok in that case.

Ok, with that in mind, let's look at how it's used.

Caching in Gemini #

My code is modified slightly from the docs, but credit to Google for documenting this well. Before getting into code, a high-level look:

  • First, you use the Files API to get your asset in Google's cloud. Note that this API changed from my blog post back in May.
  • Second, you create a cache. This is very similar to creating a model.
  • Third, you actually get the model using a special function that integrates with the cache.

After that, you can run prompts at will against the model.

Here's my code, and honestly, it is a bit messy, but hopefully understandable.

Let's start with the imports:

import {  GoogleGenerativeAI} from '@google/generative-ai';import { FileState, GoogleAIFileManager, GoogleAICacheManager } from '@google/generative-ai/server';

Next, some constants. By the way, I'm not using const much anymore, so when you see it, it's just code I haven't bothered to change to let.

const MODEL_NAME = 'models/gemini-1.5-pro-001';const API_KEY = process.env.GEMINI_API_KEY;const fileManager = new GoogleAIFileManager(API_KEY);const cacheManager = new GoogleAICacheManager(API_KEY);const genAI = new GoogleGenerativeAI(API_KEY);

Next, I defined my system instructions. This will be used for both model objects I create in a bit.

// System instructions used for both testslet si = 'You are an English professor for middle school students and can provide help for students struggling to understand classical works of literature.';

Now my code handles uploading my content, in this case, a 755K text version of "Pride and Prejudice":

async function uploadToGemini(path, mimeType) {	const fileResult = await fileManager.uploadFile(path, {		mimeType,		displayName: path,	});	let file = await fileManager.getFile(fileResult.file.name);	while(file.state === FileState.PROCESSING) {		console.log('Waiting for file to finish processing');		await new Promise(resolve => setTimeout(resolve, 2_000));		file = await fileManager.getFile(fileResult.file.name);	}  return file;}// First, upload the book to Google let book = './pride_and_prejudice.txt';let bookFile = await uploadToGemini(book, 'text/plain');console.log(`${book} uploaded to Google.`);

At this point, we can create our cache:

let cache = await cacheManager.create({	model: MODEL_NAME, 	displayName:'pride and prejudice', 	systemInstruction:si,	contents: [		{			role:'user',			parts:[				{					fileData: {						mimeType:bookFile.mimeType, 						fileUri: bookFile.uri					}				}			]		}	],	ttlSeconds: 60 * 10 // ten minutes});

Note that this is very similar to how you create a model normally. It's got the model name, system instructions, and a reference to the file.

The cache object returned there is the only time you have access to the cache. There are APIs to list, update, and delete caches, but you can't get a reference once the script execution ends.

To get the actual model you can run prompts on, you then do:

let genModel = genAI.getGenerativeModelFromCachedContent(cache);

As an example:

// used for both tests.let contents = [		{			role:'user',			parts: [				{					text:'Describe the major themes of this work and then list the major characters.'				}			]		}	];let result = await genModel.generateContent({	contents});

And that's it really. I've got a complete script that demos this in action and it shows a comparison to a non-cached model. It reports on the timings, which again, at this point do not show the cached stuff being quicker, but it also reports the usageMetadata and that shows the impact of the cached token count against your total. Here's an example with the cache:

{  promptTokenCount: 189940,  candidatesTokenCount: 591,  totalTokenCount: 190531,  cachedContentTokenCount: 189925}with cache, duration is 52213{  promptTokenCount: 189935,  candidatesTokenCount: 251,  totalTokenCount: 190186,  cachedContentTokenCount: 189925}with cache, second prompt, duration is 19117

And here's the report when the cache isn't used:

{  promptTokenCount: 189939,  candidatesTokenCount: 790,  totalTokenCount: 190729}without cache, duration is 29005{  promptTokenCount: 189934,  candidatesTokenCount: 181,  totalTokenCount: 190115}without cache, second prompt, duration is 11707

Again, the timing above shows that with the cache, the timings were actually slower, but cost-wise, something like 99% of the cost is reduced. That's huge. If you want the complete script (and source book), you can find it here: https://github.com/cfjedimaster/ai-testingzone/tree/main/cache_test


Web Component to Generate Image Color Palettes
16 July 2024 | 6:00 pm

Chalk this up to something that is probably not terribly useful, but was kind of fun to build. A few weeks ago I came across a site talking about the colors used in the Fallout TV show. I grabbed a screenshot of how they rendered it:

Screenshot

Unfortunately, I didn't make note of the site itself and I can't seem to find it anymore. I really dug how it showed the palette of prominent colors directly beneath the image itself. Using this as inspiration, I looked into how I could automate this with a web component.

To get the color palette, I turned to a library I've used many times before, Color Thief. Given an image, it can return either the most dominant color of an image or return an array of values representing the palette of the image.

I began with the HTML, which in this case simply wrapped a random image returned from Unsplash at a specific height and width:

<color-palette><img src="https://unsplash.it/640/425" crossorigin="anonymous"></color-palette>

In retrospect, maybe using a random image was a bad idea, as every reload showed something different, but once I had the code working it, well, worked, so I kept it as is.

Now for the code. To be honest, I didn't spend much time thinking about the color palette, rather, I was more concerned about how to 'rewrite' the image HTML in a way to look like the screen grab above. I knew CSS Grid could do it, so I went that route. I built a simple CodePen that only handled the layout.

See the Pen Pallete Demo 1 by Raymond Camden (@cfjedimaster) on CodePen.

It was... nearly perfect. You can see a bit of whitespace after the image and before the images beneath. Honestly, if someone were to fork my CodePen with a fix, I'd definitely appreciate it! But with CSS in hand, I proceeded to the web component version.

Here's the code:

class ColorPalette extends HTMLElement {	#scriptSrc = 'https://cdnjs.cloudflare.com/ajax/libs/color-thief/2.3.0/color-thief.umd.js';		constructor() {		super();	}		async connectedCallback() {		/*		If we wrap an image, we work, otherwise, leave early		*/		this.imgRef = this.querySelector('img');		if(!this.imgRef) {			console.warn('color-palette: No img found.'); 			return;		}				//not sure why getHTML() doesn't work		let initialHTML = this.imgRef.outerHTML;		await this.loadScript();		console.log('loaded');		await this.loadImage(this.imgRef);		console.log('img loaded');		let colorThief = new ColorThief();		let palette = colorThief.getPalette(this.imgRef, 5);		console.log(JSON.stringify(palette,null,'\t'));		let newHTML = `<style>div.photo_palette {	display: grid;	grid-template-columns: repeat(5, 1fr);	width: 640px;	row-gap: 0;}div.photo_palette div {	outline: 1px solid black;}div.photo {	grid-column: span 5;}div.colorbar {	width: 1fr;	height: 50px;}</style><div class="photo_palette">	<div class="photo">		${initialHTML}	</div>	<div class="colorbar" style="background-color:rgb(${palette[0][0]},${palette[0][1]},${palette[0][2]})"></div>	<div class="colorbar" style="background-color:rgb(${palette[1][0]},${palette[1][1]},${palette[1][2]})""></div>	<div class="colorbar" style="background-color:rgb(${palette[2][0]},${palette[2][1]},${palette[2][2]})""></div>	<div class="colorbar" style="background-color:rgb(${palette[3][0]},${palette[3][1]},${palette[3][2]})"></div>	<div class="colorbar" style="background-color:rgb(${palette[4][0]},${palette[4][1]},${palette[4][2]})"></div></div>`;		this.innerHTML = newHTML;		}		async loadImage(i) {		return new Promise((resolve, reject) => {			console.log('complete', i.complete);			if(i.complete) {				resolve();				return;			}			i.addEventListener('load', () => {				resolve();			});		});	};		async loadScript() {		return new Promise((resolve, reject) => {			let script = document.createElement('script');			script.type = 'text/javascript';			script.src=this.#scriptSrc;			document.head.appendChild(script);			script.addEventListener('load', () => {				resolve();			});				});	}}if(!customElements.get('color-palette')) customElements.define('color-palette', ColorPalette);

The script has two async processes it needs to wait for. First, load the external Color Thief library. Next, see if the image is loaded. As it's possible it's already loaded, my code has to handle that case as well.

But once past that, I can get the color palette and then rewrite the HTML contained with the tags with my CSS and new divs holding the colors. I try to avoid the shadow DOM where possible, but obviously, this code will possibly 'clash' with existing CSS. I could perhaps use some better-named classes (for example, div.color_palette_web_component).

Here it is in action:

See the Pen Palete Demo 2 by Raymond Camden (@cfjedimaster) on CodePen.

And since what you see above is random, I captured two examples:

First example

Seconds example



More News from this Feed See Full Web Site