code:
App.Art.GenAI.PiercingsPromptPart = class PiercingsPromptPart extends App.Art.GenAI.PromptPart {
	/**
	 * @override
	 */
	positive() {
		let piercingParts = [];
		if (this.slave.piercing.areola.weight > 0) {
			if (this.slave.fuckdoll === 0 || this.slave.race === "catgirl") { // TODO: needs exposure check
				let desc = this.slave.piercing.areola.desc ? (pronounsForSlaveProp(this.slave, this.slave.piercing.areola.desc) + ` `) : ``;
				piercingParts.push(`${desc}areola piercing`);
			}
		}
		if (this.slave.piercing.ear.weight > 0) {
			if (this.slave.fuckdoll === 0 || this.slave.race === "catgirl") { // covered by fuckdoll mask or fur
				let desc = this.slave.piercing.ear.desc ? (pronounsForSlaveProp(this.slave, this.slave.piercing.ear.desc) + ` `) : ``;
				piercingParts.push(`${desc}earrings`);
			}
		}
		if (this.slave.piercing.eyebrow.weight > 0) {
			if (this.slave.fuckdoll === 0 || this.slave.race === "catgirl") { // covered by fuckdoll mask or fur
				let desc = this.slave.piercing.eyebrow.desc ? (pronounsForSlaveProp(this.slave, this.slave.piercing.eyebrow.desc) + ` `) : ``;
				piercingParts.push(`${desc}eyebrow piercing`);
			}
		}
		if (this.slave.piercing.lips.weight > 0) {
			let desc = this.slave.piercing.lips.desc ? (pronounsForSlaveProp(this.slave, this.slave.piercing.lips.desc) + ` `) : ``;
			piercingParts.push(`${desc}lip piercing`);
		}
		if (this.slave.piercing.navel.weight > 0) {
			if (this.slave.fuckdoll === 0 || this.slave.race === "catgirl") { // covered by fuckdoll suit or fur
				let desc = this.slave.piercing.navel.desc ? (pronounsForSlaveProp(this.slave, this.slave.piercing.navel.desc) + ` `) : ``;
				piercingParts.push(`${desc}navel piercing`);
			}
		}
		if (this.slave.piercing.nipple.weight > 0) {
			if (this.slave.fuckdoll === 0) { // TODO: needs exposure check
				let desc = this.slave.piercing.nipple.desc ? (pronounsForSlaveProp(this.slave, this.slave.piercing.nipple.desc) + ` `) : ``;
				piercingParts.push(`${desc}nipple piercing`);
			}
		}
		if (this.slave.piercing.nose.weight > 0) {
			if (this.slave.fuckdoll === 0 || this.slave.race === "catgirl") { // covered by fuckdoll mask or fur
				let desc = this.slave.piercing.nose.desc ? (pronounsForSlaveProp(this.slave, this.slave.piercing.nose.desc) + ` `) : ``;
				piercingParts.push(`${desc}nose piercing`);
			}
		}
		if (this.slave.piercing.tongue.weight > 0) {
			let desc = this.slave.piercing.tongue.desc ? (pronounsForSlaveProp(this.slave, this.slave.piercing.tongue.desc) + ` `) : ``;
			piercingParts.push(`${desc}tongue piercing`);
		}
		if (this.slave.piercing.vagina.weight > 0 && this.slave.dick <= 0) {
			let desc = this.slave.piercing.vagina.desc ? (pronounsForSlaveProp(this.slave, this.slave.piercing.vagina.desc) + ` `) : ``;
			piercingParts.push(`${desc}labia piercing`);
		}

		if (piercingParts.length > 0) {
			return piercingParts.join(`, `);
		}
	}

	/**
	 * @override
	 */
	negative() {
		if (this.slave.piercing.areola.weight === 0 && this.slave.piercing.ear.weight === 0 && this.slave.piercing.eyebrow.weight === 0 && this.slave.piercing.genitals.weight === 0 && this.slave.piercing.lips.weight === 0 && this.slave.piercing.navel.weight === 0 && this.slave.piercing.nipple.weight === 0 && this.slave.piercing.nose.weight === 0 && this.slave.piercing.tongue.weight === 0 && this.slave.piercing.vagina.weight === 0) {
			return `piercings`;
		}
	}
};


code:
App.Art.GenAI.PosturePromptPart = class PosturePromptPart extends App.Art.GenAI.PromptPart {
	/**
	 * @override
	 */
	positive() {
		if (this.slave.custom.aiPrompts?.pose) {
			return this.slave.custom.aiPrompts.pose;
		}

		let devotionPart;
		if (this.slave.fuckdoll !== 0) {
			let lora = ``;
			if (V.aiLoraPack && !V.aiOpenPose) { // always prefer OpenPose over lora; less side effects
				lora = `<lora:Standing straight  - arms at sides - legs together - v1 - locon 32dim:1>`;
			}
			devotionPart = `${lora} standing straight`;
		} else if (this.slave.devotion < -50) {
			devotionPart = `standing, from side, arms crossed`;
		} else if (this.slave.devotion < -20) {
			devotionPart = `standing, arms crossed`;
		} else if (this.slave.devotion < 21) {
			devotionPart = `standing`;
		} else {
			devotionPart = `standing, arms behind back`;
		}

		if (isAmputee(this.slave)) {
			return devotionPart.replace(/( *)standing(,)*/g, "sitting in chair,"); // posture change prevents genning arms/legs, looks more natural
		}

		let trustPart;
		if (this.slave.fuckdoll !== 0) {
			trustPart = ``;
		} else if (this.slave.trust < -50) {
			trustPart = `trembling, head down`;
		} else if (this.slave.trust < -20) {
			trustPart = `trembling`;
		}

		if (devotionPart && trustPart) {
			return `${devotionPart}, ${trustPart}`;
		} else if (devotionPart) {
			return devotionPart;
		} else if (trustPart) {
			return trustPart;
		}
	}

	/**
	 * @override
	 */
	negative() {
		return undefined;
	}
};

repo_name: Ducati/fc

license: mit


code:
App.Art.GenAI.PregPromptPart = class PregPromptPart extends App.Art.GenAI.PromptPart {
	/**
	 * @override
	 */
	positive() {
		if (this.slave.belly >= 10000) {
			return "pregnant, full term";
		} else if (this.slave.belly >= 5000) {
			return "pregnant";
		} else if (this.slave.belly >= 1500) {
			return "baby bump";
		}
	}

	/**
	 * @override
	 */
	negative() {
		return undefined;
	}
};


code:
App.Art.GenAI.PubicHairPromptPart = class PubicHairPromptPart extends App.Art.GenAI.PromptPart {
	/**
	 * @override
	 */
	positive() {
		if (this.slave.pubicHStyle === "waxed" || this.slave.pubicHStyle === "bald" || this.slave.pubicHStyle === "hairless" || this.slave.physicalAge < Math.min(this.slave.pubertyAgeXX, this.slave.pubertyAgeXY)) {
			return;
		}
		if (App.Data.clothes.get(this.slave.clothes).exposure < 3 || this.slave.fuckdoll > 0) {
			return; // pubic region should be covered by clothes
		}
		const style = (this.slave.pubicHStyle === "bushy in the front and neat in the rear" ? "bushy" : this.slave.pubicHStyle); // less complicated prompt works better for the long style
		return `${this.slave.pubicHColor} ${style} pubic hair`;
	}

	/**
	 * @override
	 */
	negative() {
		if (this.slave.pubicHStyle === "waxed" || this.slave.pubicHStyle === "bald" || this.slave.pubicHStyle === "hairless" || this.slave.physicalAge < Math.min(this.slave.pubertyAgeXX, this.slave.pubertyAgeXY)) {
			return "pubic hair";
		}
		return;
	}
};


