Integrate Stable Diffusion with the Imaginate layer (#784)

* Add AI Artist layer

* WIP add a button to download the rendered folder under an AI Artist layer

* Successfully download the correct image

* Break out image downloading JS into helper function

* Change file download from using data URLs to blob URLs

* WIP rasterize to blob

* Remove dimensions from AI Artist layer

* Successfully draw rasterized image on layer after calculation

* Working txt2img generation based on user prompt

* Add img2img and the main parameters

* Fix ability to rasterize multi-depth documents with blob URL images by switching them to base64

* Fix test

* Rasterize with artboard background color

* Allow aspect ratio stretch of AI Artist images

* Add automatic resolution choosing

* Add a terminate button, and make the lifecycle more robust

* Add negative prompt

* Add range bounds for parameter inputs

* Add seed

* Add tiling and restore faces

* Add server status check, server hostname customization, and resizing layer to fit AI Artist resolution

* Fix background color of infinite canvas rasterization

* Escape prompt text sent in the JSON

* Revoke blob URLs when cleared/replaced to reduce memory leak

* Fix welcome screen logo color

* Add PreferencesMessageHandler

* Add persistent storage of preferences

* Fix crash introduced in previous commit when moving mouse on page load

* Add tooltips to the AI Artist layer properties

* Integrate AI Artist tool into the raster section of the tool shelf

* Add a refresh button to the connection status

* Fix crash when generating and switching to a different document tab

* Add persistent image storage to AI Artist layers and fix duplication bugs

* Add a generate with random seed button

* Simplify and standardize message names

* Majorly improve robustness of networking code

* Fix race condition causing default server hostname to show disconnected when app loads with AI Artist layer selected (probably, not confirmed fixed)

* Clean up messages and function calls by changing arguments into structs

* Update API to more recent server commit

* Add support for picking the sampling method

* Add machinery for filtering selected layers with type

* Replace placeholder button icons

* Improve the random icon by tilting the dice

* Use selected_layers() instead of repeating that code

* Fix borrow error

* Change message flow in progress towards fixing #797

* Allow loading image on non-active document (fixes #797)

* Reduce code duplication with rasterization

* Add AI Artist tool and layer icons, and remove ugly node layer icon style

* Rename "AI Artist" codename to "Imaginate" feature name

Co-authored-by: otdavies <oliver@psyfer.io>
Co-authored-by: 0hypercube <0hypercube@gmail.com>
This commit is contained in:
Keavon Chambers
2022-10-18 22:33:27 -07:00
committed by GitHub
co-authored by otdavies 0hypercube
parent 06acd45a81
commit 30719bdc72
118 changed files with 3767 additions and 678 deletions
+439
View File
@@ -0,0 +1,439 @@
import { escapeJSON } from "@/utility-functions/escape";
import { blobToBase64 } from "@/utility-functions/files";
import { type RequestResult, requestWithUploadDownloadProgress } from "@/utility-functions/network";
import { stripIndents } from "@/utility-functions/strip-indents";
import { type Editor } from "@/wasm-communication/editor";
import { type ImaginateGenerationParameters } from "@/wasm-communication/messages";
const MAX_POLLING_RETRIES = 4;
const SERVER_STATUS_CHECK_TIMEOUT = 5000;
const SAMPLING_MODES_POLLING_UNSUPPORTED = ["DPM fast", "DPM adaptive"];
let timer: NodeJS.Timeout | undefined;
let terminated = false;
let generatingAbortRequest: XMLHttpRequest | undefined;
let pollingAbortController = new AbortController();
let statusAbortController = new AbortController();
// PUBLICLY CALLABLE FUNCTIONS
export async function imaginateGenerate(
parameters: ImaginateGenerationParameters,
image: Blob | undefined,
hostname: string,
refreshFrequency: number,
documentId: bigint,
layerPath: BigUint64Array,
editor: Editor
): Promise<void> {
// Ignore a request to generate a new image while another is already being generated
if (generatingAbortRequest !== undefined) return;
terminated = false;
// Immediately set the progress to 0% so the backend knows to update its layout
editor.instance.setImaginateGeneratingStatus(documentId, layerPath, 0, "Beginning");
// Initiate a request to the computation server
const discloseUploadingProgress = (progress: number): void => {
editor.instance.setImaginateGeneratingStatus(documentId, layerPath, progress * 100, "Uploading");
};
const { uploaded, result, xhr } = await generate(discloseUploadingProgress, hostname, image, parameters);
generatingAbortRequest = xhr;
try {
// Wait until the request is fully uploaded, which could be slow if the img2img source is large and the user is on a slow connection
await uploaded;
editor.instance.setImaginateGeneratingStatus(documentId, layerPath, 0, "Generating");
// Begin polling for updates to the in-progress image generation at the specified interval
// Don't poll if the chosen interval is 0, or if the chosen sampling method does not support polling
if (refreshFrequency > 0 && !SAMPLING_MODES_POLLING_UNSUPPORTED.includes(parameters.samplingMethod)) {
const interval = Math.max(refreshFrequency * 1000, 500);
scheduleNextPollingUpdate(interval, Date.now(), 0, editor, hostname, documentId, layerPath, parameters.resolution);
}
// Wait for the final image to be returned by the initial request containing either the full image or the last frame if it was terminated by the user
const { body, status } = await result;
if (status < 200 || status > 299) {
throw new Error(`Request to server failed to return a 200-level status code (${status})`);
}
// Extract the final image from the response and convert it to a data blob
// Highly unstable API
const base64 = JSON.parse(body)?.data[0]?.[0] as string | undefined;
if (typeof base64 !== "string" || !base64.startsWith("data:image/png;base64,")) throw new Error("Could not read final image result from server response");
const blob = await (await fetch(base64)).blob();
// Send the backend an updated status
const percent = terminated ? undefined : 100;
const newStatus = terminated ? "Terminated" : "Idle";
editor.instance.setImaginateGeneratingStatus(documentId, layerPath, percent, newStatus);
// Send the backend a blob URL for the final image
const blobURL = URL.createObjectURL(blob);
editor.instance.setImaginateBlobURL(documentId, layerPath, blobURL, parameters.resolution[0], parameters.resolution[1]);
// Send the backend the blob data to be stored persistently in the layer
const u8Array = new Uint8Array(await blob.arrayBuffer());
editor.instance.setImaginateImageData(documentId, layerPath, u8Array);
} catch {
editor.instance.setImaginateGeneratingStatus(documentId, layerPath, undefined, "Terminated");
await imaginateCheckConnection(hostname, editor);
}
abortAndResetGenerating();
abortAndResetPolling();
}
export async function imaginateTerminate(hostname: string, documentId: bigint, layerPath: BigUint64Array, editor: Editor): Promise<void> {
terminated = true;
abortAndResetPolling();
try {
await terminate(hostname);
editor.instance.setImaginateGeneratingStatus(documentId, layerPath, undefined, "Terminating");
} catch {
abortAndResetGenerating();
abortAndResetPolling();
editor.instance.setImaginateGeneratingStatus(documentId, layerPath, undefined, "Terminated");
await imaginateCheckConnection(hostname, editor);
}
}
export async function imaginateCheckConnection(hostname: string, editor: Editor): Promise<void> {
const serverReached = await checkConnection(hostname);
editor.instance.setImaginateServerStatus(serverReached);
}
// ABORTING AND RESETTING HELPERS
function abortAndResetGenerating(): void {
generatingAbortRequest?.abort();
generatingAbortRequest = undefined;
}
function abortAndResetPolling(): void {
pollingAbortController.abort();
pollingAbortController = new AbortController();
clearTimeout(timer);
}
// POLLING IMPLEMENTATION DETAILS
function scheduleNextPollingUpdate(
interval: number,
timeoutBegan: number,
pollingRetries: number,
editor: Editor,
hostname: string,
documentId: bigint,
layerPath: BigUint64Array,
resolution: [number, number]
): void {
// Pick a future time that keeps to the user-requested interval if possible, but on slower connections will go as fast as possible without overlapping itself
const nextPollTimeGoal = timeoutBegan + interval;
const timeFromNow = Math.max(0, nextPollTimeGoal - Date.now());
timer = setTimeout(async () => {
const nextTimeoutBegan = Date.now();
try {
const [blob, percentComplete] = await pollImage(hostname);
if (terminated) return;
const blobURL = URL.createObjectURL(blob);
editor.instance.setImaginateBlobURL(documentId, layerPath, blobURL, resolution[0], resolution[1]);
editor.instance.setImaginateGeneratingStatus(documentId, layerPath, percentComplete, "Generating");
scheduleNextPollingUpdate(interval, nextTimeoutBegan, 0, editor, hostname, documentId, layerPath, resolution);
} catch {
if (generatingAbortRequest === undefined) return;
if (pollingRetries + 1 > MAX_POLLING_RETRIES) {
abortAndResetGenerating();
abortAndResetPolling();
await imaginateCheckConnection(hostname, editor);
} else {
scheduleNextPollingUpdate(interval, nextTimeoutBegan, pollingRetries + 1, editor, hostname, documentId, layerPath, resolution);
}
}
}, timeFromNow);
}
// API COMMUNICATION FUNCTIONS
// These are highly unstable APIs that will need to be updated very frequently, so we currently assume usage of this exact commit from the server:
// https://github.com/AUTOMATIC1111/stable-diffusion-webui/commit/7d6042b908c064774ee10961309d396eabdc6c4a
function endpoint(hostname: string): string {
// Highly unstable API
return `${hostname}api/predict/`;
}
async function pollImage(hostname: string): Promise<[Blob, number]> {
// Highly unstable API
const result = await fetch(endpoint(hostname), {
signal: pollingAbortController.signal,
headers: {
accept: "*/*",
"accept-language": "en-US,en;q=0.9",
"content-type": "application/json",
},
referrer: hostname,
referrerPolicy: "strict-origin-when-cross-origin",
body: stripIndents`
{
"fn_index":3,
"data":[],
"session_hash":"0000000000"
}`,
method: "POST",
mode: "cors",
credentials: "omit",
});
const json = await result.json();
// Highly unstable API
const percentComplete = Math.abs(Number(json.data[0].match(/(?<="width:).*?(?=%")/)[0])); // The API sometimes returns negative values presumably due to a bug
// Highly unstable API
const base64 = json.data[2];
if (typeof base64 !== "string" || !base64.startsWith("data:image/png;base64,")) return Promise.reject();
const blob = await (await fetch(base64)).blob();
return [blob, percentComplete];
}
async function generate(
discloseUploadingProgress: (progress: number) => void,
hostname: string,
image: Blob | undefined,
parameters: ImaginateGenerationParameters
): Promise<{
uploaded: Promise<void>;
result: Promise<RequestResult>;
xhr?: XMLHttpRequest;
}> {
let body;
if (image === undefined || parameters.denoisingStrength === undefined) {
// Highly unstable API
body = stripIndents`
{
"fn_index":13,
"data":[
"${escapeJSON(parameters.prompt)}",
"${escapeJSON(parameters.negativePrompt)}",
"None",
"None",
${parameters.samples},
"${parameters.samplingMethod}",
${parameters.restoreFaces},
${parameters.tiling},
1,
1,
${parameters.cfgScale},
${parameters.seed},
-1,
0,
0,
0,
false,
${parameters.resolution[1]},
${parameters.resolution[0]},
false,
0.7,
0,
0,
"None",
false,
false,
null,
"",
"Seed",
"",
"Nothing",
"",
true,
false,
false,
null,
""
],
"session_hash":"0000000000"
}`;
} else {
const sourceImageBase64 = await blobToBase64(image);
// Highly unstable API
body = stripIndents`
{
"fn_index":33,
"data":[
0,
"${escapeJSON(parameters.prompt)}",
"${escapeJSON(parameters.negativePrompt)}",
"None",
"None",
"${sourceImageBase64}",
null,
null,
null,
"Draw mask",
${parameters.samples},
"${parameters.samplingMethod}",
4,
"fill",
${parameters.restoreFaces},
${parameters.tiling},
1,
1,
${parameters.cfgScale},
${parameters.denoisingStrength},
${parameters.seed},
-1,
0,
0,
0,
false,
${parameters.resolution[1]},
${parameters.resolution[0]},
"Just resize",
false,
32,
"Inpaint masked",
"",
"",
"None",
"",
true,
true,
"",
"",
true,
50,
true,
1,
0,
false,
4,
1,
"",
128,
8,
["left","right","up","down"],
1,
0.05,
128,
4,
"fill",
["left","right","up","down"],
false,
false,
null,
"",
"",
64,
"None",
"Seed",
"",
"Nothing",
"",
true,
false,
false,
null,
"",
""
],
"session_hash":"0000000000"
}`;
}
// Prepare a promise that will resolve after the outbound request upload is complete
let uploadedResolve: () => void;
let uploadedReject: () => void;
const uploaded = new Promise<void>((resolve, reject): void => {
uploadedResolve = resolve;
uploadedReject = reject;
});
// Fire off the request and, once the outbound request upload is complete, resolve the promise we defined above
const uploadProgress = (progress: number): void => {
if (progress < 1) {
discloseUploadingProgress(progress);
} else {
uploadedResolve();
}
};
const [result, xhr] = requestWithUploadDownloadProgress(endpoint(hostname), "POST", body, uploadProgress, abortAndResetPolling);
result.catch(() => uploadedReject());
// Return the promise that resolves when the request upload is complete, the promise that resolves when the response download is complete, and the XHR so it can be aborted
return { uploaded, result, xhr };
}
async function terminate(hostname: string): Promise<void> {
const body = stripIndents`
{
"fn_index":2,
"data":[],
"session_hash":"0000000000"
}`;
await fetch(endpoint(hostname), {
headers: {
accept: "*/*",
"accept-language": "en-US,en;q=0.9",
"content-type": "application/json",
},
referrer: hostname,
referrerPolicy: "strict-origin-when-cross-origin",
body,
method: "POST",
mode: "cors",
credentials: "omit",
});
}
async function checkConnection(hostname: string): Promise<boolean> {
statusAbortController.abort();
statusAbortController = new AbortController();
const timeout = setTimeout(() => statusAbortController.abort(), SERVER_STATUS_CHECK_TIMEOUT);
const body = stripIndents`
{
"fn_index":100,
"data":[],
"session_hash":"0000000000"
}`;
try {
await fetch(endpoint(hostname), {
signal: statusAbortController.signal,
headers: {
accept: "*/*",
"accept-language": "en-US,en;q=0.9",
"content-type": "application/json",
},
referrer: hostname,
referrerPolicy: "strict-origin-when-cross-origin",
body,
method: "POST",
mode: "cors",
credentials: "omit",
});
clearTimeout(timeout);
return true;
} catch (_) {
return false;
}
}