code:
App.Art.GenAI.PiercingsPromptPart = class PiercingsPromptPart extends App.Art.GenAI.PromptPart {
	/**
	 * @returns {string}
	 */
	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(`, `);
		}
	}

	/**
	 * @returns {string}
	 */
	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 {
	/**
	 * @returns {string}
	 */
	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;
		}
	}

	/**
	 * @returns {string}
	 */
	negative() {
		return undefined;
	}
};

repo_name: Arkerthan/fc

license: mit


code:
App.Art.GenAI.PregPromptPart = class PregPromptPart extends App.Art.GenAI.PromptPart {
	/**
	 * @returns {string}
	 */
	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";
		}
	}

	/**
	 * @returns {string}
	 */
	negative() {
		return undefined;
	}
};


code:
App.Art.GenAI.PubicHairPromptPart = class PubicHairPromptPart extends App.Art.GenAI.PromptPart {
	/**
	 * @returns {string}
	 */
	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`;
	}

	/**
	 * @returns {string}
	 */
	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 {
	/**
	 * @returns {string}
	 */
	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;
	}

	/**
	 * @returns {string}
	 */
	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 {
	/**
	 * @returns {string}
	 */
	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`;
		}
	}

	/**
	 * @returns {string}
	 */
	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:
/* eslint-disable camelcase */
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;
}

App.Art.GenAI.StableDiffusionClientQueue = class {
	constructor() {
		/** @type {Array<{slaveID: number, body: string, resolves: [function(object): void], rejects: [function(string): void]}>} */
		this.queue = [];
		this.interrupted = false;
		/** @type {number|null} */
		this.workingOnID = null;
	}

	/**
	 * 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;
		}
		const top = this.queue.shift();
		if (!top) {
			return false;
		}
		try {
			this.workingOnID = top.slaveID;
			console.log(`Fetching image for slave ${top.slaveID}, ${this.queue.length} requests remaining in the queue.`);
			// 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.process();
				})
				.catch(err => {
					this.workingOnID = null;
					top.rejects.forEach(reject => reject(`${top.slaveID}: Error fetching Stable Diffusion image - status: ${err}`));
					this.process();
				});
		} catch (err) {
			this.workingOnID = null;
			top.rejects.forEach(reject => reject(err));
			this.process();
		}
		return true;
	}

	/**
	 * await this in order to block until the queue exits the interrupted state
	 */
	async resumeAfterInterrupt() {
		const sleep = () => new Promise(r => setTimeout(r, 10));
		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, 10));
		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)
		if (slaveID !== null && slaveID > 0) {
			let item = this.queue.find(i => i.slaveID === slaveID);
			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,
					resolves: [resolve],
					rejects: [reject]
				});
			} else {
				this.queue.push({
					slaveID: slaveID,
					body: body,
					resolves: [resolve],
					rejects: [reject]
				});
			}

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

	/**
	 * 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 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": [
					{
						"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
					}
				]
			};
		}

		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];
			});
	}

	/**
	 * @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 or png)
	 */
	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 = this.isTemporaryImage();
		}
		if (isEventImage === true) {
			steps = V.aiSamplingStepsEvent;
		}

		const settings = await this.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
		if (isEventImage || this.isTemporaryImage()) {
			const oldHandler = App.Utils.PassageSwitchHandler.get();
			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();
				}
				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.imageDB.putImage({data: imageData});
			if (replacementImageIndex !== null) {
				await App.Art.GenAI.imageDB.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;
		}
	}

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

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

/**
 * 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.imageDB.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.StylePromptPart = class StylePromptPart extends App.Art.GenAI.PromptPart {
	/**
	 * @returns {string}
	 */
	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";
		}
	}

	/**
	 * @returns {string}
	 */
	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 {
	/**
	 * @returns {string}
	 */
	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(', ');
		}
	}

	/**
	 * @returns {string}
	 */
	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 {
	/**
	 * @returns {string}
	 */
	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)`;
		}
	}

	/**
	 * @returns {string}
	 */
	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 {
	/**
	 * @returns {string}
	 */
	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`;
		}
	}

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