code:
App.Art.GenAI.RacePromptPart = class RacePromptPart extends App.Art.GenAI.PromptPart {
	/**
	 * @override
	 */
	positive() {
		if (this.slave.race === "white") {
			return "caucasian";
		} else if (this.slave.race === "black") {
			return "african";
		} else if (this.slave.race === "catgirl") {
			return undefined; // catgirl/catboy race is covered by gender prompt
		}
		return this.slave.race;
	}

	/**
	 * @override
	 */
	negative() {
		if (this.slave.race !== "asian" && this.slave.race !== "catgirl") {
			return "asian";
		}
		return;
	}
};


code:
App.Art.GenAI.SkinPromptPart = class SkinPromptPart extends App.Art.GenAI.PromptPart {
	/**
	 * @override
	 */
	positive() {
		if (this.slave.geneticQuirks.albinism === 2) {
			return "albino";
		}

		if (this.slave.race === "catgirl") {
			return `covered in ${this.slave.skin} fur`;
		}

		switch (this.slave.skin) {
			case "pure white":
			case "ivory":
			case "white":
				return "white skin";
			case "extremely pale":
			case "very pale":
			case "pale":
				return "pale skin";
			case "extremely fair":
			case "very fair":
			case "fair":
			case "light":
			case "light olive":
				return "fair skin";
			case "sun tanned":
			case "spray tanned":
			case "tan":
			case "olive":
			case "bronze":
			case "dark olive":
			case "dark":
			case "light beige":
			case "beige":
			case "dark beige":
			case "light brown":
			case "brown":
				return "tan skin";
			case "dark brown":
			case "black":
			case "ebony":
				return "dark skin";
			case "pure black":
				return "black skin";
			default:
				return `${this.slave.skin} skin`;
		}
	}

	/**
	 * @override
	 */
	negative() {
		switch (this.slave.skin) {
			case "pure white":
			case "ivory":
			case "white":
			case "extremely pale":
			case "very pale":
			case "pale":
			case "extremely fair":
			case "very fair":
			case "fair":
			case "light":
			case "light olive":
				return "dark skin";
			case "sun tanned":
			case "spray tanned":
			case "tan":
			case "olive":
				return "black skin";
			case "bronze":
			case "dark olive":
			case "dark":
			case "light beige":
			case "beige":
			case "dark beige":
			case "light brown":
			case "brown":
			case "dark brown":
			case "black":
			case "ebony":
			case "pure black":
				return "light skin";
		}
	}
};


code:
App.Art.GenAI.StylePromptPart = class StylePromptPart extends App.Art.GenAI.PromptPart {
	/**
	 * @override
	 */
	positive() {
		switch (V.aiStyle) {
			case 0: // custom
				return V.aiCustomStylePos;
			case 1: // photorealistic
				return "<lora:LowRA:0.5> full body portrait, photorealistic, dark theme, black background";
			case 2: // anime/hentai
				return "full body portrait, 2d, anime, hentai, dark theme, black background";
		}
	}

	/**
	 * @override
	 */
	negative() {
		switch (V.aiStyle) {
			case 0: // custom
				return V.aiCustomStyleNeg;
			case 1: // photorealistic
				return "greyscale, monochrome, cg, render, unreal engine, closeup, medium shot";
			case 2: // anime/hentai
				return "greyscale, monochrome, photography, 3d render, text, speech bubble, closeup, medium shot";
		}
	}
};


code:
App.Art.GenAI.TattoosPromptPart = class TattoosPromptPart extends App.Art.GenAI.PromptPart {
	/**
	 * @override
	 */
	positive() {
		if (this.slave.fuckdoll > 0 || this.slave.race === "catgirl") {
			return undefined; // fuckdoll suit covers all possible tattoo locations, catgirl covered with fur
		}
		// TODO: clothes can cover limbs/belly/boobs.
		let tattooParts = [];
		if (this.slave.armsTat) {
			tattooParts.push(`${this.slave.armsTat} arm tattoo`);
		}
		if (this.slave.legsTat) {
			tattooParts.push(`${this.slave.legsTat} leg tattoo`);
		}
		if (this.slave.bellyTat) {
			tattooParts.push(`${this.slave.bellyTat} belly tattoo`);
		}
		if (this.slave.boobsTat) { // TODO: needs exposure check
			tattooParts.push(`${this.slave.boobsTat} breast tattoo`);
		}

		if (tattooParts.length > 0) {
			return tattooParts.join(', ');
		}
	}

	/**
	 * @override
	 */
	negative() {
		if (!this.slave.armsTat && !this.slave.legsTat && !this.slave.bellyTat && !this.slave.boobsTat) {
			return `tattoo`;
		}
	}
};


code:
App.Art.GenAI.WaistPromptPart = class WaistPromptPart extends App.Art.GenAI.PromptPart {
	/**
	 * @override
	 */
	positive() {
		if (this.slave.waist > 95) {
			return `very wide waist`;
		} else if (this.slave.waist > 10) {
			return `wide waist`;
		} else if (this.slave.waist > -40) {
			return null;
		} else if (this.slave.waist > -95) {
			return `narrow waist`;
		} else {
			return `(narrow waist:1.1)`;
		}
	}

	/**
	 * @override
	 */
	negative() {
		if (this.slave.waist > 30) {
			return `narrow waist`;
		} else if (this.slave.waist > -30) {
			return null;
		} else if (this.slave.waist > -95) {
			return `wide waist`;
		}
	}
};


code:
App.Art.GenAI.WeightPromptPart = class WeightPromptPart extends App.Art.GenAI.PromptPart {
	/**
	 * @override
	 */
	positive() {
		if (this.slave.weight < -95) {
			return `emaciated, very thin, skinny`;
		} else if (this.slave.weight < -30) {
			return `very thin, skinny`;
		} else if (this.slave.weight < -10) {
			return `slim`;
		} else if (this.slave.weight < 10) {
			return null;
		} else if (this.slave.weight < 30) {
			return `curvy`;
		} else if (this.slave.weight < 95) {
			return `plump, chubby`;
		} else {
			return `fat, obese, plump`;
		}
	}

	/**
	 * @override
	 */
	negative() {
		if (this.slave.weight < -30) {
			return `plump, chubby`;
		} else if (this.slave.weight < 50) {
			return null;
		} else {
			return `thin, skinny`;
		}
	}
};


code:
/**
 * @typedef {object} App.Art.GenAI.AIImageResponse
 * @property {string} image Base64 image in the form of `data:${mimeType};base64,${base64Image}`
 * @property {string} eventId Event ID
 */

/**
 * @typedef {'headshot' | 'overview'} App.Art.GenAI.Action
 */

/**
 * @typedef App.Art.GenAI.EventStore.OverviewData
 * @property {object} images
 * @property {string} [images.lowRes] Base64 encoded image, generated quickly
 * @property {string} [images.highRes] Base64 encoded image, with upscaling/fancy stuff
 */

/**
 * @typedef {object} App.Art.GenAI.EventStore.HeadshotData
 * @property {object} images
 * @property {string} [images.image]  Base64 encoded image, with fancy stuff
 */

/**
 * @typedef {XOR<App.Art.GenAI.EventStore.OverviewData, App.Art.GenAI.EventStore.HeadshotData>} App.Art.GenAI.EventStore.DataType
 */

/**
 * @typedef App.Art.GenAI.EventStore.Entry
 * @property {number[]} slaveIds Sorted (ascending) list of slaves involved in event. Indexed for fast retrieval.
 * @property {App.Entity.SlaveState[]} slaveStates State of the slaves at the time of generation
 * @property {App.Art.GenAI.EventStore.OverviewData} data
 * @property {App.Art.GenAI.Action} action String describing what this contains. e.g. headshot, status, fucked vaginal, standing, etc.  Move to enum/complex types later.
 * @property {number} seed Seed used to send to SD. Likely the seed of slaveStates[0]
 * @property {number} [id] IndexedDB id
 *
 */

/**
 * @typedef {object} App.Art.GenAI.GetImageOptions
 * @property {App.Art.GenAI.Action} action
 * @property {App.Art.ArtSizes} size
 * @property {boolean} forceRegenerate
 * @property {boolean} isEventImage
 */

