From dd9ed69129f9e49c24e7041dd402912e34df7857 Mon Sep 17 00:00:00 2001
From: Anonymous <>
Date: Tue, 25 Jul 2023 23:28:16 +0100
Subject: [PATCH] Add dynamic AI art

---
 css/art/genAI.css                     |  43 +++
 js/002-config/fc-js-init.js           |   3 +-
 js/003-data/gameVariableData.js       |   8 +-
 src/004-base/basePrompt.js            |  52 +++
 src/art/artJS.js                      | 104 +++++-
 src/art/genAI/agePromptPart.js        |  28 ++
 src/art/genAI/arousalPromptPart.js    |  22 ++
 src/art/genAI/beautyPromptPart.js     |  25 ++
 src/art/genAI/breastsPromptPart.js    |  37 +++
 src/art/genAI/buildPrompt.js          |  33 ++
 src/art/genAI/clothesPromptPart.js    | 461 ++++++++++++++++++++++++++
 src/art/genAI/collarPromptPart.js     |  20 ++
 src/art/genAI/expressionPromptPart.js |  76 +++++
 src/art/genAI/eyePromptPart.js        |  19 ++
 src/art/genAI/eyebrowPromptPart.js    |  21 ++
 src/art/genAI/hairPromptPart.js       |  37 +++
 src/art/genAI/healthPromptPart.js     |  28 ++
 src/art/genAI/heightPromptPart.js     |  23 ++
 src/art/genAI/hipsPromptPart.js       |  35 ++
 src/art/genAI/imageDB.js              |  83 +++++
 src/art/genAI/musclesPromptPart.js    |  33 ++
 src/art/genAI/piercingsPromptPart.js  |  57 ++++
 src/art/genAI/posturePromptPart.js    |  39 +++
 src/art/genAI/pubicHairPromptPart.js  |  21 ++
 src/art/genAI/racePromptPart.js       |  18 +
 src/art/genAI/skinPromptPart.js       |  79 +++++
 src/art/genAI/stableDiffusion.js      | 211 ++++++++++++
 src/art/genAI/stylePromptPart.js      |  15 +
 src/art/genAI/tattoosPromptPart.js    |  33 ++
 src/art/genAI/waistPromptPart.js      |  31 ++
 src/art/genAI/weightPromptPart.js     |  35 ++
 src/gui/options/options.js            |  10 +-
 src/interaction/siCustom.js           |  21 +-
 src/js/SlaveState.js                  |   7 +
 34 files changed, 1754 insertions(+), 14 deletions(-)
 create mode 100644 css/art/genAI.css
 create mode 100644 src/004-base/basePrompt.js
 create mode 100644 src/art/genAI/agePromptPart.js
 create mode 100644 src/art/genAI/arousalPromptPart.js
 create mode 100644 src/art/genAI/beautyPromptPart.js
 create mode 100644 src/art/genAI/breastsPromptPart.js
 create mode 100644 src/art/genAI/buildPrompt.js
 create mode 100644 src/art/genAI/clothesPromptPart.js
 create mode 100644 src/art/genAI/collarPromptPart.js
 create mode 100644 src/art/genAI/expressionPromptPart.js
 create mode 100644 src/art/genAI/eyePromptPart.js
 create mode 100644 src/art/genAI/eyebrowPromptPart.js
 create mode 100644 src/art/genAI/hairPromptPart.js
 create mode 100644 src/art/genAI/healthPromptPart.js
 create mode 100644 src/art/genAI/heightPromptPart.js
 create mode 100644 src/art/genAI/hipsPromptPart.js
 create mode 100644 src/art/genAI/imageDB.js
 create mode 100644 src/art/genAI/musclesPromptPart.js
 create mode 100644 src/art/genAI/piercingsPromptPart.js
 create mode 100644 src/art/genAI/posturePromptPart.js
 create mode 100644 src/art/genAI/pubicHairPromptPart.js
 create mode 100644 src/art/genAI/racePromptPart.js
 create mode 100644 src/art/genAI/skinPromptPart.js
 create mode 100644 src/art/genAI/stableDiffusion.js
 create mode 100644 src/art/genAI/stylePromptPart.js
 create mode 100644 src/art/genAI/tattoosPromptPart.js
 create mode 100644 src/art/genAI/waistPromptPart.js
 create mode 100644 src/art/genAI/weightPromptPart.js

diff --git a/css/art/genAI.css b/css/art/genAI.css
new file mode 100644
index 00000000000..5e10009b5f1
--- /dev/null
+++ b/css/art/genAI.css
@@ -0,0 +1,43 @@
+.ai-art-image {
+    transition: filter 0.5s ease-in-out;
+    position: relative;
+}
+
+.ai-art-container.refreshing .ai-art-image {
+    filter: blur(5px);
+}
+
+.spinner {
+    display: none;
+    position: absolute;
+    top: 50%;
+    left: 50%;
+    transform: translate(-50%, -50%);
+    font-size: 30px;
+    animation: spin 2s linear infinite;
+}
+
+.ai-art-container {
+    width: 100%;
+    height: 100%;
+    min-width: 100px;
+    min-height: 100px;
+    cursor: pointer;
+    float: right;
+    border: 3px hidden;
+    object-fit: contain;
+}
+
+.ai-art-container.refreshing .spinner {
+    display: block;
+}
+
+@keyframes spin {
+    0% {
+        transform: translate(-50%, -50%) rotate(0deg);
+    }
+
+    100% {
+        transform: translate(-50%, -50%) rotate(360deg);
+    }
+}
\ No newline at end of file
diff --git a/js/002-config/fc-js-init.js b/js/002-config/fc-js-init.js
index 2197e4b88e3..5447e561135 100644
--- a/js/002-config/fc-js-init.js
+++ b/js/002-config/fc-js-init.js
@@ -2,7 +2,7 @@
 // @ts-ignore
 "use strict";
 
-var App = { }; // eslint-disable-line no-redeclare
+var App = {}; // eslint-disable-line no-redeclare
 
 // When adding namespace declarations, please consider needs of those using VSCode:
 // when you declare App.A{ A1:{}, A2:{} }, VSCode considers A, A1, and A2 to be
@@ -15,6 +15,7 @@ var App = { }; // eslint-disable-line no-redeclare
 App.Arcology = {};
 App.Arcology.Cell = {};
 App.Art = {};
+App.Art.GenAI = {};
 App.Budget = {};
 App.Corporate = {};
 App.Corporate.Division = {};
