diff --git a/css/art/genAI.css b/css/art/genAI.css index ddc5482cb17605044a76d698aaf1253c872fa30a..8d111f780b44da137dc59309f762990d28a314cb 100644 --- a/css/art/genAI.css +++ b/css/art/genAI.css @@ -17,11 +17,33 @@ animation: spin 2s linear infinite; } +.rightArrow { + right: 0px; +} + +.leftArrow { + left: 0px; +} + +.arrow { + display: none; + position: absolute; + bottom: 0px; + cursor: pointer; + border: none; + background: none; +} + +.arrow:hover { + border: none; + background: none; +} + .ai-art-container { width: 100%; height: 100%; min-width: 100px; - min-height: 100px; + min-height: 150px; float: right; border: 3px hidden; object-fit: contain; @@ -34,8 +56,8 @@ .ai-toolbar { display: none; position: absolute; - right: 1rem; - top: 1rem; + right: 0px; + top: 0px; } .ai-art-container:hover .ai-toolbar { @@ -43,6 +65,9 @@ flex-direction: column; } +.ai-art-container:hover .arrow { + display: block; +} .ai-toolbar button { /* position: absolute; */ diff --git a/src/art/artJS.js b/src/art/artJS.js index 7b50e278b5020baae9f98a4227ecfa659bc9f070..efa2d8fc38b28c636294f8fcd8d5d8e2ed8c41fe 100644 --- a/src/art/artJS.js +++ b/src/art/artJS.js @@ -434,10 +434,10 @@ App.Art.customArtElement = function(imageInfo, imageSize) { * @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) { +async function renderAIArt(slave, imageSize, imageNum = null) { let imgElement; - if (slave.custom.aiImageId === null) { - imgElement = document.createElement("div"); + if (slave.custom.aiImageIds.length === 0) { + imgElement = document.createElement("div", {is: `slaveImg${slave.ID}`}); } else { imgElement = document.createElement("img"); } @@ -452,8 +452,15 @@ async function renderAIArt(slave, imageSize) { } try { - const imageData = await App.Art.GenAI.imageDB.getImage(slave.custom.aiImageId); + // Initial slave state + if (imageNum === -1) { + return imgElement; + } + const imageIdx = imageNum || 0; + const imageDbId = slave.custom.aiImageIds[imageIdx]; + const imageData = await App.Art.GenAI.imageDB.getImage(imageDbId); imgElement.setAttribute("src", imageData.data); + imgElement.setAttribute("title", `${slave.custom.aiDisplayImageIdx + 1}/${slave.custom.aiImageIds.length}`); } catch (e) { return Promise.reject(e); } @@ -473,7 +480,11 @@ App.Art.aiArtElement = function(slave, imageSize) { toolbar.classList.add('ai-toolbar'); container.appendChild(toolbar); /** @type {HTMLButtonElement} */ - let refreshButton; + let replaceButton; + /** @type {HTMLButtonElement} */ + let generationButton; + /** @type {HTMLButtonElement} */ + let deletionButton; /** @type {HTMLDivElement} */ let spinner; /** @type {HTMLButtonElement} */ @@ -525,19 +536,67 @@ App.Art.aiArtElement = function(slave, imageSize) { * @param {HTMLDivElement} toolbar * @param {HTMLDivElement} container */ - function makeRefreshButton(toolbar, container) { - refreshButton = document.createElement("button"); - refreshButton.innerText = '⟳'; - refreshButton.title = 'Regenerate'; - refreshButton.addEventListener("click", function() { - console.log('clicked listner to refresh button'); + function makeReplaceButton(toolbar, container) { + replaceButton = document.createElement("button"); + replaceButton.innerText = '⟳'; + replaceButton.title = 'Replace'; + replaceButton.addEventListener("click", function() { + console.log('clicked listner to replace button'); + if (!container.classList.contains("refreshing")) { + if (slave.custom.aiDisplayImageIdx === -1) return; + updateAndRefresh(slave.custom.aiDisplayImageIdx); + } + }); + toolbar.appendChild(replaceButton); + } + makeReplaceButton(toolbar, container); + + /** + * @param {HTMLDivElement} toolbar + * @param {HTMLDivElement} container + */ + function makeGenerationButton(toolbar, container) { + generationButton = document.createElement("button"); + generationButton.innerText = '+'; + generationButton.title = 'Add image'; + generationButton.addEventListener("click", function() { if (!container.classList.contains("refreshing")) { updateAndRefresh(); } }); - toolbar.appendChild(refreshButton); + toolbar.appendChild(generationButton); } - makeRefreshButton(toolbar, container); + makeGenerationButton(toolbar, container); + + async function deleteSlaveAiImage(slave, idx) { + const deletionId = slave.custom.aiImageIds[idx]; + await App.Art.GenAI.imageDB.removeImage(deletionId); + slave.custom.aiImageIds = [...slave.custom.aiImageIds.slice(0, idx), ...slave.custom.aiImageIds.slice(idx + 1)]; + if (slave.custom.aiImageIds.length === 0) { + slave.custom.aiDisplayImageIdx = -1; + } else if (slave.custom.aiDisplayImageIdx !== 0) { + slave.custom.aiDisplayImageIdx--; + } + }; + + /** + * @param {HTMLDivElement} toolbar + * @param {HTMLDivElement} container + */ + function makeDeleteButton(toolbar, container) { + deletionButton = document.createElement("button"); + deletionButton.innerText = 'â“'; + deletionButton.title = 'Delete image'; + deletionButton.addEventListener("click", async function() { + if (!container.classList.contains("refreshing")) { + if (slave.custom.aiDisplayImageIdx === -1) return; + await deleteSlaveAiImage(slave, slave.custom.aiDisplayImageIdx) + refresh(false); + } + }); + toolbar.appendChild(deletionButton); + } + makeDeleteButton(toolbar, container); /** * @param {HTMLDivElement} container @@ -555,23 +614,77 @@ App.Art.aiArtElement = function(slave, imageSize) { * @param {boolean} retry should we retry image generation or not? */ function refresh(retry) { - renderAIArt(slave, imageSize) + renderAIArt(slave, imageSize, slave.custom.aiDisplayImageIdx) .then((imgElement) => { container.querySelector('.ai-art-image')?.remove(); container.prepend(imgElement); // prepend it before the toolbar and spinner, otherwise you can't see them - }).catch(() => { + }).catch((e) => { if (retry) { + console.log('Error in refresh retry') + console.log(e); updateAndRefresh(); } }); } - function updateAndRefresh() { + function makeImageNavigationArrows(container) { + const leftArrow = document.createElement('button'); + leftArrow.classList.add("leftArrow", "arrow"); + leftArrow.name = 'leftButton'; + leftArrow.title = 'Previous image'; + leftArrow.innerText = 'â†'; + leftArrow.onclick = (e) => { + // Stop update onclick + e.stopPropagation(); + if(!slave.custom.aiImageIds) { + slave.custom.aiImageIds = []; + } + + if (slave.custom.aiImageIds.length === 0) { + updateAndRefresh(); + } else { + if (slave.custom.aiDisplayImageIdx > 0) { + slave.custom.aiDisplayImageIdx--; + } else { + slave.custom.aiDisplayImageIdx = slave.custom.aiImageIds.length - 1; + } + refresh(false); + }; + }; + container.appendChild(leftArrow); + + const rightArrow = document.createElement('button'); + rightArrow.classList.add("rightArrow", "arrow"); + rightArrow.name = 'rightButton'; + rightArrow.title = 'Next image'; + rightArrow.innerText = '→'; + rightArrow.onclick = (e) => { + e.stopPropagation(); + if(!slave.custom.aiImageIds) { + slave.custom.aiImageIds = []; + } + + if (slave.custom.aiImageIds.length === 0) { + updateAndRefresh(); + } else { + if (slave.custom.aiDisplayImageIdx < slave.custom.aiImageIds.length - 1) { + slave.custom.aiDisplayImageIdx++; + } else { + slave.custom.aiDisplayImageIdx = 0; + } + refresh(false); + } + }; + container.appendChild(rightArrow); + } + makeImageNavigationArrows(container); + + function updateAndRefresh(index = null) { const imageGenerator = new App.Art.GenAI.StableDiffusionClient(); container.classList.add("refreshing"); - imageGenerator.updateSlave(slave).then(() => { + imageGenerator.updateSlave(slave, index).then(() => { refresh(false); }).catch(error => { console.error(error); @@ -580,11 +693,10 @@ App.Art.aiArtElement = function(slave, imageSize) { }); } - - if (slave.custom.aiImageId === null) { + if (slave.custom.aiImageIds === null) { updateAndRefresh(); } else { - refresh(true); + refresh(false); } return container; }; diff --git a/src/art/genAI/imageDB.js b/src/art/genAI/imageDB.js index 0323db9090947cf51a78903218082318191e8c42..3c6e5973d0dae5433136fe4bb7bf3313da803027 100644 --- a/src/art/genAI/imageDB.js +++ b/src/art/genAI/imageDB.js @@ -63,10 +63,9 @@ App.Art.GenAI.imageDB = (function() { */ 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); + const transaction = db.transaction(['images'], 'readonly'); + const objectStore = transaction.objectStore('images'); + const request = objectStore.get(id); request.onsuccess = function() { resolve(request.result); diff --git a/src/art/genAI/stableDiffusion.js b/src/art/genAI/stableDiffusion.js index 21fd3bc4cd8bd514e3ffa825dc27f9b10bd8e6fd..3e7cd7faf93c70ba397cfee508a2f5c05a768433 100644 --- a/src/art/genAI/stableDiffusion.js +++ b/src/art/genAI/stableDiffusion.js @@ -110,7 +110,7 @@ App.Art.GenAI.StableDiffusionClientQueue = class { body: satisfied.last().body, }; try { - const response = await fetchWithTimeout(`${V.aiApiUrl}/sdapi/v1/txt2img`, 60000, options); + const response = await fetchWithTimeout(`${V.aiApiUrl}/sdapi/v1/txt2img`, 600000, options); if (!response.ok) { throw new Error(`Error fetching Stable Diffusion image - status: ${response.status}`); } @@ -136,7 +136,7 @@ App.Art.GenAI.StableDiffusionClientQueue = class { /** * Queue image generation for an entity * @param {number} slaveID or a unique negative value for non-slave entities - * @param {string} body body of the post request to be sent to txt2img + * @param {string} body of the post request to be sent to txt2img * @returns {Promise<Object>} */ async add(slaveID, body) { @@ -223,7 +223,6 @@ App.Art.GenAI.StableDiffusionClient = class { */ async fetchImageForSlave(slave) { const settings = this.buildStableDiffusionSettings(slave); - // set up a passage switch handler to interrupt image generation if it's incomplete const oldHandler = App.Utils.PassageSwitchHandler.get(); App.Utils.PassageSwitchHandler.set(() => { @@ -240,21 +239,54 @@ App.Art.GenAI.StableDiffusionClient = class { /** * 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 */ - async updateSlave(slave) { + async updateSlave(slave, replacementImageIndex = null) { const base64Image = await this.fetchImageForSlave(slave); - const mimeType = getMimeType(base64Image); + const imageData = getImageData(base64Image); + const imagePreexisting = await compareExistingImages(slave, imageData); - const dbrecord = {data: `data:${mimeType};base64,${base64Image}`}; - if (slave.custom.aiImageId !== null) { - dbrecord.id = slave.custom.aiImageId; + // 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; } - - const imageId = await App.Art.GenAI.imageDB.putImage(dbrecord); - slave.custom.aiImageId = imageId; } }; +/** + * 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 index of the image in aiImageIds or -1 + */ +async function compareExistingImages (slave, newImageData) { + const aiImages = await Promise.all(slave.custom.aiImageIds.map(id => { + return App.Art.GenAI.imageDB.getImage(id); + })); + return aiImages.findIndex(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} diff --git a/src/data/backwardsCompatibility/datatypeCleanup.js b/src/data/backwardsCompatibility/datatypeCleanup.js index 5f401ffb15b50c7933050f1973d25681a49a0c9c..eea18fd7183159c878a457862ce7f96c7365e282 100644 --- a/src/data/backwardsCompatibility/datatypeCleanup.js +++ b/src/data/backwardsCompatibility/datatypeCleanup.js @@ -266,8 +266,15 @@ App.Entity.Utils.SlaveDataSchemeCleanup = (function() { slave.custom.name = ""; } - if (!slave.custom.hasOwnProperty("aiImageId")) { - slave.custom.aiImageId = null; + if (!slave.custom.hasOwnProperty("aiImageIds")) { + if (slave.custom.hasOwnProperty("aiImageId") && slave.custom.aiImageId !== null) { + slave.custom.aiImageIds = [slave.custom.aiImageId]; + slave.custom.aiDisplayImageIdx = 0; + delete slave.custom.aiImageId; + } else { + slave.custom.aiImageIds = []; + slave.custom.aiDisplayImageIdx = -1; + } } } diff --git a/src/js/SlaveState.js b/src/js/SlaveState.js index 03f3fd0307633714ef395b8b5a7f68185ffb839d..89a9cc9d8fc186aba92f876b610b1e40f95a4224 100644 --- a/src/js/SlaveState.js +++ b/src/js/SlaveState.js @@ -409,9 +409,16 @@ App.Entity.SlaveCustomAddonsState = class SlaveCustomAddonsState { * holds the ai image ID * * used if ai images are enabled + * @type {Array<number>} + */ + this.aiImageIds = []; + /** + * holds the index of the displayed AI image in aiImageIds + * + * used if ai images are enabled * @type {number} */ - this.aiImageId = null; + this.aiDisplayImageIdx = -1; /** * custom AI prompts; may be null or absent * @type {App.Entity.SlaveCustomAIPrompts}