const SIGNIFICANTLY_DIFFERENT_THRESHOLD = 50;

App.Art.GenAI.reactiveImageDB = (function() {
	const sleep = (/** @type {number} */ n = 100) => new Promise(r => setTimeout(r, n));

	/** @type {import("../../../devTools/types/idb/entry").IDBPDatabase} */
	let db;

	/** Metadata about generated images */
	const EVENT_STORE = {
		path: 'eventStore',
		indicies: {
			bySlaveIdsActions: 'bySlaveIdsActions',
			bySlaveId: 'bySlaveId',
			byImageId: 'byImageId'
		}
	};

	const ALL_STORES = [EVENT_STORE];

	let initialized = false;
	async function waitForInit() {
		while (!initialized) {
			await sleep();
		}
		initialized = true;
	}

	/**
	 * Create an IndexedDB and initialize objectStore if it doesn't already exist.
	 * @returns {Promise<import("../../../devTools/types/idb/entry").IDBPDatabase>} Promise object that resolves with the opened database
	 */
	async function createDB() {
		return idb.openDB('AIImages-Reactive', 1, {
			blocked: () => {
				throw new Error("You have an older version of the AI Image DB open in another tab. Please close it and refresh this page.");
			},
			upgrade: (database, oldVersion, newVersion, tx, ev) => {
				// v1 DB spec
				if (oldVersion < 1) {
					const eventStore = database.createObjectStore(EVENT_STORE.path, {autoIncrement: true, keyPath: 'id'});
					eventStore.createIndex('id', 'id', {unique: true});
					eventStore.createIndex("bySlaveIdsActions", ['slaveIds', 'action'], {multiEntry: false}); // to check if a given actor is in _any_ scene. Does not matter who the other is.
					eventStore.createIndex('bySlaveId', 'slaveIds', {multiEntry: true});
					eventStore.createIndex('byImageId', 'imageId'); // for easy deletion
				}
			},
			blocking: (currentVersion, blockedVersion, ev) => {
				db.close();
			},
			terminated: () => {
				console.error("Connection to xmAIImages IndexedDB unexpectedly closed.");
			}
		});
	}

	/**
	 * Generates and saves a new image and returns it.
	 *
	 * @private
	 *
	 * @param {App.Entity.SlaveState[]} slaves The ID of the image to retrieve
	 * @param {App.Art.GenAI.GetImageOptions} options Fully populated misc options.
	 *
	 * @returns {Promise<string>} Promise object that resolves with the retrieved image data
	 */
	async function generateNewImage(slaves, options) {
		// {isEventImage: options.isEventImage, action: options.action}
		/** @type {string} */
		const base64Image = await App.Art.GenAI.reactiveCache.fetchImageForSlave(slaves[0], options.isEventImage);
		return getImageData(base64Image);
	}

	/**
	 * Compares to see whether an image may be resused for a given slave.
	 *
	 * @param {App.Entity.SlaveState} s1 The first state of the slave. You are checking to see if this one may be used.
	 * @param {App.Entity.SlaveState} s2 The second state of the same slave
	 *
	 * @returns {{
	 * 	canReuse: boolean,
	 * 	difference?: number
	 * }} Difference score from 0 to infinity. 0 is identical, and 30 is "significantly different"
	 */
	function fuzzyCompareSlaves(s1, s2) {
		// Potential performance setting: have different sets of dealbreakers, potentialImpact etc. for different powered machines

		// TODO: migrate to what @Engineerix is building

		// Immediate disqualification from usage
		// Either they have large changes to appearance no matter how small (e.g. Style),
		// or the prompts are already fuzzy enough to ignore minor differences (e.g. height)
		const dealBreakers = [
			App.Art.GenAI.StylePromptPart,
			App.Art.GenAI.SkinPromptPart,
			App.Art.GenAI.RacePromptPart,
			App.Art.GenAI.GenderPromptPart,
			App.Art.GenAI.AgePromptPart,
			App.Art.GenAI.PregPromptPart,
			App.Art.GenAI.ClothesPromptPart,
			App.Art.GenAI.BreastsPromptPart,
			App.Art.GenAI.HairPromptPart,
			App.Art.GenAI.NationalityPromptPart,
			App.Art.GenAI.EyePromptPart,
			App.Art.GenAI.HealthPromptPart,
			App.Art.GenAI.PubicHairPromptPart,
			App.Art.GenAI.AmputationPromptPart,
			App.Art.GenAI.AndroidPromptPart,
			App.Art.GenAI.TattoosPromptPart,
			App.Art.GenAI.WeightPromptPart,
			App.Art.GenAI.HeightPromptPart,
			App.Art.GenAI.CollarPromptPart,
			App.Art.GenAI.WaistPromptPart,
			App.Art.GenAI.HipsPromptPart,
			App.Art.GenAI.CustomPromptPart // player probably cares
		];

		for (const DealBreaker of dealBreakers) {
			const p1 = new DealBreaker(s1);
			const p2 = new DealBreaker(s2);

			// immediate disqualification
			if (p1.positive() !== p2.positive()) {
				return {
					canReuse: false
				};
			}
		}

		let differenceScore = 0;

		// // Calculate and sum the "difference score" for each of these
		// // TODO: calibrate scores properly
		// const potentialImpact = [
		// 	App.Art.GenAI.BeautyPromptPart,
		// 	App.Art.GenAI.PosturePromptPart,
		// 	App.Art.GenAI.ArousalPromptPart,
		// 	App.Art.GenAI.MusclesPromptPart,
		// 	App.Art.GenAI.ExpressionPromptPart,
		// ];

		/**
			*
			* @param {number} num1
			* @param {number} num2
			* @param {number} threshold Minimum difference before counting it as different
			* @returns {number} difference score, above 0.
			*/
		const differenceThreshold = (num1, num2, threshold) => Math.max(Math.abs(num1 - num2) - threshold, 0);

		// beauty
		differenceScore += Math.abs(s1.face - s2.face);
		// posture (bad trust or bad devotion)
		if (s1.devotion < -20) {
			differenceScore += differenceThreshold(s1.devotion, s2.devotion, 0);
		}
		// arousal
		if (App.Data.clothes.get(s1.clothes).exposure < 3 !== App.Data.clothes.get(s2.clothes).exposure < 3) {
			differenceScore += differenceThreshold(s1.energy, s2.energy, 0);
		}
		// muscles
		differenceScore += 0.5 * differenceThreshold(s1.muscles, s2.muscles, 0);
		// expression (trust, devotion)
		differenceScore += 0.5 * differenceThreshold(s1.trust, s2.trust, 0);
		differenceScore += 0.5 * differenceThreshold(s1.devotion, s2.devotion, 0);

		// // // Will get regenerated only when schedule says to
		// const probablyDoesntMatter = [
		// 	App.Art.GenAI.EyebrowPromptPart,
		// 	App.Art.GenAI.PiercingsPromptPart
		// ]

		// random small amount to show that it wasn't an exact match.
		if (s1.eyebrowHStyle !== s2.eyebrowHStyle) {
			differenceScore += 0.1;
		}
		if ((new App.Art.GenAI.PiercingsPromptPart(s1)).positive() !== (new App.Art.GenAI.PiercingsPromptPart(s2)).positive()) {
			differenceScore += 0.1;
		}

		return {
			canReuse: differenceScore < SIGNIFICANTLY_DIFFERENT_THRESHOLD,
			difference: differenceScore
		};
	}

	/**
	 * Fuzzily compares to see if all the slaves in an array are close enough to be used again
	 *
	 * @param {App.Entity.SlaveState[]} slaveArr1
	 * @param {App.Entity.SlaveState[]} slaveArr2
	 *
	 * @returns {{canReuse: boolean, averageDifference: number}} Comparison results
	 */
	function fuzzyCompareSlavesArr(slaveArr1, slaveArr2) {
		let totalDifference = 0;
		let canReuse = true;

		if (slaveArr1.length !== slaveArr2.length) {
			throw new Error(`Trying to compare slave arrays of different sizes: ${JSON.stringify(slaveArr1)}, ${JSON.stringify(slaveArr2)}`);
		}

		for (let i = 0; i < slaveArr1.length; i++) {
			const diff = fuzzyCompareSlaves(slaveArr1[i], slaveArr2[i]);

			if (!diff.canReuse) {
				canReuse = false;
			}

			totalDifference += diff.difference;
		}

		return {
			canReuse,
			averageDifference: totalDifference / slaveArr1.length
		};
	}

	/**
	 * Finds the closest events from an array of events
	 *
	 * @private
	 *
	 * @param {App.Entity.SlaveState[]} slaveStates The state of the slaves you want an image for
	 * @param {App.Art.GenAI.EventStore.Entry[]} entries Previous event entries
	 * @param {{forceRegenerate: boolean}} options Fully populated misc options.
	 *
	 * @returns {{averageDifference: number, matches: App.Art.GenAI.EventStore.Entry[]}} Matches, and the lowest difference it could find. If totalDifference is 0, then it means it was an exact match.
	 */
	function findClosestEvents(slaveStates, entries, options) {
		/**
		 * @private
		 * @typedef ClosestEventRecord
		 * @property {App.Art.GenAI.EventStore.Entry[]} matches
		 * @property {number} averageDifference
		 */
		const fuzzyResults = entries.map((entry) => {
			return {
				entry,
				...fuzzyCompareSlavesArr(slaveStates, entry.slaveStates)
			};
		}).filter((record) => record.canReuse)
			.reduce(( /** @type {ClosestEventRecord} */prevRecord, currentRecord) => {
				if (currentRecord.averageDifference < prevRecord.averageDifference) {
					return {
						matches: [currentRecord.entry],
						averageDifference: currentRecord.averageDifference
					};
				} else if (currentRecord.averageDifference === prevRecord.averageDifference) {
					prevRecord.matches.push(currentRecord.entry);
					return prevRecord;
				}
			}, {matches: [], averageDifference: Number.MAX_SAFE_INTEGER});
		// to regenerate, we need an exact match.
		if (options.forceRegenerate && fuzzyResults.averageDifference > 0) {
			return {
				matches: [],
				averageDifference: Number.MAX_SAFE_INTEGER
			};
		}

		return fuzzyResults;
	}

	/**
	 * Get an image from the IndexedDB
	 * @param {App.Entity.SlaveState[]} slaves The ID of the image to retrieve
	 * @param {Partial<App.Art.GenAI.GetImageOptions>} [options] Misc options.
	 * Defaults: action='overview', size=App.Art.ArtSizes.SMALL, forceRegenerate: false, isEventImage: false
	 *
	 * @returns {Promise<App.Art.GenAI.EventStore.Entry>} Promise object that resolves with the retrieved image data
	 */
	async function getImage(slaves, options = {}) {
		await waitForInit();

		/** @type {App.Art.GenAI.GetImageOptions} */
		const effectiveOptions = {
			/** @type {App.Art.GenAI.Action} */
			action: 'overview',
			size: App.Art.ArtSizes.SMALL,
			forceRegenerate: false,
			isEventImage: false,
			...options
		};

		// Data is optional
		/** @type {Omit<App.Art.GenAI.EventStore.Entry, "data"> & { data?: App.Art.GenAI.EventStore.DataType}} */
		let event = {
			slaveIds: slaves.map(s => s.ID).sort(),
			seed: slaves[0].natural.artSeed,
			slaveStates: JSON.parse(JSON.stringify(slaves)),
			action: effectiveOptions.action,
		};

		// look for identical event
		/** @type {App.Art.GenAI.EventStore.Entry[]} */
		const eventEntries = await db.getAllFromIndex(EVENT_STORE.path, EVENT_STORE.indicies.bySlaveIdsActions, IDBKeyRange.only([event.slaveIds, event.action]));

		const {matches, averageDifference} = findClosestEvents(slaves, eventEntries, effectiveOptions);
		const shouldUseCache = (averageDifference <= SIGNIFICANTLY_DIFFERENT_THRESHOLD) && !effectiveOptions.forceRegenerate;
		const isExactMatch = averageDifference === 0;
		const chosenEvent = matches[Math.floor(Math.random() * matches.length)];

		// Use the cached value
		if (matches?.length > 0 && shouldUseCache) {
			// Any of the allowed entries will work. Return a random one.
			return matches[Math.floor(Math.random() * matches.length)];
		}

		const base64Image = await generateNewImage(slaves, effectiveOptions);

		/** @type {App.Art.GenAI.EventStore.Entry} */
		// @ts-expect-error
		let fullEvent = event;
		if (isExactMatch) {
			// fill in with previous data
			fullEvent = {
				...event,
				...chosenEvent
			};
		}

		if (event.action === 'overview') {
			fullEvent.data = {
				images: {
					lowRes: base64Image
				}
			};
		}

		// save it to the DB, unless it's temporary
		if (!fullEvent.slaveIds.includes(0)) {
			await db.put(EVENT_STORE.path, fullEvent);
		}

		return fullEvent;
	}

	/**
	 * Count the images currently in the DB
	 * @returns {Promise<number>}
	 */
	async function count() {
		await waitForInit();
		return db.count(EVENT_STORE.path);
	}

	async function init() {
		db = await createDB();
		initialized = true;
	}

	function isInit() {
		return initialized;
	}

	/**
	 * Purge all the images from DB
	 */
	async function clear() {
		await waitForInit();
		return Promise.all(ALL_STORES.map(store => db.clear(store.path)));
	}

	// /**
	//  * Gets all images where actor(s) have participated in.
	//  *
	//  * @param {number | number[]} slaveIds Id of actor(s) in the image.
	//  * @param {{soloOnly: boolean}} options
	//  */
	// async function getAllSlaveEvents(slaveIds, options) {
	// 	// TODO for gallery view
	// }

	// /**
	//  * Gets all images where actor(s) have participated in.
	//  *
	//  * @param {number | number[]} slaveIds Id of actor(s) in the image.
	//  * @param {{soloOnly: boolean}} options
	//  */
	// async function getAllSlaveImages(slaveIds, options) {
	// 	/** @type {number[]} */
	// 	let searchForIds;
	// 	if (typeof slaveIds === 'number') {
	// 		searchForIds = [slaveIds];
	// 	} else {
	// 		searchForIds = slaveIds;
	// 	}

	// 	const actorQuery = IDBKeyRange.only(slaveIds);

	// 	const tx = db.transaction([EVENT_STORE.path]);
	// 	const cursor = await tx.objectStore(EVENT_STORE.path)
	// 		.index(EVENT_STORE.indicies.bySlaveId)
	// 		.openCursor(actorQuery);

	// 	const eventEntries = [];
	// 	let eventEntry;
	// 	while (eventEntry = await cursor?.continue()) {
	// 		eventEntries.push(eventEntry);
	// 	}
	// }

	/**
	 * Regenerates images for all slaves
	 */
	async function regenAllImages() {
		V.slaves.forEach((s) => getImage([s], {forceRegenerate: true}));
	}

	/**
	 * Count the images currently in the DB
	 * @returns {Promise<string>}
	 */
	async function sizeInfo() {
		await waitForInit();
		const numImages = await count();

		let sizeEstimate;
		// Navigator.storage not supported in all browsers
		try {
			// Total memory usage of this origin
			const estimate = await navigator.storage.estimate();
			sizeEstimate = (estimate.usage / 1024 / 1024).toFixed(2) + "MB";
		} catch (e) {
			console.error(e);
		}

		return `${numImages} images ${sizeEstimate ? ` (${sizeEstimate})` : ''}`;
	}

	return {
		/**
		 * Flow control
		 */
		/** Initalizes database */
		init,
		/** Resolves promise when DB has been initalized */
		waitForInit,
		/** Checks to see if DB is initalized */
		isInit,
		/** Close the DB */
		close,
		/** Number of images in the DB */
		count,
		sizeInfo,

		/**
		 * Intelligently (create/update/reads image) then returns it. You should use this most of the time.
		 * Ideally, the DB will deal with caching/sending requests to SD.
		 */
		getImage,
		/** Gets all images of a given slave */
		// getAllSlaveImages,
		/** Clear DB to start from scratch */
		clear,
		/** Regenerate images for _all slaves_ */
		regenAllImages,
	};
})();