diff --git a/js/003-data/gameVariableData.js b/js/003-data/gameVariableData.js
index 9de30128088..0e9234f13d0 100644
--- a/js/003-data/gameVariableData.js
+++ b/js/003-data/gameVariableData.js
@@ -99,6 +99,7 @@ App.Data.defaultGameStateVariables = {
 	 * | *3*    	 | Revamped embedded vector art |
 	 * | *4*    	 | Elohiem's WebGL              |
 	 * | *5*    	 | Shokushu's rendered          |
+	 * / *6*		 / Anon's AI image generation   /
 	 */
 	imageChoice: 1,
 	inbreeding: 1,
@@ -168,6 +169,7 @@ App.Data.defaultGameStateVariables = {
 	set3QView: false,
 	seeAnimation: false,
 	animFPS: 12,
+	aiApiUrl: "http://localhost:7860",
 	showAgeDetail: 1,
 	showAppraisal: 1,
 	showAssignToScenes: 1,
@@ -518,7 +520,7 @@ App.Data.resetOnNGPlus = {
 	defaultRules: [],
 	/** @type {Object.<string, number[]>} */
 	rulesToApplyOnce: {},
-	raDefaultMode : 0,
+	raDefaultMode: 0,
 
 	RECheckInIDs: [],
 
@@ -1015,7 +1017,9 @@ App.Data.resetOnNGPlus = {
 	/** @type {FC.SlaveStateOrZero} */
 	hostageWife: 0,
 	/** @type {FC.Rival} */
-	rival: {state: 0, duration: 0, prosperity: 0, power: 0, FS: {name: ""}, hostageState: 0},
+	rival: {
+		state: 0, duration: 0, prosperity: 0, power: 0, FS: {name: ""}, hostageState: 0
+	},
 	nationHate: 0,
 	eventResults: {},
 
diff --git a/src/004-base/basePrompt.js b/src/004-base/basePrompt.js
new file mode 100644
index 00000000000..55bb617dd12
--- /dev/null
+++ b/src/004-base/basePrompt.js
@@ -0,0 +1,52 @@
+/** base class for prompt parts */
+App.Art.GenAI.PromptPart = class PromptPart {
+	/**
+	 * @param {FC.SlaveState} slave
+	 */
+	constructor(slave) {
+		this.slave = slave;
+	}
+
+	/**
+	 * @returns {string}
+	 * @abstract
+	 */
+	positive() {
+		throw new Error("not implemented");
+	}
+
+	/**
+	 * @returns {string}
+	 * @abstract
+	 */
+	negative() {
+		throw new Error("not implemented");
+	}
+};
+
+App.Art.GenAI.Prompt = class Prompt {
+	/**
+	 * @param {App.Art.GenAI.PromptPart[]} parts
+	 */
+	constructor(parts) {
+		this.parts = parts;
+	}
+
+	/**
+	 * @returns {string}
+	 */
+	positive() {
+		let parts = this.parts.map(part => part.positive());
+		parts = parts.filter(part => part);
+		return parts.join(", ");
+	}
+
+	/**
+	 * @returns {string}
+	 */
+	negative() {
+		let parts = this.parts.map(part => part.negative());
+		parts = parts.filter(part => part);
+		return parts.join(", ");
+	}
+};
diff --git a/src/art/artJS.js b/src/art/artJS.js
index 1764d4a9d6f..a99ad1ea29d 100644
--- a/src/art/artJS.js
+++ b/src/art/artJS.js
@@ -134,6 +134,8 @@ App.Art.SlaveArtElement = function(artSlave, artSize, UIDisplay) {
 		return App.Art.webglArtElement(artSlave, artSize);
 	} else if (imageChoice === 5) { /* RENDERED IMAGES BY SHOKUSHU */
 		return App.Art.renderedArtElement(artSlave, artSize);
+	} else if (imageChoice === 6) { /* AI GENERATED IMAGES */
+		return App.Art.aiArtElement(artSlave, artSize);
 	}
 	throw new Error(`imageChoice ${imageChoice} is out of range`);
 };
@@ -150,7 +152,7 @@ App.Art.setDynamicCSS = function(newState) {
 		App.Art.dynamicCSS.innerHTML += '.smlImg { height: 150px; width: 150px }\n';
 		App.Art.dynamicCSS.innerHTML += '.medImg { height: 300px; width: 300px }\n';
 
-		App.Art.dynamicCSS.innerHTML += '.lrgRender:not(.custom) { height: '+height+'px; width: '+width+'px;}\n';
+		App.Art.dynamicCSS.innerHTML += '.lrgRender:not(.custom) { height: ' + height + 'px; width: ' + width + 'px;}\n';
 		App.Art.dynamicCSS.innerHTML += '.lrgRender > img { margin-left: auto; height: 530px; width: auto }\n';
 	} else {
 		App.Art.dynamicCSS.innerHTML = '';
@@ -202,7 +204,7 @@ App.Art.webglInitialize = function() {
 		try {
 			// load model/morphs/textures assets
 			let sceneData = App.Art.sceneGetData();
-			App.Art.sceneGetData = function(){};
+			App.Art.sceneGetData = function() { };
 			// load default dictionary containing camera/light/morph/material values
 			let scene = App.Art.sceneGetParams();
 			scene.lockView = false;
@@ -284,7 +286,7 @@ App.Art.webglArtElement = function(inSlave, artSize) {
 	// it's common for e.g. events to make alterations to a slave just before rendering and revert them afterwards
 	const slave = clone(inSlave);
 	container.addEventListener("engineLoaded", function() {
-		 new IntersectionObserver(function(entries) { // when visible in viewport
+		new IntersectionObserver(function(entries) { // when visible in viewport
 			if (entries.some(e => e.isIntersecting)) {
 				this.unobserve(container); // render only once
 
@@ -426,6 +428,102 @@ App.Art.customArtElement = function(imageInfo, imageSize) {
 	return res;
 };
 
+/**
+ * Render an AI generated image
+ * @param {App.Entity.SlaveState} slave - The slave whose image to render
+ * @param {number} imageSize - The size of the image to render
+ * @returns {Promise<HTMLElement>} Promise object that resolves with the created img element
+ */
+async function renderAIArt(slave, imageSize) {
+	let imgElement;
+	if (slave.custom.aiImageId === null) {
+		imgElement = document.createElement("div");
+	} else {
+		imgElement = document.createElement("img");
+	}
+
+	imgElement.classList.add("ai-art-image");
+	imgElement.setAttribute("style", "float:right; border:3px hidden; object-fit:contain; height:100%; width:100%;");
+
+	const sz = App.Art.artSizeToPx(imageSize);
+	if (sz) {
+		imgElement.setAttribute("width", sz);
+		imgElement.setAttribute("height", sz);
+	}
+
+	try {
+		const imageData = await App.Art.GenAI.imageDB.getImage(slave.custom.aiImageId);
+		imgElement.setAttribute("src", imageData.data);
+	} catch (e) {
+		console.error(e);
+	}
+
+	return imgElement;
+}
+
+/** AI generated image that refreshes on click
+ * @param {App.Entity.SlaveState} slave
+ * @param {number} imageSize
+ * @returns {HTMLElement}
+ */
+App.Art.aiArtElement = function(slave, imageSize) {
+	const container = document.createElement("div");
+	container.classList.add("ai-art-container");
+
+	/**
+	 * @param {HTMLDivElement} container
+	 */
+	function makeSpinner(container) {
+		const spinner = document.createElement("div");
+		spinner.classList.add("spinner");
+		spinner.innerText = '⟳';
+		container.appendChild(spinner);
+	}
+	makeSpinner(container);
+
+	// Refresh on click
+	function refresh() {
+		renderAIArt(slave, imageSize).then((imgElement) => {
+			jQuery(container).empty().append(imgElement);
+			makeSpinner(container);
+		});
+	}
+
+	function updateAndRefresh() {
+		const imageGenerator = new App.Art.GenAI.StableDiffusionClient(V.aiApiUrl);
+
+		container.classList.add("refreshing");
+
+		imageGenerator.updateSlave(slave).then(() => {
+			refresh();
+
+			container.classList.remove("refreshing");
+		}).catch(error => {
+			console.error(error);
+
+			container.classList.remove("refreshing");
+		});
+	}
+
+	container.addEventListener("click", function() {
+		if (!container.classList.contains("refreshing")) {
+			updateAndRefresh();
+		}
+	});
+
+	if (slave.custom.aiImageId === null) {
+		updateAndRefresh();
+	}
+
+	renderAIArt(slave, imageSize).then((imgElement) => {
+		jQuery(container).empty().append(imgElement);
+		makeSpinner(container);
+	});
+	return container;
+};
+
+
+
 /**
  * @param {number} artSize
  * @returns {string}
diff --git a/src/art/genAI/agePromptPart.js b/src/art/genAI/agePromptPart.js
new file mode 100644
index 00000000000..376ef513ac9
--- /dev/null
+++ b/src/art/genAI/agePromptPart.js
@@ -0,0 +1,28 @@
+App.Art.GenAI.AgePromptPart = class AgePromptPart extends App.Art.GenAI.PromptPart {
+	/**
+	 * @returns {string}
+	 */
+	positive() {
+		let ageTags = ``;
+		if (this.slave.visualAge < 18) {
+			ageTags = `teen, young, `;
+		} else if (this.slave.visualAge < 20) {
+			ageTags = `teen, `;
+		}
+
+		return `${ageTags}${this.slave.visualAge} year old`;
+	}
+
+	/**
+	 * @returns {string}
+	 */
+	negative() {
+		if (this.slave.visualAge < 20) {
+			return `old, 30 year old, 40 year old`;
+		} else if (this.slave.visualAge < 30) { /* empty */ } else if (this.slave.visualAge < 40) {
+			return `young, teen`;
+		} else {
+			return `young, teen, 20 year old`;
+		}
+	}
+};
diff --git a/src/art/genAI/arousalPromptPart.js b/src/art/genAI/arousalPromptPart.js
new file mode 100644
index 00000000000..a5f9435b042
--- /dev/null
+++ b/src/art/genAI/arousalPromptPart.js
@@ -0,0 +1,22 @@
+App.Art.GenAI.ArousalPromptPart = class ArousalPromptPart extends App.Art.GenAI.PromptPart {
+	/**
+	 * @returns {string}
+	 */
+	positive() {
+		if (this.slave.energy > 95) {
+			return "(blush, sweat, heavy breathing:1.1)";
+		} else if (this.slave.energy > 80) {
+			return "blush, sweat, heavy breathing";
+		} else if (this.slave.energy > 60) {
+			return "blush";
+		}
+		return;
+	}
+
+	/**
+	 * @returns {string}
+	 */
+	negative() {
+		return undefined;
+	}
+};
diff --git a/src/art/genAI/beautyPromptPart.js b/src/art/genAI/beautyPromptPart.js
new file mode 100644
index 00000000000..c5b08e9ee63
--- /dev/null
+++ b/src/art/genAI/beautyPromptPart.js
@@ -0,0 +1,25 @@
+App.Art.GenAI.BeautyPromptPart = class BeautyPromptPart extends App.Art.GenAI.PromptPart {
+	/**
+	 * @returns {string}
+	 */
+	positive() {
+		if (this.slave.face < -95) {
+			return "ugly, low quality";
+		} else if (this.slave.face < -50) {
+			return "unattractive, low quality";
+		} else if (this.slave.face < 10) { /* empty */ } else if (this.slave.face < 50) {
+			return "best quality";
+		} else if (this.slave.face < 95) {
+			return "masterpiece, best quality";
+		} else {
+			return "(masterpiece, best quality:1.1)";
+		}
+	}
+
+	/**
+	 * @returns {string}
+	 */
+	negative() {
+		return "(worst quality, low quality, bad_pictures, negative_hand-neg:1.2)";
+	}
+};
diff --git a/src/art/genAI/breastsPromptPart.js b/src/art/genAI/breastsPromptPart.js
new file mode 100644
index 00000000000..269575e97d6
--- /dev/null
+++ b/src/art/genAI/breastsPromptPart.js
@@ -0,0 +1,37 @@
+App.Art.GenAI.BreastsPromptPart = class BreastsPromptPart extends App.Art.GenAI.PromptPart {
+	/**
+	 * @returns {string}
+	 */
+	positive() {
+		if (this.slave.boobs < 300) {
+			return `flat chest`;
+		} else if (this.slave.boobs < 400) {
+			return `small breasts, flat chest`;
+		} else if (this.slave.boobs < 500) {
+			return `small breasts`;
+		} else if (this.slave.boobs < 650) {
+			return `medium breasts`;
+		} else if (this.slave.boobs < 800) {
+			return `large breasts`;
+		} else if (this.slave.boobs < 1000) {
+			return `huge breasts`;
+		} else if (this.slave.boobs < 1400) {
+			return `huge breasts, large breasts`;
+		} else {
+			return `(huge breasts:1.1), large breasts`;
+		}
+	}
+
+	/**
+	 * @returns {string}
+	 */
+	negative() {
+		if (this.slave.boobs < 300) {
+			return `medium breasts, large breasts, huge breasts`;
+		} else if (this.slave.boobs < 650) {
+			return;
+		} else {
+			return `small breasts, flat chest`;
+		}
+	}
+};
diff --git a/src/art/genAI/buildPrompt.js b/src/art/genAI/buildPrompt.js
new file mode 100644
index 00000000000..77e5d7d3187
--- /dev/null
+++ b/src/art/genAI/buildPrompt.js
@@ -0,0 +1,33 @@
+/**
+ * @param {FC.SlaveState} slave
+ * @returns {App.Art.GenAI.Prompt}
+ */
+// eslint-disable-next-line no-unused-vars
+function buildPrompt(slave) {
+	let prompts = [
+		new App.Art.GenAI.StylePromptPart(slave),
+		new App.Art.GenAI.BeautyPromptPart(slave),
+		new App.Art.GenAI.SkinPromptPart(slave),
+		new App.Art.GenAI.RacePromptPart(slave),
+		new App.Art.GenAI.AgePromptPart(slave),
+		new App.Art.GenAI.PosturePromptPart(slave),
+		new App.Art.GenAI.ArousalPromptPart(slave),
+		new App.Art.GenAI.WeightPromptPart(slave),
+		new App.Art.GenAI.HeightPromptPart(slave),
+		new App.Art.GenAI.MusclesPromptPart(slave),
+		new App.Art.GenAI.ClothesPromptPart(slave),
+		new App.Art.GenAI.CollarPromptPart(slave),
+		new App.Art.GenAI.BreastsPromptPart(slave),
+		new App.Art.GenAI.WaistPromptPart(slave),
+		new App.Art.GenAI.HipsPromptPart(slave),
+		new App.Art.GenAI.HairPromptPart(slave),
+		new App.Art.GenAI.EyePromptPart(slave),
+		new App.Art.GenAI.EyebrowPromptPart(slave),
+		new App.Art.GenAI.ExpressionPromptPart(slave),
+		new App.Art.GenAI.TattoosPromptPart(slave),
+		new App.Art.GenAI.PiercingsPromptPart(slave),
+		new App.Art.GenAI.HealthPromptPart(slave),
+		new App.Art.GenAI.PubicHairPromptPart(slave),
+	];
+	return new App.Art.GenAI.Prompt(prompts);
+}
diff --git a/src/art/genAI/clothesPromptPart.js b/src/art/genAI/clothesPromptPart.js
new file mode 100644
index 00000000000..9ade4e51183
--- /dev/null
+++ b/src/art/genAI/clothesPromptPart.js
@@ -0,0 +1,461 @@
+const clothesPrompts = {
+	"no clothing": {
+		"positive": "(completely nude:1.1), pussy, nipples",
+		"negative": "clothes, jeans, underwear, pants, shorts, skirt, panties",
+	},
+	"a Fuckdoll suit": {  // Doesn't work well
+		"positive": "latex bodysuit, long sleeves",
+		"negative": "bare shoulders, exposed skin, exposed legs, exposed arms, short sleeves, nude, pussy, nipples",
+	},
+	"conservative clothing": {
+		"positive": "slacks, pants, silk blouse",
+		"negative": "jeans, nude, pussy, nipples",
+	},
+	"chains": {
+		"positive": "(metal chains:1.1), nude, pussy, nipples, navel",
+		"negative": "clothes, jeans, underwear, pants, shorts, skirt, panties",
+	},
+	"Western clothing": {
+		"positive": "flannel shirt, chaps, cowboy hat",
+		"negative": "nude, pussy, nipples",
+	},
+	"body oil": {  // Doesn't work well
+		"positive": "body oil, nude, pussy, nipples, navel",
+		"negative": "clothes, jeans, underwear, pants, shorts, skirt, panties",
+	},
+	"a toga": {  // Doesn't work well
+		"positive": "white toga",
+		"negative": "jeans, nude, pussy, nipples",
+	},
+	"a huipil": {  // Doesn't work well
+		"positive": "huipil, chinese clothing",
+		"negative": "jeans, nude, pussy, nipples",
+	},
+	"a slutty qipao": {
+		"positive": "qipao, chinese clothing, thighs",
+		"negative": "jeans, nude, pussy, nipples",
+	},
+	"spats and a tank top": {  // Spats don't work well
+		"positive": "bike shorts, tank top",
+		"negative": "jeans, nude, pussy, nipples",
+	},
+	"uncomfortable straps": {
+		"positive": "(leather straps, bondage:1.1), nude, pussy, nipples, navel",
+		"negative": "clothes, jeans, underwear, pants, shorts, skirt, panties",
+	},
+	"shibari ropes": {
+		"positive": "shibari rope, bondage, nude, pussy, nipples, navel",
+		"negative": "clothes, jeans, underwear, pants, shorts, skirt, panties",
+	},
+	"restrictive latex": {  // Doesn't work well
+		"positive": "latex bodysuit, long sleeves",
+		"negative": "bare shoulders, exposed skin, exposed legs, exposed arms, short sleeves, nude, pussy, nipples",
+	},
+	"a latex catsuit": {  // Doesn't work well
+		"positive": "latex bodysuit, long sleeves",
+		"negative": "bare shoulders, exposed skin, exposed legs, exposed arms, short sleeves, nude, pussy, nipples",
+	},
+	"attractive lingerie": {  // Cupless part doesn't work well
+		"positive": "lingerie, cupless bra, nipples, thong",
+		"negative": "clothes, jeans, pants",
+	},
+	"attractive lingerie for a pregnant woman": {  // Cupless part doesn't work well
+		"positive": "lingerie, cupless bra, nipples, thong",
+		"negative": "clothes, jeans, pants",
+	},
+	"kitty lingerie": {  // Broken for photorealistic models, probably works for anime models
+		"positive": "cat lingerie, cat cutout, cat ear panties, bra, panties",
+		"negative": "cat ears, jeans, nude, pussy, nipples",
+	},
+	"a maternity dress": {
+		"positive": "maternity dress, loose dress",
+		"negative": "jeans, nude, pussy, nipples",
+	},
+	"stretch pants and a crop-top": {
+		"positive": "crop top, midriff, navel, leggings",
+		"negative": "jeans, nude, pussy, nipples",
+	},
+	"a succubus outfit": {
+		"positive": "red leather corset, red leather miniskirt, thighs",
+		"negative": "jeans, nude, pussy, nipples",
+	},
+	"a fallen nuns habit": {
+		"positive": "(latex nun habit:1.1), thighs",
+		"negative": "jeans, nude, pussy, nipples",
+	},
+	"a penitent nuns habit": {
+		"positive": "(latex nun habit:1.1), thighs, rope, bondage",
+		"negative": "jeans, nude, pussy, nipples",
+	},
+	"a chattel habit": {
+		"positive": "(white gold latex nun:1.1), thighs",
+		"negative": "jeans, nude, pussy, nipples",
+	},
+	"a string bikini": {  // Cupless part doesn't work well
+		"positive": "string microbikini, cupless bikini, nipples",
+		"negative": "jeans, nude, pussy",
+	},
+	"a scalemail bikini": {  // Doesn't work well
+		"positive": "chainmail bikini, navel",
+		"negative": "jeans, nude, pussy, nipples",
+	},
+	"striped panties": {
+		"positive": "blue striped panties, underwear only, nipples",
+		"negative": "jeans, nude, pussy",
+	},
+	"a cheerleader outfit": {
+		"positive": "(cheerleader outfit:1.1), skirt, thighs, crop top, navel, midriff",
+		"negative": "jeans, nude, pussy, nipples",
+	},
+	"clubslut netting": {  // Doesn't work well
+		"positive": "nude, fishnets, nipples, pussy",
+		"negative": "cloth, jeans, pants, corset",
+	},
+	"cutoffs and a t-shirt": {
+		"positive": "white t-shirt, jean shorts",
+		"negative": "nude, pussy, nipples",
+	},
+	"slutty business attire": {
+		"positive": "suit jacket, cleavage, black skirt, thighs",
+		"negative": "jeans, nude, pussy, nipples"
+	},
+	"nice business attire": {
+		"positive": "suit jacket, collared shirt, black skirt",
+		"negative": "jeans, nude, pussy, nipples",
+	},
+	"a ball gown": {
+		"positive": "ballgown, long dress, luxurious dress, thighhighs",
+		"negative": "jeans, nude, pussy, nipples",
+	},
+	"a slave gown": {
+		"positive": "ballgown, long dress, luxurious dress, thighhighs, cleavage, see-through, translucent clothing, straps, bdsm",
+		"negative": "jeans, nude",
+	},
+	"a halter top dress": {
+		"positive": "(halterneck:1.1), long dress, luxurious dress, bare back,",
+		"negative": "jeans, nude, pussy, nipples",
+	},
+	"an evening dress": {
+		"positive": "evening gown, long dress, luxurious dress, thighs",
+		"negative": "jeans, nude, pussy, nipples",
+	},
+	"a mini dress": {
+		"positive": "short dress, tight dress, strapless, cleavage, thighs",
+		"negative": "jeans, nude, pussy, nipples",
+	},
+	"a comfortable bodysuit": {
+		"positive": "latex bodysuit, long sleeves",
+		"negative": "bare shoulders, exposed skin, exposed legs, exposed arms, short sleeves, nude, pussy, nipples",
+	},
+	"a leotard": {
+		"positive": "leotard, thighs",
+		"negative": "jeans, nude, pussy, nipples",
+	},
+	"a monokini": {  // Doesn't work well
+		"positive": "monokini",
+		"negative": "jeans, nude, pussy, nipples",
+	},
+	"an apron": {
+		"positive": "apron, thighs, nude",
+		"negative": "clothes, shirt, pants, shorts, pussy, nipples",
+	},
+	"overalls": {
+		"positive": "overalls, naked overalls",
+		"negative": "shirt, pants, shorts, pussy, nipples, topless",
+	},
+	"a cybersuit": {  // Doesn't work well
+		"positive": "cybersuit, latex bodysuit, long sleeves, cybernetic, science fiction",
+		"negative": "bare shoulders, exposed skin, exposed legs, exposed arms, short sleeves, nude, pussy, nipples",
+	},
+	"a tight Imperial bodysuit": {  // Doesn't work well
+		"positive": "imperial bodysuit, latex bodysuit, long sleeves, cybernetic, science fiction",
+		"negative": "bare shoulders, exposed skin, exposed legs, exposed arms, short sleeves, nude, pussy, nipples",
+	},
+	"battlearmor": {  // Doesn't work well
+		"positive": "(armor, science fiction, soldier:1.1)",
+		"negative": "jeans, nude, pussy, nipples",
+	},
+	"Imperial Plate": {  // Doesn't work well
+		"positive": "(armor, science fiction, soldier:1.1)",
+		"negative": "jeans, nude, pussy, nipples",
+	},
+	"a bunny outfit": {
+		"positive": "playboy bunny, backless leotard, pantyhose",
+		"negative": "jeans, nude, pussy, nipples, rabbit ears",
+	},
+	"a slutty maid outfit": {
+		"positive": "maid, minidress, apron, white shirt, cleavage, thighs",
+		"negative": "jeans, nude, pussy, nipples",
+	},
+	"a nice maid outfit": {
+		"positive": "maid, dress, apron, white shirt",
+		"negative": "jeans, nude, pussy, nipples",
+	},
+	"a slutty nurse outfit": {
+		"positive": "nurse, white jacket, cleavage, white skirt, thighs",
+		"negative": "jeans, shirt, pussy, nipples",
+	},
+	"a nice nurse outfit": {
+		"positive": "nurse, white medical scrubs, pants",
+		"negative": "jeans, nude, pussy, nipples",
+	},
+	"a dirndl": {
+		"positive": "(dirndl:1.1)",
+		"negative": "jeans, nude, pussy, nipples",
+	},
+	"a long qipao": {
+		"positive": "(qipao:1.1), long dress, chinese clothes",
+		"negative": "jeans, nude, pussy, nipples",
+	},
+	"lederhosen": {
+		"positive": "(lederhosen:1.1)",
+		"negative": "jeans, nude, pussy, nipples",
+	},
+	"a biyelgee costume": {  // Doesn't work well
+		"positive": "mongolian traditional clothes",
+		"negative": "jeans, nude, pussy, nipples",
+	},
+	"a hanbok": {
+		"positive": "(hanbok:1.1)",
+		"negative": "jeans, nude, pussy, nipples",
+	},
+	"burkini": {
+		"positive": "burqa, muslim clothes, burkini, pants",
+		"negative": "jeans, nude, pussy, nipples",
+	},
+	"a hijab and blouse": {
+		"positive": "(hijab:1.1), blouse, short sleeves, long skirt",
+		"negative": "jeans, nude, pussy, nipples",
+	},
+	"a hijab and abaya": {
+		"positive": "hijab, abaya",
+		"negative": "jeans, nude, pussy, nipples",
+	},
+	"a niqab and abaya": {  // Doesn't work well
+		"positive": "niqab, covered face, abaya",
+		"negative": "jeans, nude, pussy, nipples",
+	},
+	"a burqa": {  // Doesn't work well
+		"positive": "burqa, muslim clothes",
+		"negative": "jeans, nude, pussy, nipples",
+	},
+	"a police uniform": {
+		"positive": "police uniform, policewoman, police hat, jacket, pants, belt",
+		"negative": "jeans, nude, pussy, nipples",
+	},
+	"a gothic lolita dress": {
+		"positive": "gothic lolita, dress, thighhighs",
+		"negative": "jeans, nude, pussy, nipples",
+	},
+	"a one-piece swimsuit": {
+		"positive": "one-piece swimsuit, thighs",
+		"negative": "jeans, nude, pussy, nipples",
+	},
+	"a nice pony outfit": {  // Tbh, not really sure what this is
+		"positive": "latex bodysuit, long sleeves",
+		"negative": "bare shoulders, exposed skin, exposed legs, exposed arms, short sleeves, nude, pussy, nipples",
+	},
+	"a slutty pony outfit": {  // Same
+		"positive": "latex bodysuit, long sleeves, cleavage, thighs",
+		"negative": "nude, pussy, nipples",
+	},
+	"a button-up shirt and panties": {  // Often not bottomless
+		"positive": "collared shirt, oversized clothes, panties, (bottomless:1.1), thighs",
+		"negative": "jeans, pants, skirt, shorts, nude, pussy, nipples",
+	},
+	"a button-up shirt": {  // Often not bottomless
+		"positive": "collared shirt, oversized clothes, pussy, nude, (bottomless:1.1), thighs",
+		"negative": "jeans, pants, skirt, shorts, nipples",
+	},
+	"a sweater": {  // Often not bottomless
+		"positive": "sweater, oversized clothes, pussy, nude, (bottomless:1.1), thighs",
+		"negative": "jeans, pants, skirt, shorts, nipples",
+	},
+	"a t-shirt": {  // Often not bottomless
+		"positive": "t-shirt, (pussy, nude:1.1), (bottomless:1.1), thighs",
+		"negative": "jeans, pants, skirt, shorts, nipples",
+	},
+	"a tank-top": {  // Often not bottomless
+		"positive": "tank top, bare shoulders, (pussy, nude:1.1), (bottomless:1.1), thighs",
+		"negative": "jeans, pants, skirt, shorts, nipples",
+	},
+	"a tube top": {  // Often not bottomless
+		"positive": "tube top, bare shoulders, (pussy, nude:1.1), (bottomless:1.1), thighs",
+		"negative": "jeans, pants, skirt, shorts, nude, nipples",
+	},
+	"an oversized t-shirt": {  // Often not bottomless
+		"positive": "t-shirt, oversized clothes, (pussy, nude:1.1), (bottomless:1.1), thighs",
+		"negative": "jeans, pants, skirt, shorts, nipples",
+	},
+	"a bra": {  // Often not bottomless
+		"positive": "bra, (pussy, nude:1.1), (bottomless:1.1), thighs",
+		"negative": "jeans, pants, skirt, shorts, nipples",
+	},
+	"a sports bra": {  // Often not bottomless
+		"positive": "sports bra, (pussy, nude:1.1), (bottomless:1.1), thighs",
+		"negative": "jeans, pants, skirt, shorts, nipples",
+	},
+	"a striped bra": {  // Often not bottomless
+		"positive": "striped bra, (pussy, nude:1.1), (bottomless:1.1), thighs",
+		"negative": "jeans, pants, skirt, shorts, nipples",
+	},
+	"pasties": {  // Doesn't work well
+		"positive": "pasties, pussy, nude, (bottomless:1.1), thighs",
+		"negative": "jeans, pants, skirt, shorts, nipples",
+	},
+	"a tube top and thong": {
+		"positive": "tube top, bare shoulders, (nude:1.1), (bottomless:1.1), g-string, thighs",
+		"negative": "jeans, pants, skirt, shorts, nipples, pussy",
+	},
+	"a sweater and panties": {  // Often not bottomless
+		"positive": "sweater, oversized clothes, panties, (nude:1.1), (bottomless:1.1), thighs",
+		"negative": "jeans, pants, skirt, shorts, nipples, pussy",
+	},
+	"a tank-top and panties": {  // Often not bottomless
+		"positive": "tank top, bare shoulders, panties, (nude:1.1), (bottomless:1.1), thighs",
+		"negative": "jeans, pants, skirt, shorts, nipples, pussy",
+	},
+	"a t-shirt and thong": {  // Often not bottomless
+		"positive": "t-shirt, (nude:1.1), (bottomless:1.1), g-string, thighs",
+		"negative": "jeans, pants, skirt, shorts, nipples, pussy",
+	},
+	"an oversized t-shirt and boyshorts": {  // Doesn't work well
+		"positive": "t-shirt, oversized clothes, boyshort panties, (nude:1.1), (bottomless:1.1), thighs",
+		"negative": "jeans, pants, skirt, nipples, pussy",
+	},
+	"sport shorts and a t-shirt": {
+		"positive": "t-shirt, sport shorts",
+		"negative": "jeans, pants, skirt, nipples, pussy",
+	},
+	"sport shorts and a sports bra": {
+		"positive": "sports bra, sport shorts",
+		"negative": "jeans, pants, skirt, nipples, pussy",
+	},
+	"a t-shirt and panties": {  // Often not bottomless
+		"positive": "t-shirt, (nude:1.1), (bottomless:1.1), panties, thighs",
+		"negative": "jeans, pants, skirt, shorts, nipples, pussy",
+	},
+	"striped underwear": {  // Often not bottomless
+		"positive": "striped panties, striped bra",
+		"negative": "jeans, pants, skirt, shorts, nipples, pussy",
+	},
+	"a thong": {
+		"positive": "thong, topless, nipples",
+		"negative": "jeans, pants, skirt, shorts, pussy",
+	},
+	"a skimpy loincloth": {  // Doesn't work well
+		"positive": "loincloth, topless, nipples",
+		"negative": "jeans, pants, skirt, shorts, pussy",
+	},
+	"boyshorts": {
+		"positive": "boyshort panties, topless, nipples",
+		"negative": "jeans, pants, skirt, pussy",
+	},
+	"panties": {
+		"positive": "panties, topless, nipples",
+		"negative": "jeans, pants, skirt, pussy",
+	},
+	"panties and pasties": {  // Doesn't work well
+		"positive": "panties, pasties, topless",
+		"negative": "jeans, pants, skirt, pussy, nipples",
+	},
+	"cutoffs": {
+		"positive": "jean shorts, topless, nipples",
+		"negative": "pussy",
+	},
+	"sport shorts": {
+		"positive": "sport shorts, topless, nipples",
+		"negative": "jeans, pants, skirt, pussy",
+	},
+	"a sweater and cutoffs": {
+		"positive": "sweater, jean shorts",
+		"negative": "pussy, nipples",
+	},
+	"leather pants and a tube top": {
+		"positive": "leather pants, tube top, bare shoulders",
+		"negative": "jeans, pants, skirt, shorts, pussy, nipples",
+	},
+	"a t-shirt and jeans": {
+		"positive": "t-shirt, jeans",
+		"negative": "pussy, nipples",
+	},
+	"leather pants and pasties": {  // Doesn't work well
+		"positive": "leather pants, pasties, topless",
+		"negative": "jeans, pants, skirt, shorts, pussy, nipples",
+	},
+	"leather pants": {
+		"positive": "leather pants, topless, nipples",
+		"negative": "jeans, pants, skirt, shorts, pussy",
+	},
+	"jeans": {
+		"positive": "jeans, topless, nipples",
+		"negative": "pussy",
+	},
+	"a military uniform": {
+		"positive": "military uniform, shirt, necktie, skirt",
+		"negative": "jeans, shorts, pussy, nipples",
+	},
+	"battledress": {
+		"positive": "military fatigues, jumpsuit",
+		"negative": "jeans, shorts, pussy, nipples",
+	},
+	"a mounty outfit": {  // Doesn't work well
+		"positive": "mounty, red military jacket",
+		"negative": "jeans, shorts, pussy, nipples",
+	},
+	"harem gauze": {
+		"positive": "harem outfit, loose dress, see-through, transparent clothes, nipples, pussy",
+		"negative": "jeans, shorts",
+	},
+	"slutty jewelry": {
+		"positive": "nude, jewelry, gem, gold chains, armlet, nipples, pussy",
+		"negative": "clothes, jeans, underwear, pants, shorts, skirt, panties"
+	},
+	"a Santa dress": {
+		"positive": "santa costume, santa dress, thighs",
+		"negative": "jeans, nude, pussy, nipples"
+	},
+	"a bimbo outfit": {
+		"positive": "(pink:1.1) tube top, bra, cleavage, pink microskirt, thighs, panties, navel, midriff",
+		"negative": "jeans, nude, pussy, nipples",
+	},
+	"a slutty outfit": {
+		"positive": "(pink:1.1) crop top, pink lowleg microskirt, (pussy:1.1), hip bones, groin, tight clothes, midriff, navel, (thighs:1.1)",
+		"negative": "jeans, nude, nipples",
+	},
+	"a courtesan dress": {  // Corset was messing stuff up, so I removed it
+		"positive": "(luxurious flowing dress:1.1), bare shoulders, long sleeves, detached sleeves",
+		"negative": "jeans, nude, pussy, nipples",
+	},
+	"a schoolgirl outfit": {
+		"positive": "school uniform, white shirt, plaid skirt",
+		"negative": "jeans, nude, pussy, nipples",
+	}
+};
+
+App.Art.GenAI.ClothesPromptPart = class ClothesPromptPart extends App.Art.GenAI.PromptPart {
+	/**
+	 * @returns {string}
+	 */
+	getClothes() {
+		let clothes = this.slave.clothes;
+		if (!clothesPrompts.hasOwnProperty(clothes)) {
+			clothes = "no clothing";
+		}
+		return clothes;
+	}
+
+	/**
+	 * @returns {string}
+	 */
+	positive() {
+		return clothesPrompts[this.getClothes()].positive;
+	}
+
+	/**
+	 * @returns {string}
+	 */
+	negative() {
+		return clothesPrompts[this.getClothes()].negative;
+	}
+};
diff --git a/src/art/genAI/collarPromptPart.js b/src/art/genAI/collarPromptPart.js
new file mode 100644
index 00000000000..dd0fe004286
--- /dev/null
+++ b/src/art/genAI/collarPromptPart.js
@@ -0,0 +1,20 @@
+App.Art.GenAI.CollarPromptPart = class CollarPromptPart extends App.Art.GenAI.PromptPart {
+	/**
+	 * @returns {string}
+	 */
+	positive() {
+		if (this.slave.collar !== "none") {
+			return `${this.slave.collar} collar`;
+		}
+	}
+
+	/**
+	 * @returns {string}
+	 */
+	negative() {
+		if (this.slave.collar === "none") {
+			return "collar";
+		}
+		return;
+	}
+};
diff --git a/src/art/genAI/expressionPromptPart.js b/src/art/genAI/expressionPromptPart.js
new file mode 100644
index 00000000000..56abc5cc624
--- /dev/null
+++ b/src/art/genAI/expressionPromptPart.js
@@ -0,0 +1,76 @@
+App.Art.GenAI.ExpressionPromptPart = class ExpressionPromptPart extends App.Art.GenAI.PromptPart {
+	/**
+	 * @returns {string}
+	 */
+	positive() {
+		let devotionPart;
+		if (this.slave.devotion < -50) {
+			devotionPart = `angry expression, hateful`;
+		} else if (this.slave.devotion < -20) {
+			devotionPart = `angry`;
+		} else if (this.slave.devotion < 51) {
+			devotionPart = null;
+		} else if (this.slave.devotion < 95) {
+			devotionPart = `smile`;
+		} else {
+			devotionPart = `smile, grin, teeth, loving expression`;
+		}
+
+		let trustPart;
+		if (this.slave.trust < -90) {
+			trustPart = `(scared expression:1.2), looking down, crying, tears`;
+		}
+		if (this.slave.trust < -50) {
+			trustPart = `(scared expression:1.1), looking down, crying`;
+		} else if (this.slave.trust < -20) {
+			trustPart = `scared expression, looking down`;
+		} else if (this.slave.trust < 51) {
+			trustPart = `looking at viewer`;
+		} else if (this.slave.trust < 95) {
+			trustPart = `looking at viewer, confident`;
+		} else {
+			trustPart = `looking at viewer, confident, smirk`;
+		}
+
+		if (devotionPart && trustPart) {
+			return `(${devotionPart}, ${trustPart}:1.1)`;
+		} else if (devotionPart) {
+			return `(${devotionPart}:1.1)`;
+		} else if (trustPart) {
+			return `(${trustPart}:1.1)`;
+		}
+	}
+
+	/**
+	 * @returns {string}
+	 */
+	negative() {
+		let devotionPart;
+		if (this.slave.devotion < -50) {
+			devotionPart = `smile, loving expression`;
+		} else if (this.slave.devotion < -20) {
+			devotionPart = `smile`;
+		} else if (this.slave.devotion < 51) {
+			devotionPart = null;
+		} else {
+			devotionPart = `angry`;
+		}
+
+		let trustPart;
+		if (this.slave.trust < -50) {
+			trustPart = `looking at viewer, confident`;
+		} else if (this.slave.trust < -20) {
+			trustPart = null;
+		} else {
+			trustPart = `looking away`;
+		}
+
+		if (devotionPart && trustPart) {
+			return `${devotionPart}, ${trustPart}`;
+		} else if (devotionPart) {
+			return devotionPart;
+		} else if (trustPart) {
+			return trustPart;
+		}
+	}
+};
diff --git a/src/art/genAI/eyePromptPart.js b/src/art/genAI/eyePromptPart.js
new file mode 100644
index 00000000000..e99d2bb5438
--- /dev/null
+++ b/src/art/genAI/eyePromptPart.js
@@ -0,0 +1,19 @@
+App.Art.GenAI.EyePromptPart = class EyePromptPart extends App.Art.GenAI.PromptPart {
+	/**
+	 * @returns {string}
+	 */
+	positive() {
+		if (this.slave.eye.left.iris === this.slave.eye.right.iris) {
+			return `${this.slave.eye.left.iris} eyes`;
+		} else {
+			return `heterochromia, ${this.slave.eye.left.iris} left eye, ${this.slave.eye.right.iris} right eye`;
+		}
+	}
+
+	/**
+	 * @returns {string}
+	 */
+	negative() {
+		return undefined;
+	}
+};
diff --git a/src/art/genAI/eyebrowPromptPart.js b/src/art/genAI/eyebrowPromptPart.js
new file mode 100644
index 00000000000..d8ac6bbdce7
--- /dev/null
+++ b/src/art/genAI/eyebrowPromptPart.js
@@ -0,0 +1,21 @@
+App.Art.GenAI.EyebrowPromptPart = class EyebrowPromptPart extends App.Art.GenAI.PromptPart {
+	/**
+	 * @returns {string}
+	 */
+	positive() {
+		if (this.slave.eyebrowHStyle === "shaved" || this.slave.eyebrowHStyle === "bald" || this.slave.eyebrowHStyle === "hairless") {
+			return;
+		}
+		return `${this.slave.eyebrowFullness} ${this.slave.eyebrowHColor} eyebrows`;
+	}
+
+	/**
+	 * @returns {string}
+	 */
+	negative() {
+		if (this.slave.eyebrowHStyle === "shaved" || this.slave.eyebrowHStyle === "bald" || this.slave.eyebrowHStyle === "hairless") {
+			return "eyebrows";
+		}
+		return;
+	}
+};
diff --git a/src/art/genAI/hairPromptPart.js b/src/art/genAI/hairPromptPart.js
new file mode 100644
index 00000000000..b48071d00fb
--- /dev/null
+++ b/src/art/genAI/hairPromptPart.js
@@ -0,0 +1,37 @@
+App.Art.GenAI.HairPromptPart = class HairPromptPart extends App.Art.GenAI.PromptPart {
+	/**
+	 * @returns {string}
+	 */
+	positive() {
+		if (this.slave.bald || this.slave.hLength === 0) {
+			return `bald`;
+		}
+
+		const heightVhLength = this.slave.hLength / this.slave.height;
+		let hairLength = '';
+		if (heightVhLength > 0.9) {
+			hairLength = `(very long:1.2)`;
+		} else if (heightVhLength > 0.7) {
+			hairLength = `(very long:1.1)`;
+		} else if (heightVhLength >= 0.4) {
+			hairLength = `very long`;
+		} else if (heightVhLength >= 0.2) {
+			hairLength = `long`;
+		} else if (this.slave.hLength >= 15) {
+			hairLength = `medium`;
+		} else {
+			hairLength = `short`;
+		}
+		return `${this.slave.hStyle} hair, ${hairLength} hair, ${this.slave.hColor} hair`;
+	}
+
+	/**
+	 * @returns {string}
+	 */
+	negative() {
+		if (this.slave.bald || this.slave.hLength === 0) {
+			return `hair, long hair, short hair`;
+		}
+		return;
+	}
+};
diff --git a/src/art/genAI/healthPromptPart.js b/src/art/genAI/healthPromptPart.js
new file mode 100644
index 00000000000..e0437b1e8fc
--- /dev/null
+++ b/src/art/genAI/healthPromptPart.js
@@ -0,0 +1,28 @@
+App.Art.GenAI.HealthPromptPart = class HealthPromptPart extends App.Art.GenAI.PromptPart {
+	/**
+	 * @returns {string}
+	 */
+	positive() {
+		if (this.slave.health.condition < -90) {
+			return `(very sick, ill:1.1)`;
+		} else if (this.slave.health.condition < -50) {
+			return `sick, ill`;
+		} else if (this.slave.health.condition < -10) {
+			return `tired`;
+		} else if (this.slave.health.condition < 90) {
+			return null;
+		} else {
+			return `healthy`;
+		}
+	}
+
+	/**
+	 * @returns {string}
+	 */
+	negative() {
+		if (this.slave.health.condition > 50) {
+			return `sick, ill`;
+		}
+		return;
+	}
+};
diff --git a/src/art/genAI/heightPromptPart.js b/src/art/genAI/heightPromptPart.js
new file mode 100644
index 00000000000..180bd73314f
--- /dev/null
+++ b/src/art/genAI/heightPromptPart.js
@@ -0,0 +1,23 @@
+App.Art.GenAI.HeightPromptPart = class HeightPromptPart extends App.Art.GenAI.PromptPart {
+	/**
+	 * @returns {string}
+	 */
+	positive() {
+		if (this.slave.height < 150) {
+			return `short`;
+		} else if (this.slave.height > 180) {
+			return `tall`;
+		}
+	}
+
+	/**
+	 * @returns {string}
+	 */
+	negative() {
+		if (this.slave.height < 150) {
+			return `tall`;
+		} else if (this.slave.height > 180) {
+			return `short`;
+		}
+	}
+};
diff --git a/src/art/genAI/hipsPromptPart.js b/src/art/genAI/hipsPromptPart.js
new file mode 100644
index 00000000000..ecb0182031b
--- /dev/null
+++ b/src/art/genAI/hipsPromptPart.js
@@ -0,0 +1,35 @@
+App.Art.GenAI.HipsPromptPart = class HipsPromptPart extends App.Art.GenAI.PromptPart {
+	/**
+	 * @returns {string}
+	 */
+	positive() {
+		if (this.slave.hips <= -2) {
+			return `(narrow hips:1.1)`;
+		} else if (this.slave.hips === -1) {
+			return `narrow hips`;
+		} else if (this.slave.hips === 0) {
+			return null;
+		} else if (this.slave.hips === 1) {
+			return `hips`;
+		} else if (this.slave.hips === 2) {
+			return `wide hips`;
+		} else {
+			return `(wide hips:1.1)`;
+		}
+	}
+
+	/**
+	 * @returns {string}
+	 */
+	negative() {
+		if (this.slave.hips <= -2) {
+			return `hips, wide hips`;
+		} else if (this.slave.hips === -1) {
+			return `hips`;
+		} else if (this.slave.hips === 0) {
+			return null;
+		} else {
+			return `narrow hips`;
+		}
+	}
+};
diff --git a/src/art/genAI/imageDB.js b/src/art/genAI/imageDB.js
new file mode 100644
index 00000000000..992c4872043
--- /dev/null
+++ b/src/art/genAI/imageDB.js
@@ -0,0 +1,83 @@
+App.Art.GenAI.imageDB = (function() {
+	let db;
+
+	/**
+	 * 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');
+			};
+
+			request.onsuccess = function() {
+				console.log('Database opened successfully');
+				db = request.result;
+				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 addImage(imageData) {
+		return new Promise((resolve, reject) => {
+			let transaction = db.transaction(['images'], 'readwrite');
+			let objectStore = transaction.objectStore('images');
+
+			let request = objectStore.add(imageData);
+
+			request.onsuccess = function() {
+				resolve(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) {
+		return new Promise((resolve, reject) => {
+			let transaction = db.transaction(['images'], 'readonly');
+			let objectStore = transaction.objectStore('images');
+
+			let request = objectStore.get(id);
+
+			request.onsuccess = function() {
+				resolve(request.result);
+			};
+		});
+	}
+
+	return {
+		createDB,
+		addImage,
+		getImage,
+	};
+})();
+
+App.Art.GenAI.imageDB.createDB();
diff --git a/src/art/genAI/musclesPromptPart.js b/src/art/genAI/musclesPromptPart.js
new file mode 100644
index 00000000000..50ead82aa51
--- /dev/null
+++ b/src/art/genAI/musclesPromptPart.js
@@ -0,0 +1,33 @@
+App.Art.GenAI.MusclesPromptPart = class MusclesPromptPart extends App.Art.GenAI.PromptPart {
+	/**
+	 * @returns {string}
+	 */
+	positive() {
+		if (this.slave.muscles > 95) {
+			return `(muscular:1.3)`;
+		} else if (this.slave.muscles > 30) {
+			return `(muscular:1.2)`;
+		} else if (this.slave.muscles > 10) {
+			return `muscular`;
+		} else if (this.slave.muscles > -10) {
+			return null;
+		} else if (this.slave.muscles > -95) {
+			return `soft`;
+		} else {
+			return `frail, weak`;
+		}
+	}
+
+	/**
+	 * @returns {string}
+	 */
+	negative() {
+		if (this.slave.muscles > 30) {
+			return `soft`;
+		} else if (this.slave.muscles > -10) {
+			return null;
+		} else if (this.slave.muscles > -30) {
+			return `muscular`;
+		}
+	}
+};
diff --git a/src/art/genAI/piercingsPromptPart.js b/src/art/genAI/piercingsPromptPart.js
new file mode 100644
index 00000000000..e760be542a0
--- /dev/null
+++ b/src/art/genAI/piercingsPromptPart.js
@@ -0,0 +1,57 @@
+App.Art.GenAI.PiercingsPromptPart = class PiercingsPromptPart extends App.Art.GenAI.PromptPart {
+	/**
+	 * @returns {string}
+	 */
+	positive() {
+		let piercingParts = [];
+		if (this.slave.piercing.areola.weight > 0) {
+			let desc = this.slave.piercing.areola.desc ? (this.slave.piercing.areola.desc + ` `) : ``;
+			piercingParts.push(`${desc}areola piercing`);
+		}
+		if (this.slave.piercing.ear.weight > 0) {
+			let desc = this.slave.piercing.ear.desc ? (this.slave.piercing.ear.desc + ` `) : ``;
+			piercingParts.push(`${desc}ear piercing`);
+		}
+		if (this.slave.piercing.eyebrow.weight > 0) {
+			let desc = this.slave.piercing.eyebrow.desc ? (this.slave.piercing.eyebrow.desc + ` `) : ``;
+			piercingParts.push(`${desc}eyebrow piercing`);
+		}
+		if (this.slave.piercing.lips.weight > 0) {
+			let desc = this.slave.piercing.lips.desc ? (this.slave.piercing.lips.desc + ` `) : ``;
+			piercingParts.push(`${desc}lip piercing`);
+		}
+		if (this.slave.piercing.navel.weight > 0) {
+			let desc = this.slave.piercing.navel.desc ? (this.slave.piercing.navel.desc + ` `) : ``;
+			piercingParts.push(`${desc}navel piercing`);
+		}
+		if (this.slave.piercing.nipple.weight > 0) {
+			let desc = this.slave.piercing.nipple.desc ? (this.slave.piercing.nipple.desc + ` `) : ``;
+			piercingParts.push(`${desc}nipple piercing`);
+		}
+		if (this.slave.piercing.nose.weight > 0) {
+			let desc = this.slave.piercing.nose.desc ? (this.slave.piercing.nose.desc + ` `) : ``;
+			piercingParts.push(`${desc}nose piercing`);
+		}
+		if (this.slave.piercing.tongue.weight > 0) {
+			let desc = this.slave.piercing.tongue.desc ? (this.slave.piercing.tongue.desc + ` `) : ``;
+			piercingParts.push(`${desc}tongue piercing`);
+		}
+		if (this.slave.piercing.vagina.weight > 0) {
+			let desc = this.slave.piercing.vagina.desc ? (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`;
+		}
+	}
+};
diff --git a/src/art/genAI/posturePromptPart.js b/src/art/genAI/posturePromptPart.js
new file mode 100644
index 00000000000..9653828fe56
--- /dev/null
+++ b/src/art/genAI/posturePromptPart.js
@@ -0,0 +1,39 @@
+App.Art.GenAI.PosturePromptPart = class PosturePromptPart extends App.Art.GenAI.PromptPart {
+	/**
+	 * @returns {string}
+	 */
+	positive() {
+		let devotionPart;
+		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`;
+		}
+
+		let trustPart;
+		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;
+	}
+};
diff --git a/src/art/genAI/pubicHairPromptPart.js b/src/art/genAI/pubicHairPromptPart.js
new file mode 100644
index 00000000000..c82b735f78e
--- /dev/null
+++ b/src/art/genAI/pubicHairPromptPart.js
@@ -0,0 +1,21 @@
+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") {
+			return;
+		}
+		return `${this.slave.pubicHColor} ${this.slave.pubicHStyle} pubic hair`;
+	}
+
+	/**
+	 * @returns {string}
+	 */
+	negative() {
+		if (this.slave.pubicHStyle === "waxed" || this.slave.pubicHStyle === "bald" || this.slave.pubicHStyle === "hairless") {
+			return "pubic hair";
+		}
+		return;
+	}
+};
diff --git a/src/art/genAI/racePromptPart.js b/src/art/genAI/racePromptPart.js
new file mode 100644
index 00000000000..dda8562160f
--- /dev/null
+++ b/src/art/genAI/racePromptPart.js
@@ -0,0 +1,18 @@
+App.Art.GenAI.RacePromptPart = class RacePromptPart extends App.Art.GenAI.PromptPart {
+	/**
+	 * @returns {string}
+	 */
+	positive() {
+		return this.slave.race;
+	}
+
+	/**
+	 * @returns {string}
+	 */
+	negative() {
+		if (this.slave.race !== "asian") {
+			return "asian";
+		}
+		return;
+	}
+};
diff --git a/src/art/genAI/skinPromptPart.js b/src/art/genAI/skinPromptPart.js
new file mode 100644
index 00000000000..8bedb7ed739
--- /dev/null
+++ b/src/art/genAI/skinPromptPart.js
@@ -0,0 +1,79 @@
+App.Art.GenAI.SkinPromptPart = class SkinPromptPart extends App.Art.GenAI.PromptPart {
+	/**
+	 * @returns {string}
+	 */
+	positive() {
+		switch (this.slave.skin) {
+			case "pure white":
+			case "ivory":
+			case "white":
+			case "extremely pale":
+			case "very pale":
+			case "pale":
+				return "pale skin";
+			case "extremely fair":
+			case "very fair":
+			case "fair":
+			case "light":
+			case "light olive":
+				break;
+			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 "dark skin";
+			case "dark brown":
+			case "black":
+			case "ebony":
+			case "pure black":
+				return "black 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;
+			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";
+		}
+	}
+};
+
diff --git a/src/art/genAI/stableDiffusion.js b/src/art/genAI/stableDiffusion.js
new file mode 100644
index 00000000000..4d4bfcde4a1
--- /dev/null
+++ b/src/art/genAI/stableDiffusion.js
@@ -0,0 +1,211 @@
+/* eslint-disable camelcase */
+App.Art.GenAI.StableDiffusionSettings = class {
+	/**
+	 * @typedef {Object} ConstructorOptions
+	 * @param {boolean} [enable_hr=true]
+	 * @param {number} [denoising_strength=0.6]
+	 * @param {number} [firstphase_width=400]
+	 * @param {number} [firstphase_height=768]
+	 * @param {number} [hr_scale=1.7]
+	 * @param {string} [hr_upscaler="Latent"]
+	 * @param {number} [hr_second_pass_steps=12]
+	 * @param {number} [hr_resize_x=0]
+	 * @param {number} [hr_resize_y=0]
+	 * @param {string} [hr_sampler_name="DPM++ 2M SDE Karras"]
+	 * @param {string} [hr_prompt=null]
+	 * @param {string} [hr_negative_prompt=null]
+	 * @param {string} [prompt=""]
+	 * @param {number} [seed=1337]
+	 * @param {string} [sampler_name="DPM++ 2M SDE Karras"]
+	 * @param {number} [batch_size=1]
+	 * @param {number} [n_iter=1]
+	 * @param {number} [steps=20]
+	 * @param {number} [cfg_scale=5.5]
+	 * @param {number} [width=400]
+	 * @param {number} [height=768]
+	 * @param {string} [negative_prompt=""]
+	 * @param {number} [eta=1.0]
+	 * @param {number} [s_min_uncond=0.0]
+	 * @param {number} [s_churn=0.0]
+	 * @param {number} [s_tmax=0.0]
+	 * @param {number} [s_tmin=0.0]
+	 * @param {number} [s_noise=0]
+	 * @param {string[]} [override_settings=["Discard penultimate sigma: True"]]
+	 * @param {boolean} [override_settings_restore_afterwards=true]
+	 */
+
+	/**
+	 * @param {ConstructorOptions} options The options for the constructor.
+	 */
+	constructor({
+		enable_hr = true,
+		denoising_strength = 0.6,
+		firstphase_width = 400,
+		firstphase_height = 768,
+		hr_scale = 1.7,
+		hr_upscaler = "Latent",
+		hr_second_pass_steps = 12,
+		hr_resize_x = 0,
+		hr_resize_y = 0,
+		hr_sampler_name = "DPM++ 2M SDE Karras",
+		hr_prompt = null,
+		hr_negative_prompt = null,
+		prompt = "",
+		seed = 1337,
+		sampler_name = "DPM++ 2M SDE Karras",
+		batch_size = 1,
+		n_iter = 1,
+		steps = 20,
+		cfg_scale = 5.5,
+		width = 400,
+		height = 768,
+		negative_prompt = "",
+		eta = 1.0,
+		s_min_uncond = 0.0,
+		s_churn = 0.0,
+		s_tmax = 0.0,
+		s_tmin = 0.0,
+		s_noise = 0,
+		override_settings = {
+			"always_discard_next_to_last_sigma": true,
+		},
+		override_settings_restore_afterwards = true,
+	} = {}) {
+		this.enable_hr = enable_hr;
+		this.denoising_strength = denoising_strength;
+		this.firstphase_width = firstphase_width;
+		this.firstphase_height = firstphase_height;
+		this.hr_scale = hr_scale;
+		this.hr_upscaler = hr_upscaler;
+		this.hr_second_pass_steps = hr_second_pass_steps;
+		this.hr_resize_x = hr_resize_x;
+		this.hr_resize_y = hr_resize_y;
+		this.hr_sampler_name = hr_sampler_name;
+		this.hr_prompt = hr_prompt;
+		this.hr_negative_prompt = hr_negative_prompt;
+		this.prompt = prompt;
+		this.seed = seed;
+		this.sampler_name = sampler_name;
+		this.batch_size = batch_size;
+		this.n_iter = n_iter;
+		this.steps = steps;
+		this.cfg_scale = cfg_scale;
+		this.width = width;
+		this.height = height;
+		this.negative_prompt = negative_prompt;
+		this.eta = eta;
+		this.s_min_uncond = s_min_uncond;
+		this.s_churn = s_churn;
+		this.s_tmax = s_tmax;
+		this.s_tmin = s_tmin;
+		this.s_noise = s_noise;
+		this.override_settings = override_settings;
+		this.override_settings_restore_afterwards = override_settings_restore_afterwards;
+	}
+};
+
+
+/**
+ * @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.StableDiffusionClient = class {
+	/**
+	 * @param {string} apiUrl
+	 */
+	constructor(apiUrl) {
+		this.apiUrl = apiUrl;
+	}
+
+	/**
+	 * @param {App.Art.GenAI.StableDiffusionSettings} settings
+	 * @returns {Promise<string>} - Base 64 encoded image (could be a jpeg or png)
+	 */
+	async fetchImage(settings) {
+		const options = {
+			method: "POST",
+			headers: {
+				"Content-Type": "application/json",
+			},
+			body: JSON.stringify(settings),
+		};
+
+		const response = await fetchWithTimeout(`${this.apiUrl}/sdapi/v1/txt2img`, 30000, options);
+		if (!response.ok) {
+			console.error("Error fetching Stable Diffusion image", response);
+			throw new Error(`Error fetching Stable Diffusion image - status: ${response.status}`);
+		}
+
+		let parsedRes = await response.json();
+		return parsedRes.images[0];
+	}
+
+	/**
+	 * @param {FC.SlaveState} slave
+	 * @returns {App.Art.GenAI.StableDiffusionSettings}
+	 */
+	buildStableDiffusionSettings(slave) {
+		const prompt = buildPrompt(slave);
+		const settings = new App.Art.GenAI.StableDiffusionSettings({
+			prompt: prompt.positive(),
+			hr_prompt: prompt.positive(),
+			negative_prompt: prompt.negative(),
+			hr_negative_prompt: prompt.negative(),
+			seed: slave.natural.artSeed,
+		});
+
+		return settings;
+	}
+
+	/**
+	 * @param {FC.SlaveState} slave
+	 * @returns {Promise<string>} - Base 64 encoded image (could be a jpeg or png)
+	 */
+	async fetchImageForSlave(slave) {
+		const settings = this.buildStableDiffusionSettings(slave);
+		return this.fetchImage(settings);
+	}
+
+	/**
+	 * Update a slave object with a new image
+	 * @param {FC.SlaveState} slave - The slave to update
+	 */
+	async updateSlave(slave) {
+		const base64Image = await this.fetchImageForSlave(slave);
+		const mimeType = getMimeType(base64Image);
+
+		const imageId = await App.Art.GenAI.imageDB.addImage({data: `data:${mimeType};base64,${base64Image}`});
+		slave.custom.aiImageId = imageId;
+	}
+};
+
+/**
+ * @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";
+	}
+}
diff --git a/src/art/genAI/stylePromptPart.js b/src/art/genAI/stylePromptPart.js
new file mode 100644
index 00000000000..4b76eb204c9
--- /dev/null
+++ b/src/art/genAI/stylePromptPart.js
@@ -0,0 +1,15 @@
+App.Art.GenAI.StylePromptPart = class StylePromptPart extends App.Art.GenAI.PromptPart {
+	/**
+	 * @returns {string}
+	 */
+	positive() {
+		return "<lora:FilmVelvia3:0.6> <lora:LowRA:0.5> 8k photography, dark theme, black background";
+	}
+
+	/**
+	 * @returns {string}
+	 */
+	negative() {
+		return "greyscale, monochrome, cg, render, unreal engine";
+	}
+};
diff --git a/src/art/genAI/tattoosPromptPart.js b/src/art/genAI/tattoosPromptPart.js
new file mode 100644
index 00000000000..6811e2d7012
--- /dev/null
+++ b/src/art/genAI/tattoosPromptPart.js
@@ -0,0 +1,33 @@
+App.Art.GenAI.TattoosPromptPart = class TattoosPromptPart extends App.Art.GenAI.PromptPart {
+	/**
+	 * @returns {string}
+	 */
+	positive() {
+		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) {
+			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`;
+		}
+	}
+};
diff --git a/src/art/genAI/waistPromptPart.js b/src/art/genAI/waistPromptPart.js
new file mode 100644
index 00000000000..016896bea14
--- /dev/null
+++ b/src/art/genAI/waistPromptPart.js
@@ -0,0 +1,31 @@
+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`;
+		}
+	}
+};
diff --git a/src/art/genAI/weightPromptPart.js b/src/art/genAI/weightPromptPart.js
new file mode 100644
index 00000000000..9a55f1fae98
--- /dev/null
+++ b/src/art/genAI/weightPromptPart.js
@@ -0,0 +1,35 @@
+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`;
+		}
+	}
+};
diff --git a/src/gui/options/options.js b/src/gui/options/options.js
index 51dde4f01e9..e67062537a7 100644
--- a/src/gui/options/options.js
+++ b/src/gui/options/options.js
@@ -492,7 +492,7 @@ App.UI.optionsPassage = function() {
 			option.addComment("Enable cheat mode to edit genetics.");
 		}
 
-		options.addCustomOption("Rules Assistant").addButton("Reset Rules", () => { initRules(); }, "Rules Assistant");
+		options.addCustomOption("Rules Assistant").addButton("Reset Rules", () => {initRules();}, "Rules Assistant");
 
 		options.addOption("Passage Profiler is", "profiler")
 			.addValue("Enabled", 1).on().addValue("Disabled", 0).off()
@@ -737,10 +737,6 @@ App.UI.optionsPassage = function() {
 		options.addOption("New event", "tempEventToggle")
 			.addValue("Enabled", 1).on().addValue("Disabled", 0).off();
 
-		options.addOption("New Interactions", "interactions", V.experimental)
-			.addValue("Enabled", 1).on().addValue("Disabled", 0).off()
-			.addComment("This will enable WIP slave interactions. Currently fully functional, but a little rough around the edges.");
-
 		options.addOption("Sex overhaul", "sexOverhaul", V.experimental)
 			.addValue("Enabled", 1).on().addValue("Disabled", 0).off()
 			.addComment("This will enable a new way to interact with slaves. Currently working but missing flavor text.");
@@ -1144,6 +1140,7 @@ App.UI.artOptions = function() {
 					["Revamped embedded vector art", 3],
 					["Non-embedded vector art", 2],
 					["Shokushu's rendered image pack", 5],
+					["Anon's AI image generation", 6],
 				]);
 			if (V.imageChoice === 1) {
 				options.addComment("The only 2D art in somewhat recent development. Contains many outfits.");
@@ -1227,6 +1224,9 @@ App.UI.artOptions = function() {
 					.addValue("6", 6).off().addValue("12", 12).on().addValue("24", 24).off().addValue("32", 32).off();
 			} else if (V.imageChoice === 2) {
 				option.addComment("This art development is dead since vanilla. Since it is not embedded, requires a separate art pack to be downloaded.");
+			} else if (V.imageChoice === 6) {
+				options.addComment("This is highly experimental. If you're reading this, tell me to replace it with instructions on how to set up Stable Diffusion.");
+				options.addOption("API URL", "aiApiUrl").showTextBox().addComment("The URL of the Automatic 1111 Stable Diffusion API.");
 			}
 
 			options.addOption("PA avatar art is", "seeAvatar")
diff --git a/src/interaction/siCustom.js b/src/interaction/siCustom.js
index 793b3720a03..40a30adefc3 100644
--- a/src/interaction/siCustom.js
+++ b/src/interaction/siCustom.js
@@ -17,7 +17,8 @@ App.UI.SlaveInteract.custom = function(slave, refresh) {
 	el.append(
 		customSlaveImage(),
 		customHairImage(),
-		artSeed()
+		artSeed(),
+		genAIPrompt()
 	);
 
 	App.UI.DOM.appendNewElement("h3", el, `Names`);
@@ -688,8 +689,8 @@ App.UI.SlaveInteract.custom = function(slave, refresh) {
 
 	function artSeed() {
 		const frag = new DocumentFragment();
-		if (V.imageChoice === 4) { // webGL only right now
-			App.UI.DOM.appendNewElement("p", frag, `WebGL rendering uses a "seed value" to make small changes to the appearance of your slaves. If you're dissatisfied with this slave's appearance and correcting ${his} physical parameters doesn't seem to help, you can try replacing the seed value. Slaves with identical seeds will look identical; the game carefully preserves this value for clones and identical twins, but if you change it here it becomes your responsibility.`);
+		if (V.imageChoice === 4 || V.imageChoice === 6) { // webGL and AI art only right now
+			App.UI.DOM.appendNewElement("p", frag, `Some rendering methods use a "seed value" to make small changes to the appearance of your slaves. If you're dissatisfied with this slave's appearance and correcting ${his} physical parameters doesn't seem to help, you can try replacing the seed value. Slaves with identical seeds will look identical; the game carefully preserves this value for clones and identical twins, but if you change it here it becomes your responsibility.`);
 
 			const setArtSeed = (/** @type {number} */ num) => {
 				slave.natural.artSeed = num;
@@ -823,4 +824,18 @@ App.UI.SlaveInteract.custom = function(slave, refresh) {
 
 		return el;
 	}
+
+	function genAIPrompt() {
+		let el = document.createElement('p');
+
+		el.appendChild(document.createElement('h4')).textContent = `Image generation AI (eg. Stable Diffusion):`;
+
+		let prompt = buildPrompt(slave);
+		el.appendChild(document.createElement('h5')).textContent = `Positive prompt`;
+		el.appendChild(document.createElement('kbd')).textContent = prompt.positive();
+		el.appendChild(document.createElement('h5')).textContent = `Negative prompt`;
+		el.appendChild(document.createElement('kbd')).textContent = prompt.negative();
+
+		return el;
+	}
 };
diff --git a/src/js/SlaveState.js b/src/js/SlaveState.js
index 38db8b14537..f7e61cb97f1 100644
--- a/src/js/SlaveState.js
+++ b/src/js/SlaveState.js
@@ -380,6 +380,13 @@ App.Entity.SlaveCustomAddonsState = class SlaveCustomAddonsState {
 		 * @type {FC.Zeroable<string>}
 		 */
 		this.hairVector = 0;
+		/**
+		 * holds the ai image ID
+		 *
+		 * used if ai images are enabled
+		 * @type {FC.Zeroable<number>}
+		 */
+		this.aiImageId = null;
 	}
 };
 
-- 
GitLab