App.Art.GenAI.reactiveImageDB.init();


code:
/* eslint-disable camelcase */
// cSpell:ignore swinir, sdapi, yolov, VBOR

App.Art.GenAI.StableDiffusionSettings = class {
	/**
	 * @typedef {object} ConstructorOptions
	 * @param {boolean} [enable_hr=true]
	 * @param {number} [denoising_strength=0.3]
	 * @param {number} [hr_scale=1.7]
	 * @param {string} [hr_upscaler="SwinIR_4x"]
	 * @param {number} [hr_second_pass_steps=10]
	 * @param {string} [prompt=""]
	 * @param {number} [seed=1337]
	 * @param {string} [sampler_name="DPM++ 2M SDE Karras"]
	 * @param {number} [steps=20]
	 * @param {number} [cfg_scale=5.5]
	 * @param {number} [width=512]
	 * @param {number} [height=768]
	 * @param {boolean} [restore_faces=true] Whether to use a model to restore faces or not
	 * @param {string} [negative_prompt=""]
	 * @param {string[]} [override_settings=["Discard penultimate sigma: True"]]
	 * @param {object} [alwayson_scripts={}] Always on Scripts (e.g. ADetailer)
	 */

	/**
	 * @param {ConstructorOptions} options The options for the constructor.
	 */
	constructor({
		enable_hr = true,
		denoising_strength = 0.3,
		hr_scale = 1.7,
		hr_upscaler = "SwinIR_4x",
		hr_second_pass_steps = 10,
		prompt = "",
		seed = 1337,
		sampler_name = "DPM++ 2M SDE Karras",
		steps = 20,
		cfg_scale = 5.5,
		width = 512,
		height = 768,
		negative_prompt = "",
		restore_faces = true,
		override_settings = {
			"always_discard_next_to_last_sigma": true,
		},
		alwayson_scripts = {}
	} = {}) {
		this.enable_hr = enable_hr;
		this.denoising_strength = denoising_strength;
		this.firstphase_width = width;
		this.firstphase_height = height;
		this.hr_scale = hr_scale;
		this.hr_upscaler = hr_upscaler;
		this.hr_second_pass_steps = hr_second_pass_steps;
		this.hr_sampler_name = sampler_name;
		this.hr_prompt = prompt;
		this.hr_negative_prompt = negative_prompt;
		this.prompt = prompt;
		this.seed = seed;
		this.sampler_name = sampler_name;
		this.batch_size = 1;
		this.n_iter = 1;
		this.steps = steps;
		this.cfg_scale = cfg_scale;
		this.width = width;
		this.height = height;
		this.negative_prompt = negative_prompt;
		this.restore_faces = restore_faces;
		this.override_strings = override_settings;
		this.override_settings_restore_afterwards = true;
		this.alwayson_scripts = alwayson_scripts;
	}
};

/**
 * @param {string} url
 * @param {number} timeout
 * @param {object} [options]
 * @returns {Promise<Response>}
 */
async function fetchWithTimeout(url, timeout, options) {
	const controller = new AbortController();
	const id = setTimeout(() => controller.abort(), timeout);
	const response = await fetch(url, {signal: controller.signal, ...options});
	clearTimeout(id);
	return response;
}

/**
 * @typedef App.Art.GenAI.SdQueueItem
 * @property {number} slaveID
 * @property {string} body
 * @property {boolean} isEventImage
 * @property {[function(object): void]} resolves
 * @property {[function(string): void]} rejects
 */

App.Art.GenAI.StableDiffusionClientQueue = class {
	constructor() {
		// Images for this current screen
		/**  @type {Array<App.Art.GenAI.SdQueueItem>} */
		this.queue = [];
		// Images for permanent slaves (i.e. not event) that were requested to be generated in previous screens
		/**  @type {Array<App.Art.GenAI.SdQueueItem>} */
		this.backlogQueue = [];
		this.interrupted = false;
		/** @type {number|null} */
		this.workingOnID = null;
	}

	/**
	 * Updates the queue counts if on the ai image settings page
	 */
	updateQueueCounts() {
		// update queue counts if on the page
		["#mainQueueCount", "#backlogQueueCount"].forEach(queueElement => {
			const queue = $(queueElement);
			let count = 0;
			if (queueElement === "#mainQueueCount") {
				count = this.queue.length;
			} else {
				count = this.backlogQueue.length;
			}
			if (queue !== undefined && queue.length) {
				queue.empty().append(count.toString());
			}
		});
	}

	/**
	 * Process the top item in the queue, and continue processing the queue one at a time afterwards
	 * @private
	 */
	process() {
		if (this.workingOnID !== null) {
			return false;
		}
		if (this.interrupted) {
			return false;
		}

		let top;
		if (this.queue.length > 0) {
			top = this.queue.shift();
		} else if (this.backlogQueue.length > 0) {
			top = this.backlogQueue.shift();
		} else {
			return false;
		}

		try {
			this.workingOnID = top.slaveID;
			console.log(`Fetching image for slave ${top.slaveID}, ${this.queue.length} requests remaining in the queue; ${this.backlogQueue.length} in backlog.`);
			// console.log("Generation Settings: ", JSON.parse(top.body));
			const options = {
				method: "POST",
				headers: {
					"Content-Type": "application/json",
				},
				body: top.body,
			};
			fetchWithTimeout(`${V.aiApiUrl}/sdapi/v1/txt2img`, (V.aiTimeoutPerStep * 1000 + 200) * V.aiSamplingSteps, options)
				.then((value) => {
					return value.json();
				}).then(obj => {
					top.resolves.forEach(resolve => resolve(obj));
					this.workingOnID = null;
					this.updateQueueCounts();
					this.process();
				})
				.catch(err => {
					this.workingOnID = null;
					top.rejects.forEach(reject => reject(`${top.slaveID}: Error fetching Stable Diffusion image - status: ${err}`));
					this.updateQueueCounts();
					this.process();
				});
		} catch (err) {
			this.workingOnID = null;
			top.rejects.forEach(reject => reject(err));
			this.updateQueueCounts();
			this.process();
		}
		this.updateQueueCounts();
		return true;
	}

	/**
	 * await this in order to block until the queue exits the interrupted state
	 */
	async resumeAfterInterrupt() {
		const sleep = () => new Promise(r => setTimeout(r, 100));
		while (this.interrupted) {
			await sleep();
		}
	}

	/**
	 * await this in order to block until the queue stops processing
	 */
	async resumeAfterProcessing() {
		const sleep = () => new Promise(r => setTimeout(r, 100));
		while (this.workingOnID !== null) {
			await sleep();
		}
	}

	/**
	 * Queue image generation for an entity
	 * @param {number} slaveID or a unique negative value for non-slave entities
	 * @param {string} body of the post request to be sent to txt2img
	 * @param {boolean | false} isEventImage Whether to add the request to the beginning of the queue for a faster response
	 * @returns {Promise<object>}
	 */
	async add(slaveID, body, isEventImage = false) {
		if (this.interrupted) {
			await this.resumeAfterInterrupt();
		}

		// if an image request already exists for this ID (and ID is not zero), and it's not an event image
		if (slaveID !== null && slaveID > 0) {
			const comparisonFn = V.aiCachingStrategy === 'static'
				? ((/** @type {App.Art.GenAI.SdQueueItem} */ x) => x.slaveID === slaveID)
				: ((/** @type {App.Art.GenAI.SdQueueItem} */ x) => x.body === body); // reactive needs exact match

			// if it's in the backlog queue, and the new request is also for a permanent image, pull it into the foreground queue first
			if (!isEventImage) {
				let blItem = this.backlogQueue.find(comparisonFn);
				if (blItem) {
					this.queue.push(blItem);
					this.backlogQueue.delete(blItem);
				}
			}
			let item = this.queue.find(comparisonFn);
			if (item) {
				// if id is already queued, add a handle to receive the previously queued Promise's response and update `body` with the new query
				return new Promise((resolve, reject) => {
					item.body = body;
					item.resolves.push(resolve);
					item.rejects.push(reject);
				});
			}
		}
		return new Promise((resolve, reject) => {
			if (isEventImage) {
				// inject event images to the beginning of the queue
				this.queue.unshift({
					slaveID: slaveID,
					body: body,
					isEventImage: isEventImage,
					resolves: [resolve],
					rejects: [reject]
				});
			} else {
				this.queue.push({
					slaveID: slaveID,
					body: body,
					isEventImage: isEventImage,
					resolves: [resolve],
					rejects: [reject]
				});
			}

			this.updateQueueCounts();

			this.process(); // do not await
		});
	}

	onPassageSwitch() {
		this.backlogQueue = [...this.queue.filter((job) => !job.isEventImage), ...this.backlogQueue];
		this.queue = [];
	}

	/**
	 * Stop processing the queue and reject everything in it.
	 */
	async interrupt() {
		if (this.interrupted) { // permit nesting and consecutive calls
			return false;
		}

		this.interrupted = true; // pause processing of the queue and don't accept further interrupts

		// reject everything in the backlog queue
		while (this.backlogQueue.length > 0) {
			let item = this.backlogQueue.pop();
			if (item) {
				item.rejects.forEach(r => r(`${item.slaveID}: Stable Diffusion fetch interrupted`));
			}
		}
		this.backlogQueue = [];

		// and also everything in the main queue
		while (this.queue.length > 0) {
			let item = this.queue.pop();
			if (item) {
				item.rejects.forEach(r => r(`${item.slaveID}: Stable Diffusion fetch interrupted`));
			}
		}
		this.queue = [];

		this.sendInterrupt();

		this.interrupted = false; // resume with next add
		return true;
	}

	sendInterrupt() {
		// tell SD to stop generating the current image
		const options = {
			method: "POST",
			headers: {
				"Content-Type": "application/json",
			},
		};
		fetchWithTimeout(`${V.aiApiUrl}/sdapi/v1/interrupt`, 1000, options)
			.then(() => {
				console.log("Stable Diffusion: Interrupt Sent.");
			}).catch(() => {
				// ignore errors
			});
	}
};

// instantiate global queue
App.Art.GenAI.sdQueue = new App.Art.GenAI.StableDiffusionClientQueue();

App.Art.GenAI.StableDiffusionClient = class {
	/**
	 * @param {FC.SlaveState} slave
	 * @param {number} steps to use when generating the image
	 * @returns {Promise<InstanceType<App.Art.GenAI.StableDiffusionSettings>>}
	 */
	async buildStableDiffusionSettings(slave, steps) {
		const prompt = buildPrompt(slave);

		// TODO: Add more config options to ADetailer, and add ReActor
		const alwaysOnScripts = {};
		if (V.aiAdetailerFace) {
			// API Docs: https://github.com/Bing-su/adetailer/wiki/API
			alwaysOnScripts.ADetailer = {
				"args": [
					true, // ad_enable
					true, // skip_img2img
					{
						"ad_model": "face_yolov8s.pt"
					}
				]
			};
		}
		const poseFile = await App.Art.GenAI.getOpenPoseData(slave);
		if (poseFile) {
			// API Docs: https://github.com/Mikubill/sd-webui-controlnet/wiki/API#web-api
			alwaysOnScripts.controlnet = {
				"args": [
					{
						"input_image": poseFile,
						"module": "none",
						"model": V.aiOpenPoseModel
					}
				]
			};
		}

		if (V.aiDynamicCfgEnabled) {
			alwaysOnScripts['Dynamic Thresholding (CFG Scale Fix)'] = {
				"args": [
					true, // Enabled
					V.aiDynamicCfgMimic, // mimic scale (5-12 ish)
					100, // threshold percentile
					"Half Cosine Up", // mimic mode
					V.aiDynamicCfgMinimum, // mimic scale
					"Half Cosine Up", // cfg mode
					V.aiDynamicCfgMinimum // cfg scale
				]
			};
		}

		const settings = new App.Art.GenAI.StableDiffusionSettings({
			cfg_scale: V.aiCfgScale,
			enable_hr: V.aiUpscale,
			height: V.aiHeight,
			hr_upscaler: V.aiUpscaler,
			hr_scale: V.aiUpscaleScale,
			negative_prompt: prompt.negative(),
			prompt: prompt.positive(),
			sampler_name: V.aiSamplingMethod,
			seed: slave.natural.artSeed,
			steps: steps,
			width: V.aiWidth,
			restore_faces: V.aiRestoreFaces,
			alwayson_scripts: alwaysOnScripts
		});
		return settings;
	}

	/** Note the long timeout; if SD is actively rendering it'll sometimes stop responding to API queries.
	 * Do not block on API calls.
	 * @param {string} relativeUrl
	 * @returns {Promise<Response>}
	 */
	async fetchAPIQuery(relativeUrl) {
		return fetchWithTimeout(`${V.aiApiUrl}${relativeUrl}`, 30000, {method: "GET"});
	}

	/**
	 * @returns {Promise<string[]>}
	 */
	async getUpscalerList() {
		return this.fetchAPIQuery(`/sdapi/v1/upscalers`)
			.then((value) => {
				return value.json();
			})
			.then((list) => {
				return list.map(o => o.name);
			})
			.catch(err => {
				console.log(`Failed to get upscaler list from Stable Diffusion.`);
				return [];
			});
	}

	/**
	 * @returns {Promise<string[]>}
	 */
	async getSamplerList() {
		return this.fetchAPIQuery(`/sdapi/v1/samplers`)
			.then((value) => {
				return value.json();
			})
			.then((list) => {
				return list.map(o => o.name);
			})
			.catch(err => {
				console.log(`Failed to get sampler list from Stable Diffusion.`);
				return [];
			});
	}

	/** Check to see whether a face restore model is configured.
	 * @returns {Promise<boolean>}
	 */
	async canRestoreFaces() {
		return this.fetchAPIQuery(`/sdapi/v1/face-restorers`)
			.then((value) => {
				return value.json();
			})
			.then((list) => {
				return list.some(o => !!o.cmd_dir);
			})
			.catch(err => {
				console.log(`Failed to get face restorers from Stable Diffusion.`);
				return false;
			});
	}

	/** Check to see if the ADetailer script is installed. Probably should check more than that, but this'll catch the dumb cases.
	 * @returns {Promise<boolean>}
	 */
	async hasAdetailer() {
		return this.fetchAPIQuery(`/sdapi/v1/script-info`)
			.then((value) => {
				return value.json();
			})
			.then((list) => {
				return list.some(o => o.name === "adetailer");
			})
			.catch(err => {
				console.log(`Failed to get script information from Stable Diffusion.`);
				return false;
			});
	}

	/** Check to see if the ControlNet script is installed.
	 * @returns {Promise<boolean>}
	 */
	async hasControlNet() {
		return this.fetchAPIQuery(`/sdapi/v1/script-info`)
			.then((value) => {
				return value.json();
			})
			.then((list) => {
				return list.some(o => o.name === "controlnet");
			})
			.catch(err => {
				console.log(`Failed to get script information from Stable Diffusion.`);
				return false;
			});
	}

	/** Check to see if the OpenPose module for ControlNet is set up.
	 * @returns {Promise<boolean>}
	 */
	async _hasOpenPoseControlNetModule() {
		return this.fetchAPIQuery(`/controlnet/module_list`)
			.then((value) => {
				return value.json();
			})
			.then((obj) => {
				return obj.module_list.some(o => o === "openpose");
			})
			.catch(err => {
				console.log(`Failed to get ControlNet Module information from Stable Diffusion.`);
				return false;
			});
	}

	/** Check to see if OpenPose is fully set up.
	 * @returns {Promise<boolean>}
	 */
	async hasOpenPose() {
		const hasCN = await this.hasControlNet();
		if (hasCN) {
			return this._hasOpenPoseControlNetModule();
		} else {
			return false;
		}
	}

	/**
	 * @returns {Promise<string[]>}
	 */
	async getOpenPoseModelList() {
		return this.fetchAPIQuery(`/controlnet/control_types`)
			.then((value) => {
				return value.json();
			})
			.then((list) => {
				return list.control_types.OpenPose.model_list;
			})
			.catch(err => {
				console.log(`Failed to get OpenPose model list from Stable Diffusion.`);
				return [];
			});
	}

	/**
	 * @param {string} json
	 * @returns {Promise<string>}
	 */
	async renderOpenPoseJSON(json) {
		const options = {
			method: "POST",
			headers: {
				"Content-Type": "application/json",
			},
			body: `[${json}]`
		};
		return fetchWithTimeout(`${V.aiApiUrl}/controlnet/render_openpose_json`, 30000, options)
			.then(value => {
				return value.json();
			})
			.then(obj => {
				if (obj.info !== "Success") {
					throw new Error(obj.info);
				}
				return obj.images[0];
			});
	}
};

App.Art.GenAI.sdClient = new App.Art.GenAI.StableDiffusionClient();

/**
 * Determines whether the current passage has the "temporary-images" tag
 * @returns {boolean}
 */
function isTemporaryImage() {
	return $(`[data-tags*=temporary-images]`).length > 0;
}

App.Art.GenAI.StaticCaching = class {
	/**
	 * @param {FC.SlaveState} slave
	 * @param {boolean | null} isEventImage - Whether request is canceled on passage change and which step setting to use. true => V.aiSamplingStepsEvent, false => V.aiSamplingSteps, null => chosen based on passage tags
	 * @returns {Promise<string>} - Base 64 encoded image (could be a jpeg, png, or webp)
	 */
	async fetchImageForSlave(slave, isEventImage = null) {
		let steps = V.aiSamplingSteps;
		// always render owned slaves at full steps and without the passageSwitchHandler.  This allows the player to queue updates for slave images during events.
		if (globalThis.getSlave(slave.ID)) {
			isEventImage = false;
		}
		if (isEventImage === null) {
			isEventImage = isTemporaryImage();
		}
		if (isEventImage === true) {
			steps = V.aiSamplingStepsEvent;
		}

		const settings = await App.Art.GenAI.sdClient.buildStableDiffusionSettings(slave, steps);
		const body = JSON.stringify(settings);
		// set up a passage switch handler to clear queued generation of event and temporary images upon passage change
		const oldHandler = App.Utils.PassageSwitchHandler.get();
		if (isEventImage || isTemporaryImage()) {
			App.Utils.PassageSwitchHandler.set(() => {
				// find where this request is in the queue
				let rIndex = App.Art.GenAI.sdQueue.queue.findIndex(r => r.slaveID === slave.ID && r.body === body);
				if (rIndex > -1) {
					const rejects = App.Art.GenAI.sdQueue.queue[rIndex].rejects;
					// remove request from the queue as soon as possible
					App.Art.GenAI.sdQueue.queue.splice(rIndex, 1);
					// reject the associated promises
					rejects.forEach(r => r(`${slave.ID} (Event): Stable Diffusion fetch interrupted`));
				} else if (App.Art.GenAI.sdQueue.workingOnID === slave.ID) {
					// if this request is already in progress, send interrupt request
					App.Art.GenAI.sdQueue.sendInterrupt();
				}
				App.Art.GenAI.sdQueue.onPassageSwitch();
				if (oldHandler) {
					oldHandler();
				}
			});
		} else {
			const oldHandler = App.Utils.PassageSwitchHandler.get();
			App.Utils.PassageSwitchHandler.set(() => {
				App.Art.GenAI.sdQueue.onPassageSwitch();
				if (oldHandler) {
					oldHandler();
				}
			});
		}

		const response = await App.Art.GenAI.sdQueue.add(slave.ID, body, isEventImage);
		return response.images[0];
	}

	/**
	 * Update a slave object with a new image
	 * @param {FC.SlaveState} slave - The slave to update
	 * @param {number | null} replacementImageIndex - If provided, replace the image at this index
	 * @param {boolean | null} isEventImage - Whether request is canceled on passage change and which step setting to use. true => V.aiSamplingStepsEvent, false => V.aiSamplingSteps, null => chosen based on passage tags
	 */
	async updateSlave(slave, replacementImageIndex = null, isEventImage = null) {
		const base64Image = await this.fetchImageForSlave(slave, isEventImage);
		const imageData = getImageData(base64Image);
		const imagePreexisting = await compareExistingImages(slave, imageData);
		let vSlave = globalThis.getSlave(slave.ID);
		// if `slave` is owned but the variable has become detached from V.slaves, save the image changes to V.slaves instead
		if (vSlave && slave !== vSlave) {
			slave = vSlave;
		}
		// If new image, add or replace it in
		if (imagePreexisting === -1) {
			const imageId = await App.Art.GenAI.staticImageDB.putImage({data: imageData});
			if (replacementImageIndex !== null) {
				await App.Art.GenAI.staticImageDB.removeImage(slave.custom.aiImageIds[replacementImageIndex]);
				slave.custom.aiImageIds[replacementImageIndex] = imageId;
			} else {
				slave.custom.aiImageIds.push(imageId);
				slave.custom.aiDisplayImageIdx = slave.custom.aiImageIds.indexOf(imageId);
			}
			// If image already exists, just update the display idx to it
		} else {
			console.log('Generated redundant image, no image stored');
			slave.custom.aiDisplayImageIdx = imagePreexisting;
		}
	}
};

App.Art.GenAI.staticCache = new App.Art.GenAI.StaticCaching();

App.Art.GenAI.ReactiveCaching = class {
	/**
	 * @param {FC.SlaveState} slave
	 * @param {boolean | null} isEventImage - Whether request is canceled on passage change and which step setting to use. true => V.aiSamplingStepsEvent, false => V.aiSamplingSteps, null => chosen based on passage tags
	 * @returns {Promise<string>} - Base 64 encoded image (could be a jpeg, png, or webp)
	 */
	async fetchImageForSlave(slave, isEventImage = null) {
		let steps = V.aiSamplingSteps;
		// always render owned slaves at full steps and without the passageSwitchHandler.  This allows the player to queue updates for slave images during events.
		if (globalThis.getSlave(slave.ID)) {
			isEventImage = false;
		}
		if (isEventImage === null) {
			isEventImage = isTemporaryImage();
		}
		if (isEventImage === true) {
			steps = V.aiSamplingStepsEvent;
		}

		const settings = await App.Art.GenAI.sdClient.buildStableDiffusionSettings(slave, steps);
		const body = JSON.stringify(settings);
		// set up a passage switch handler to clear queued generation of event and temporary images upon passage change
		const oldHandler = App.Utils.PassageSwitchHandler.get();
		if (isEventImage || isTemporaryImage()) {
			App.Utils.PassageSwitchHandler.set(() => {
				// find where this request is in the queue
				let rIndex = App.Art.GenAI.sdQueue.queue.findIndex(r => r.slaveID === slave.ID && r.body === body);
				if (rIndex > -1) {
					const rejects = App.Art.GenAI.sdQueue.queue[rIndex].rejects;
					// remove request from the queue as soon as possible
					App.Art.GenAI.sdQueue.queue.splice(rIndex, 1);
					// reject the associated promises
					rejects.forEach(r => r(`${slave.ID} (Event): Stable Diffusion fetch interrupted`));
				} else if (App.Art.GenAI.sdQueue.workingOnID === slave.ID) {
					// if this request is already in progress, send interrupt request
					App.Art.GenAI.sdQueue.sendInterrupt();
				}
				App.Art.GenAI.sdQueue.onPassageSwitch();
				if (oldHandler) {
					oldHandler();
				}
			});
		} else {
			const oldHandler = App.Utils.PassageSwitchHandler.get();
			App.Utils.PassageSwitchHandler.set(() => {
				App.Art.GenAI.sdQueue.onPassageSwitch();
				if (oldHandler) {
					oldHandler();
				}
			});
		}

		const response = await App.Art.GenAI.sdQueue.add(slave.ID, body, isEventImage);
		return response.images[0];
	}

	/**
	 * Update a slave object with a new image
	 * @param {FC.SlaveState} slave - The slave to update
	 * @param {number | null} replacementImageIndex - If provided, replace the image at this index
	 * @param {boolean | null} isEventImage - Whether request is canceled on passage change and which step setting to use. true => V.aiSamplingStepsEvent, false => V.aiSamplingSteps, null => chosen based on passage tags
	 */
	async updateSlave(slave, replacementImageIndex = null, isEventImage = null) {
		const base64Image = await this.fetchImageForSlave(slave, isEventImage);
		const imageData = getImageData(base64Image);
		console.log("Image data", imageData);
		const imagePreexisting = await compareExistingImages(slave, imageData);
		let vSlave = globalThis.getSlave(slave.ID);
		// if `slave` is owned but the variable has become detached from V.slaves, save the image changes to V.slaves instead
		if (vSlave && slave !== vSlave) {
			slave = vSlave;
		}
		// If new image, add or replace it in
		if (imagePreexisting === -1) {
			const imageId = await App.Art.GenAI.reactiveImageDB.putImage({data: imageData});
			if (replacementImageIndex !== null) {
				await App.Art.GenAI.reactiveImageDB.removeImage(slave.custom.aiImageIds[replacementImageIndex]);
				slave.custom.aiImageIds[replacementImageIndex] = imageId;
			} else {
				slave.custom.aiImageIds.push(imageId);
				slave.custom.aiDisplayImageIdx = slave.custom.aiImageIds.indexOf(imageId);
			}
			// If image already exists, just update the display idx to it
		} else {
			console.log('Generated redundant image, no image stored');
			slave.custom.aiDisplayImageIdx = imagePreexisting;
		}
	}
};

App.Art.GenAI.reactiveCache = new App.Art.GenAI.ReactiveCaching();

/**
 * Search slave's existing images for a match with the new image.
 * @param {FC.SlaveState} slave - The slave we're updating
 * @param {string} newImageData - new image
 * @returns {Promise<number>} index of the image in aiImageIds or -1
 */
async function compareExistingImages(slave, newImageData) {
	const aiImages = await Promise.all(
		slave.custom.aiImageIds.map(id =>
			App.Art.GenAI.staticImageDB.getImage(id)
				.catch(() => null)  // Return null if the image is not found or there's an error
		)
	);

	return aiImages.findIndex(img => img && img.data === newImageData);
}

/**
 * Add mime type to a base64 encoded image
 * @param {string} base64Image
 * @returns {string} data string
 */
function getImageData(base64Image) {
	const mimeType = getMimeType(base64Image);
	return `data:${mimeType};base64,${base64Image}`;
}

/**
 * @param {string} base64Image
 * @returns {string}
 */
function getMimeType(base64Image) {
	const jpegCheck = "/9j/";
	const pngCheck = "iVBOR";
	const webpCheck = "UklGR";

	if (base64Image.startsWith(jpegCheck)) {
		return "image/jpeg";
	} else if (base64Image.startsWith(pngCheck)) {
		return "image/png";
	} else if (base64Image.startsWith(webpCheck)) {
		return "image/webp";
	} else {
		return "unknown";
	}
}


code:
App.Art.GenAI.staticImageDB = (function() {
	/** @type {IDBDatabase} */
	let db;

	let initialized = false;
	async function waitForInit() {
		const sleep = () => new Promise(r => setTimeout(r, 10));
		while (!initialized) {
			await sleep();
		}
	}

	/**
	 * Create an IndexedDB and initialize objectStore if it doesn't already exist.
	 * @returns {Promise<IDBDatabase>} Promise object that resolves with the opened database
	 */
	async function createDB() {
		return new Promise((resolve, reject) => {
			const request = indexedDB.open('AIImages', 1);

			request.onerror = function() {
				console.log('Database failed to open');
				reject('Database failed to open');
				initialized = true; // stop waiting and just fail any pending operations
			};

			request.onsuccess = function() {
				console.log('Database opened successfully');
				db = request.result;
				initialized = true; // stop waiting and process pending operations
				resolve(db);
			};

			request.onupgradeneeded = function(e) {
				// @ts-ignore
				let db = e.target.result;
				db.createObjectStore('images', {keyPath: 'id', autoIncrement: true});
			};
		});
	}

	/**
	 * Add an image to the IndexedDB
	 * @param {object} imageData - The image data to store
	 * @returns {Promise<number>} Promise object that resolves with the ID of the stored image
	 */
	async function putImage(imageData) {
		await waitForInit();
		return new Promise((resolve, reject) => {
			let transaction = db.transaction(['images'], 'readwrite');
			let objectStore = transaction.objectStore('images');

			let request = objectStore.put(imageData);

			request.onsuccess = function() {
				resolve(Number(request.result));
			};

			transaction.oncomplete = function() {
				console.log('Transaction completed: database modification finished.');
			};

			transaction.onerror = function() {
				console.log('Transaction not opened due to error');
				reject('Transaction not opened due to error');
			};
		});
	}

	/**
	 * Get an image from the IndexedDB
	 * @param {number} id - The ID of the image to retrieve
	 * @returns {Promise<object>} Promise object that resolves with the retrieved image data
	 */
	async function getImage(id) {
		await waitForInit();
		return new Promise((resolve, reject) => {
			const transaction = db.transaction(['images'], 'readonly');
			const objectStore = transaction.objectStore('images');
			const request = objectStore.get(id);

			request.onsuccess = function() {
				if (request.result === undefined) {
					reject(new Error(`Image with ID ${id} not found in the database.`));
				} else {
					resolve(request.result);
				}
			};

			request.onerror = function() {
				reject(new Error(`Error fetching image with ID ${id}`));
			};
		});
	}

	/**
	 * Remove an image from the IndexedDB
	 * @param {number} id - The ID of the image to remove
	 */
	async function removeImage(id) {
		await waitForInit();
		return new Promise((resolve, reject) => {
			let transaction = db.transaction(['images'], 'readwrite');
			let objectStore = transaction.objectStore('images');

			let request = objectStore.delete(id);
			request.onsuccess = function() {
				resolve();
			};
		});
	}

	/**
	 * Purge all the images from DB
	 */
	async function clear() {
		await waitForInit();
		return new Promise((resolve, reject) => {
			let transaction = db.transaction(['images'], 'readwrite');
			let objectStore = transaction.objectStore('images');

			let request = objectStore.clear();
			request.onsuccess = function() {
				resolve();
			};
		});
	}

	/**
	 * Count the images currently in the DB
	 * @returns {Promise<string>}
	 */
	async function sizeInfo() {
		await waitForInit();
		const count = await new Promise((resolve) => {
			let transaction = db.transaction(['images'], 'readonly');
			let objectStore = transaction.objectStore('images');

			let request = objectStore.count();
			request.onsuccess = function() {
				resolve(request.result);
			};
		});

		let sizeEstimate;
		// Navigator.storage not supported in all browsers
		try {
			// Total memory usage of this origin
			const estimate = await navigator.storage.estimate();
			sizeEstimate = (estimate.usage / 1024 / 1024).toFixed(2) + "MB";
		} catch (e) {
			console.error(e);
		}

		return `${count} images ${sizeEstimate ? ` (${sizeEstimate})` : ''}`;
	}

	return {
		createDB,
		putImage,
		getImage,
		removeImage,
		clear,
		sizeInfo
	};
})();

App.Art.GenAI.staticImageDB.createDB();