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
parent 562217015d
commit fe1a03fac7
118 changed files with 3767 additions and 678 deletions

View file

@ -217,7 +217,6 @@ img {
<script lang="ts">
import { defineComponent } from "vue";
import { createBlobManager } from "@/io-managers/blob";
import { createClipboardManager } from "@/io-managers/clipboard";
import { createHyperlinkManager } from "@/io-managers/hyperlinks";
import { createInputManager } from "@/io-managers/input";
@ -236,7 +235,6 @@ import { createEditor, type Editor } from "@/wasm-communication/editor";
import MainWindow from "@/components/window/MainWindow.vue";
const managerDestructors: {
createBlobManager?: () => void;
createClipboardManager?: () => void;
createHyperlinkManager?: () => void;
createInputManager?: () => void;
@ -285,7 +283,6 @@ export default defineComponent({
async mounted() {
// Initialize managers, which are isolated systems that subscribe to backend messages to link them to browser API functionality (like JS events, IndexedDB, etc.)
Object.assign(managerDestructors, {
createBlobManager: createBlobManager(this.editor),
createClipboardManager: createClipboardManager(this.editor),
createHyperlinkManager: createHyperlinkManager(this.editor),
createInputManager: createInputManager(this.editor, this.$el.parentElement, this.dialog, this.portfolio, this.fullscreen),

View file

@ -22,6 +22,7 @@
class="row"
:class="{ open: isEntryOpen(entry), active: entry.label === highlighted?.label, disabled: entry.disabled }"
:style="{ height: virtualScrollingEntryHeight || '20px' }"
:title="tooltip"
@click="() => !entry.disabled && onEntryClick(entry)"
@pointerenter="() => !entry.disabled && onEntryPointerEnter(entry)"
@pointerleave="() => !entry.disabled && onEntryPointerLeave(entry)"
@ -184,6 +185,7 @@ const MenuList = defineComponent({
interactive: { type: Boolean as PropType<boolean>, default: false },
scrollableY: { type: Boolean as PropType<boolean>, default: false },
virtualScrollingEntryHeight: { type: Number as PropType<number>, default: 0 },
tooltip: { type: String as PropType<string | undefined>, required: false },
},
data() {
return {

View file

@ -45,17 +45,14 @@
@click.alt="(e: MouseEvent) => e.stopPropagation()"
>
<LayoutRow class="layer-type-icon">
<IconLabel v-if="listing.entry.layerType === 'Folder'" :icon="'NodeFolder'" :iconStyle="'Node'" title="Folder" />
<IconLabel v-else-if="listing.entry.layerType === 'Image'" :icon="'NodeImage'" :iconStyle="'Node'" title="Image" />
<IconLabel v-else-if="listing.entry.layerType === 'Shape'" :icon="'NodeShape'" :iconStyle="'Node'" title="Shape" />
<IconLabel v-else-if="listing.entry.layerType === 'Text'" :icon="'NodeText'" :iconStyle="'Node'" title="Path" />
<IconLabel :icon="layerTypeData(listing.entry.layerType).icon" :title="layerTypeData(listing.entry.layerType).name" />
</LayoutRow>
<LayoutRow class="layer-name" @dblclick="() => onEditLayerName(listing)">
<input
data-text-input
type="text"
:value="listing.entry.name"
:placeholder="listing.entry.layerType"
:placeholder="layerTypeData(listing.entry.layerType).name"
:disabled="!listing.editingName"
@blur="() => onEditLayerNameDeselect(listing)"
@keydown.esc="onEditLayerNameDeselect(listing)"
@ -268,7 +265,16 @@
import { defineComponent, nextTick } from "vue";
import { platformIsMac } from "@/utility-functions/platform";
import { type LayerPanelEntry, defaultWidgetLayout, UpdateDocumentLayerDetails, UpdateDocumentLayerTreeStructure, UpdateLayerTreeOptionsLayout } from "@/wasm-communication/messages";
import {
type LayerType,
type LayerTypeData,
type LayerPanelEntry,
defaultWidgetLayout,
UpdateDocumentLayerDetails,
UpdateDocumentLayerTreeStructure,
UpdateLayerTreeOptionsLayout,
layerTypeData,
} from "@/wasm-communication/messages";
import LayoutCol from "@/components/layout/LayoutCol.vue";
import LayoutRow from "@/components/layout/LayoutRow.vue";
@ -484,6 +490,9 @@ export default defineComponent({
recurse(updateDocumentLayerTreeStructure, this.layers, this.layerCache);
},
layerTypeData(layerType: LayerType): LayerTypeData {
return layerTypeData(layerType) || { name: "Error", icon: "NodeText" };
},
},
mounted() {
this.editor.subscriptions.subscribeJsMessage(UpdateDocumentLayerTreeStructure, (updateDocumentLayerTreeStructure) => {

View file

@ -28,7 +28,7 @@
<div></div>
</div>
</div>
<IconLabel :icon="'NodeImage'" :iconStyle="'Node'" />
<IconLabel :icon="'NodeImage'" />
<TextLabel>Image</TextLabel>
</div>
</div>
@ -42,7 +42,7 @@
<div></div>
</div>
</div>
<IconLabel :icon="'NodeMask'" :iconStyle="'Node'" />
<IconLabel :icon="'NodeMask'" />
<TextLabel>Mask</TextLabel>
</div>
<div class="arguments">
@ -69,7 +69,7 @@
<div></div>
</div>
</div>
<IconLabel :icon="'NodeTransform'" :iconStyle="'Node'" />
<IconLabel :icon="'NodeTransform'" />
<TextLabel>Transform</TextLabel>
</div>
</div>
@ -83,7 +83,7 @@
<div></div>
</div>
</div>
<IconLabel :icon="'NodeMotionBlur'" :iconStyle="'Node'" />
<IconLabel :icon="'NodeMotionBlur'" />
<TextLabel>Motion Blur</TextLabel>
</div>
<div class="arguments">
@ -110,7 +110,7 @@
<div></div>
</div>
</div>
<IconLabel :icon="'NodeShape'" :iconStyle="'Node'" />
<IconLabel :icon="'NodeShape'" />
<TextLabel>Shape</TextLabel>
</div>
</div>
@ -124,7 +124,7 @@
<div></div>
</div>
</div>
<IconLabel :icon="'NodeBrushwork'" :iconStyle="'Node'" />
<IconLabel :icon="'NodeBrushwork'" />
<TextLabel>Brushwork</TextLabel>
</div>
</div>
@ -138,7 +138,7 @@
<div></div>
</div>
</div>
<IconLabel :icon="'NodeBlur'" :iconStyle="'Node'" />
<IconLabel :icon="'NodeBlur'" />
<TextLabel>Blur</TextLabel>
</div>
</div>
@ -152,7 +152,7 @@
<div></div>
</div>
</div>
<IconLabel :icon="'NodeGradient'" :iconStyle="'Node'" />
<IconLabel :icon="'NodeGradient'" />
<TextLabel>Gradient</TextLabel>
</div>
</div>

View file

@ -21,12 +21,6 @@
.options-bar {
height: 32px;
flex: 0 0 auto;
.widget-row > .icon-label:first-of-type {
border-radius: 2px;
background: var(--color-node-background);
fill: var(--color-node-icon);
}
}
.sections {

View file

@ -1,6 +1,6 @@
<template>
<LayoutRow class="popover-button">
<IconButton :action="() => onClick()" :icon="icon" :size="16" data-hover-menu-spawner />
<IconButton :action="() => onClick()" :icon="icon" :size="16" data-hover-menu-spawner :tooltip="tooltip" />
<FloatingMenu v-model:open="open" :type="'Popover'" :direction="'Bottom'">
<slot></slot>
</FloatingMenu>
@ -58,6 +58,7 @@ import IconButton from "@/components/widgets/buttons/IconButton.vue";
export default defineComponent({
props: {
icon: { type: String as PropType<IconName>, default: "DropdownArrow" },
tooltip: { type: String as PropType<string | undefined>, required: false },
// Callbacks
action: { type: Function as PropType<() => void>, required: false },

View file

@ -5,6 +5,7 @@
:data-emphasized="emphasized || undefined"
:data-disabled="disabled || undefined"
data-text-button
:title="tooltip"
:style="minWidth > 0 ? `min-width: ${minWidth}px` : ''"
@click="(e: MouseEvent) => action(e)"
>
@ -71,23 +72,6 @@ import { type IconName } from "@/utility-functions/icons";
import IconLabel from "@/components/widgets/labels/IconLabel.vue";
import TextLabel from "@/components/widgets/labels/TextLabel.vue";
export type TextButtonWidget = {
tooltip?: string;
message?: string | object;
callback?: () => void;
props: {
kind: "TextButton";
label: string;
icon?: string;
emphasized?: boolean;
minWidth?: number;
disabled?: boolean;
// Callbacks
// `action` is used via `IconButtonWidget.callback`
};
};
export default defineComponent({
props: {
label: { type: String as PropType<string>, required: true },
@ -95,6 +79,7 @@ export default defineComponent({
emphasized: { type: Boolean as PropType<boolean>, default: false },
minWidth: { type: Number as PropType<number>, default: 0 },
disabled: { type: Boolean as PropType<boolean>, default: false },
tooltip: { type: String as PropType<string | undefined>, required: false },
// Callbacks
action: { type: Function as PropType<(e: MouseEvent) => void>, required: true },

View file

@ -61,10 +61,14 @@
.body {
margin: 0 4px;
.text-label {
.text-label:first-of-type {
flex: 0 0 30%;
text-align: right;
}
.text-button {
flex-grow: 1;
}
}
}
</style>

View file

@ -4,6 +4,7 @@
class="dropdown-box"
:class="{ disabled, open }"
:style="{ minWidth: `${minWidth}px` }"
:title="tooltip"
@click="() => !disabled && (open = true)"
@blur="(e: FocusEvent) => blur(e)"
@keydown="(e: KeyboardEvent) => keydown(e)"
@ -115,6 +116,7 @@ export default defineComponent({
drawIcon: { type: Boolean as PropType<boolean>, default: false },
interactive: { type: Boolean as PropType<boolean>, default: true },
disabled: { type: Boolean as PropType<boolean>, default: false },
tooltip: { type: String as PropType<string | undefined>, required: false },
},
data() {
return {

View file

@ -10,6 +10,7 @@
v-model="inputValue"
:spellcheck="spellcheck"
:disabled="disabled"
:title="tooltip"
@focus="() => $emit('textFocused')"
@blur="() => $emit('textChanged')"
@change="() => $emit('textChanged')"
@ -26,6 +27,7 @@
v-model="inputValue"
:spellcheck="spellcheck"
:disabled="disabled"
:title="tooltip"
@focus="() => $emit('textFocused')"
@blur="() => $emit('textChanged')"
@change="() => $emit('textChanged')"
@ -55,6 +57,7 @@
padding: 3px 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&:not(.disabled) label {
@ -129,6 +132,7 @@ export default defineComponent({
spellcheck: { type: Boolean as PropType<boolean>, default: false },
disabled: { type: Boolean as PropType<boolean>, default: false },
textarea: { type: Boolean as PropType<boolean>, default: false },
tooltip: { type: String as PropType<string | undefined>, required: false },
},
data() {
return {

View file

@ -1,6 +1,6 @@
<template>
<LayoutRow class="font-input">
<LayoutRow class="dropdown-box" :class="{ disabled }" :style="{ minWidth: `${minWidth}px` }" tabindex="0" @click="toggleOpen" @keydown="keydown" data-hover-menu-spawner>
<LayoutRow class="dropdown-box" :class="{ disabled }" :style="{ minWidth: `${minWidth}px` }" :title="tooltip" tabindex="0" @click="toggleOpen" @keydown="keydown" data-hover-menu-spawner>
<span>{{ activeEntry?.value || "" }}</span>
<IconLabel class="dropdown-arrow" :icon="'DropdownArrow'" />
</LayoutRow>
@ -87,6 +87,7 @@ export default defineComponent({
fontStyle: { type: String as PropType<string>, required: true },
isStyle: { type: Boolean as PropType<boolean>, default: false },
disabled: { type: Boolean as PropType<boolean>, default: false },
tooltip: { type: String as PropType<string | undefined>, required: false },
},
data() {
return {

View file

@ -5,6 +5,8 @@
:label="label"
:spellcheck="false"
:disabled="disabled"
:style="minWidth > 0 ? `min-width: ${minWidth}px` : ''"
:tooltip="tooltip"
@textFocused="() => onTextFocused()"
@textChanged="() => onTextChanged()"
@cancelTextChange="() => onCancelTextChange()"
@ -107,6 +109,8 @@ export default defineComponent({
incrementBehavior: { type: String as PropType<IncrementBehavior>, default: "Add" },
incrementFactor: { type: Number as PropType<number>, default: 1 },
disabled: { type: Boolean as PropType<boolean>, default: false },
minWidth: { type: Number as PropType<number>, default: 0 },
tooltip: { type: String as PropType<string | undefined>, required: false },
// Callbacks
incrementCallbackIncrease: { type: Function as PropType<() => void>, required: false },
@ -122,7 +126,7 @@ export default defineComponent({
onTextFocused() {
if (this.value === undefined) this.text = "";
else if (this.unitIsHiddenWhenEditing) this.text = `${this.value}`;
else this.text = `${this.value}${this.unit}`;
else this.text = `${this.value}${unPluralize(this.unit, this.value)}`;
this.editing = true;
@ -201,7 +205,7 @@ export default defineComponent({
const displayValue = Math.round(value * roundingPower) / roundingPower;
return `${displayValue}${this.unit}`;
return `${displayValue}${unPluralize(this.unit, value)}`;
},
},
watch: {
@ -222,4 +226,9 @@ export default defineComponent({
},
components: { FieldInput },
});
function unPluralize(unit: string, value: number): string {
if (value === 1 && unit.endsWith("s")) return unit.slice(0, -1);
return unit;
}
</script>

View file

@ -3,10 +3,11 @@
:textarea="true"
class="text-area-input"
:class="{ 'has-label': label }"
v-model:value="inputValue"
:label="label"
:spellcheck="true"
:disabled="disabled"
:tooltip="tooltip"
v-model:value="inputValue"
@textFocused="() => onTextFocused()"
@textChanged="() => onTextChanged()"
@cancelTextChange="() => onCancelTextChange()"
@ -27,6 +28,7 @@ export default defineComponent({
value: { type: String as PropType<string>, required: true },
label: { type: String as PropType<string>, required: false },
disabled: { type: Boolean as PropType<boolean>, default: false },
tooltip: { type: String as PropType<string | undefined>, required: false },
},
data() {
return {

View file

@ -5,6 +5,8 @@
:label="label"
:spellcheck="true"
:disabled="disabled"
:tooltip="tooltip"
:style="minWidth > 0 ? `min-width: ${minWidth}px` : ''"
@textFocused="() => onTextFocused()"
@textChanged="() => onTextChanged()"
@cancelTextChange="() => onCancelTextChange()"
@ -31,6 +33,8 @@ export default defineComponent({
value: { type: String as PropType<string>, required: true },
label: { type: String as PropType<string>, required: false },
disabled: { type: Boolean as PropType<boolean>, default: false },
minWidth: { type: Number as PropType<number>, default: 0 },
tooltip: { type: String as PropType<string | undefined>, required: false },
},
data() {
return {

View file

@ -1,5 +1,5 @@
<template>
<LayoutRow :class="['icon-label', iconSizeClass, iconStyleClass]">
<LayoutRow :class="['icon-label', iconSizeClass]" :title="tooltip">
<component :is="icon" />
</LayoutRow>
</template>
@ -23,35 +23,25 @@
width: 24px;
height: 24px;
}
&.node-style {
border-radius: 2px;
background: var(--color-node-background);
fill: var(--color-node-icon);
}
}
</style>
<script lang="ts">
import { defineComponent, type PropType } from "vue";
import { type IconName, type IconStyle, ICONS, ICON_COMPONENTS } from "@/utility-functions/icons";
import { type IconName, ICONS, ICON_COMPONENTS } from "@/utility-functions/icons";
import LayoutRow from "@/components/layout/LayoutRow.vue";
export default defineComponent({
props: {
icon: { type: String as PropType<IconName>, required: true },
iconStyle: { type: String as PropType<IconStyle | undefined>, required: false },
tooltip: { type: String as PropType<string | undefined>, required: false },
},
computed: {
iconSizeClass(): string {
return `size-${ICONS[this.icon].size}`;
},
iconStyleClass(): string {
if (!this.iconStyle || this.iconStyle === "Normal") return "";
return `${this.iconStyle.toLowerCase()}-style`;
},
},
components: {
LayoutRow,

View file

@ -1,5 +1,5 @@
<template>
<span class="text-label" :class="{ bold, italic, multiline, 'table-align': tableAlign }">
<span class="text-label" :class="{ bold, italic, multiline, 'table-align': tableAlign }" :style="minWidth > 0 ? `min-width: ${minWidth}px` : ''" :title="tooltip">
<slot></slot>
</span>
</template>
@ -37,7 +37,9 @@ export default defineComponent({
bold: { type: Boolean as PropType<boolean>, default: false },
italic: { type: Boolean as PropType<boolean>, default: false },
tableAlign: { type: Boolean as PropType<boolean>, default: false },
minWidth: { type: Number as PropType<number>, default: 0 },
multiline: { type: Boolean as PropType<boolean>, default: false },
tooltip: { type: String as PropType<string | undefined>, required: false },
},
});
</script>

View file

@ -3,15 +3,16 @@
<LayoutRow class="tab-bar" data-tab-bar :class="{ 'min-widths': tabMinWidths }">
<LayoutRow class="tab-group" :scrollableX="true">
<LayoutRow
class="tab"
:class="{ active: tabIndex === tabActiveIndex }"
data-tab
v-for="(tabLabel, tabIndex) in tabLabels"
:key="tabIndex"
class="tab"
:class="{ active: tabIndex === tabActiveIndex }"
:title="tabLabel.tooltip || null"
@click="(e: MouseEvent) => (e?.stopPropagation(), clickAction?.(tabIndex))"
@click.middle="(e: MouseEvent) => (e?.stopPropagation(), closeAction?.(tabIndex))"
data-tab
>
<span>{{ tabLabel }}</span>
<span>{{ tabLabel.name }}</span>
<IconButton :action="(e: MouseEvent) => (e?.stopPropagation(), closeAction?.(tabIndex))" :icon="'CloseX'" :size="16" v-if="tabCloseButtons" />
</LayoutRow>
</LayoutRow>
@ -31,7 +32,7 @@
<table>
<tr>
<td>
<TextButton :label="'New Document:'" :icon="'File'" :action="() => newDocument()" />
<TextButton :label="'New Document'" :icon="'File'" :action="() => newDocument()" />
</td>
<td>
<UserInputLabel :keysWithLabelsGroups="[[...platformModifiers(true), { key: 'KeyN', label: 'N' }]]" />
@ -39,7 +40,7 @@
</tr>
<tr>
<td>
<TextButton :label="'Open Document:'" :icon="'Folder'" :action="() => openDocument()" />
<TextButton :label="'Open Document'" :icon="'Folder'" :action="() => openDocument()" />
</td>
<td>
<UserInputLabel :keysWithLabelsGroups="[[...platformModifiers(false), { key: 'KeyO', label: 'O' }]]" />
@ -244,7 +245,7 @@ export default defineComponent({
props: {
tabMinWidths: { type: Boolean as PropType<boolean>, default: false },
tabCloseButtons: { type: Boolean as PropType<boolean>, default: false },
tabLabels: { type: Array as PropType<string[]>, required: true },
tabLabels: { type: Array as PropType<{ name: string; tooltip?: string }[]>, required: true },
tabActiveIndex: { type: Number as PropType<number>, required: true },
panelType: { type: String as PropType<PanelTypes>, required: false },
clickAction: { type: Function as PropType<(index: number) => void>, required: false },

View file

@ -7,7 +7,7 @@
:panelType="portfolio.state.documents.length > 0 ? 'Document' : undefined"
:tabCloseButtons="true"
:tabMinWidths="true"
:tabLabels="portfolio.state.documents.map((doc) => doc.displayName)"
:tabLabels="portfolio.state.documents.map((doc) => ({ name: doc.displayName, tooltip: doc.id }))"
:clickAction="(tabIndex: number) => editor.instance.selectDocument(portfolio.state.documents[tabIndex].id)"
:closeAction="(tabIndex: number) => editor.instance.closeDocumentWithConfirmation(portfolio.state.documents[tabIndex].id)"
:tabActiveIndex="portfolio.state.activeDocumentIndex"
@ -16,17 +16,17 @@
</LayoutRow>
<LayoutRow class="workspace-grid-resize-gutter" @pointerdown="(e: PointerEvent) => resizePanel(e)" v-if="nodeGraphVisible"></LayoutRow>
<LayoutRow class="workspace-grid-subdivision" v-if="nodeGraphVisible">
<Panel :panelType="'NodeGraph'" :tabLabels="['Node Graph']" :tabActiveIndex="0" />
<Panel :panelType="'NodeGraph'" :tabLabels="[{ name: 'Node Graph' }]" :tabActiveIndex="0" />
</LayoutRow>
</LayoutCol>
<LayoutCol class="workspace-grid-resize-gutter" @pointerdown="(e: PointerEvent) => resizePanel(e)"></LayoutCol>
<LayoutCol class="workspace-grid-subdivision" style="flex-grow: 0.17">
<LayoutCol class="workspace-grid-subdivision" style="flex-grow: 0.2">
<LayoutRow class="workspace-grid-subdivision" style="flex-grow: 402">
<Panel :panelType="'Properties'" :tabLabels="['Properties']" :tabActiveIndex="0" />
<Panel :panelType="'Properties'" :tabLabels="[{ name: 'Properties' }]" :tabActiveIndex="0" />
</LayoutRow>
<LayoutRow class="workspace-grid-resize-gutter" @pointerdown="(e: PointerEvent) => resizePanel(e)"></LayoutRow>
<LayoutRow class="workspace-grid-subdivision" style="flex-grow: 590">
<Panel :panelType="'LayerTree'" :tabLabels="['Layer Tree']" :tabActiveIndex="0" />
<Panel :panelType="'LayerTree'" :tabLabels="[{ name: 'Layer Tree' }]" :tabActiveIndex="0" />
</LayoutRow>
</LayoutCol>
</LayoutRow>

View file

@ -1,20 +0,0 @@
import { type Editor } from "@/wasm-communication/editor";
import { UpdateImageData } from "@/wasm-communication/messages";
export function createBlobManager(editor: Editor): void {
// Subscribe to process backend event
editor.subscriptions.subscribeJsMessage(UpdateImageData, (updateImageData) => {
updateImageData.imageData.forEach(async (element) => {
// Using updateImageData.imageData.buffer returns undefined for some reason?
const buffer = new Uint8Array(element.imageData.values()).buffer;
const blob = new Blob([buffer], { type: element.mime });
// TODO: Call `URL.revokeObjectURL` at the appropriate time to avoid a memory leak
const blobURL = URL.createObjectURL(blob);
const image = await createImageBitmap(blob);
editor.instance.setImageBlobUrl(element.path, blobURL, image.width, image.height);
});
});
}

View file

@ -25,8 +25,8 @@ export function createPanicManager(editor: Editor, dialogState: DialogState): vo
function preparePanicDialog(header: string, details: string, panicDetails: string): [IconName, WidgetLayout, TextButtonWidget[]] {
const widgets: WidgetLayout = {
layout: [
{ rowWidgets: [new Widget({ kind: "TextLabel", value: header, bold: true, italic: false, tableAlign: false, multiline: false }, 0n)] },
{ rowWidgets: [new Widget({ kind: "TextLabel", value: details, bold: false, italic: false, tableAlign: false, multiline: true }, 1n)] },
{ rowWidgets: [new Widget({ kind: "TextLabel", value: header, bold: true, italic: false, tableAlign: false, minWidth: 0, multiline: false, tooltip: "" }, 0n)] },
{ rowWidgets: [new Widget({ kind: "TextLabel", value: details, bold: false, italic: false, tableAlign: false, minWidth: 0, multiline: true, tooltip: "" }, 1n)] },
],
layoutTarget: undefined,
};

View file

@ -1,99 +1,155 @@
import { type PortfolioState } from "@/state-providers/portfolio";
import { stripIndents } from "@/utility-functions/strip-indents";
import { type Editor } from "@/wasm-communication/editor";
import { TriggerIndexedDbWriteDocument, TriggerIndexedDbRemoveDocument } from "@/wasm-communication/messages";
import { TriggerIndexedDbWriteDocument, TriggerIndexedDbRemoveDocument, TriggerSavePreferences, TriggerLoadAutoSaveDocuments, TriggerLoadPreferences } from "@/wasm-communication/messages";
const GRAPHITE_INDEXED_DB_VERSION = 2;
const GRAPHITE_INDEXED_DB_NAME = "graphite-indexed-db";
const GRAPHITE_AUTO_SAVE_STORE = "auto-save-documents";
const GRAPHITE_AUTO_SAVE_STORE = { name: "auto-save-documents", keyPath: "details.id" };
const GRAPHITE_EDITOR_PREFERENCES_STORE = { name: "editor-preferences", keyPath: "key" };
const GRAPHITE_INDEXEDDB_STORES = [GRAPHITE_AUTO_SAVE_STORE, GRAPHITE_EDITOR_PREFERENCES_STORE];
const GRAPHITE_AUTO_SAVE_ORDER_KEY = "auto-save-documents-order";
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
export async function createPersistenceManager(editor: Editor, portfolio: PortfolioState): Promise<() => void> {
function storeDocumentOrder(): void {
// Make sure to store as string since JSON does not play nice with BigInt
const documentOrder = portfolio.state.documents.map((doc) => doc.id.toString());
window.localStorage.setItem(GRAPHITE_AUTO_SAVE_ORDER_KEY, JSON.stringify(documentOrder));
}
export function createPersistenceManager(editor: Editor, portfolio: PortfolioState): () => void {
async function initialize(): Promise<IDBDatabase> {
// Open the IndexedDB database connection and save it to this variable, which is a promise that resolves once the connection is open
return new Promise<IDBDatabase>((resolve) => {
const dbOpenRequest = indexedDB.open(GRAPHITE_INDEXED_DB_NAME, GRAPHITE_INDEXED_DB_VERSION);
async function removeDocument(id: string): Promise<void> {
const db = await databaseConnection;
const transaction = db.transaction(GRAPHITE_AUTO_SAVE_STORE, "readwrite");
transaction.objectStore(GRAPHITE_AUTO_SAVE_STORE).delete(id);
storeDocumentOrder();
}
// Handle a version mismatch if `GRAPHITE_INDEXED_DB_VERSION` is now higher than what was saved in the database
dbOpenRequest.onupgradeneeded = (): void => {
const db = dbOpenRequest.result;
async function closeDatabaseConnection(): Promise<void> {
const db = await databaseConnection;
db.close();
}
// Wipe out all stores when a request is made to upgrade the database version to a newer one
GRAPHITE_INDEXEDDB_STORES.forEach((store) => {
if (db.objectStoreNames.contains(store.name)) db.deleteObjectStore(store.name);
// Subscribe to process backend events
editor.subscriptions.subscribeJsMessage(TriggerIndexedDbWriteDocument, async (autoSaveDocument) => {
const db = await databaseConnection;
const transaction = db.transaction(GRAPHITE_AUTO_SAVE_STORE, "readwrite");
transaction.objectStore(GRAPHITE_AUTO_SAVE_STORE).put(autoSaveDocument);
storeDocumentOrder();
});
editor.subscriptions.subscribeJsMessage(TriggerIndexedDbRemoveDocument, async (removeAutoSaveDocument) => {
removeDocument(removeAutoSaveDocument.documentId);
});
db.createObjectStore(store.name, { keyPath: store.keyPath });
});
};
// Open the IndexedDB database connection and save it to this variable, which is a promise that resolves once the connection is open
const databaseConnection = new Promise<IDBDatabase>((resolve) => {
const dbOpenRequest = indexedDB.open(GRAPHITE_INDEXED_DB_NAME, GRAPHITE_INDEXED_DB_VERSION);
dbOpenRequest.onupgradeneeded = (): void => {
const db = dbOpenRequest.result;
// Wipes out all auto-save data on upgrade
if (db.objectStoreNames.contains(GRAPHITE_AUTO_SAVE_STORE)) {
db.deleteObjectStore(GRAPHITE_AUTO_SAVE_STORE);
}
db.createObjectStore(GRAPHITE_AUTO_SAVE_STORE, { keyPath: "details.id" });
};
dbOpenRequest.onerror = (): void => {
const errorText = stripIndents`
// Handle some other error by presenting it to the user
dbOpenRequest.onerror = (): void => {
const errorText = stripIndents`
Documents won't be saved across reloads and later visits.
This may be caused by Firefox's private browsing mode.
Error on opening IndexDB:
${dbOpenRequest.error}
`;
editor.instance.errorDialog("Document auto-save doesn't work in this browser", errorText);
};
editor.instance.errorDialog("Document auto-save doesn't work in this browser", errorText);
};
dbOpenRequest.onsuccess = (): void => {
resolve(dbOpenRequest.result);
};
});
databaseConnection.then(async (db) => {
// Open auto-save documents
const transaction = db.transaction(GRAPHITE_AUTO_SAVE_STORE, "readonly");
const request = transaction.objectStore(GRAPHITE_AUTO_SAVE_STORE).getAll();
await new Promise((resolve): void => {
request.onsuccess = (): void => {
const previouslySavedDocuments: TriggerIndexedDbWriteDocument[] = request.result;
const documentOrder: string[] = JSON.parse(window.localStorage.getItem(GRAPHITE_AUTO_SAVE_ORDER_KEY) || "[]");
const orderedSavedDocuments = documentOrder
.map((id) => previouslySavedDocuments.find((autoSave) => autoSave.details.id === id))
.filter((x) => x !== undefined) as TriggerIndexedDbWriteDocument[];
const currentDocumentVersion = editor.instance.graphiteDocumentVersion();
orderedSavedDocuments.forEach((doc: TriggerIndexedDbWriteDocument) => {
if (doc.version === currentDocumentVersion) {
editor.instance.openAutoSavedDocument(BigInt(doc.details.id), doc.details.name, doc.details.isSaved, doc.document);
} else {
removeDocument(doc.details.id);
}
});
resolve(undefined);
// Resolve the promise on a successful opening of the database connection
dbOpenRequest.onsuccess = (): void => {
resolve(dbOpenRequest.result);
};
});
}
function storeDocumentOrder(): void {
// Make sure to store as string since JSON does not play nice with BigInt
const documentOrder = portfolio.state.documents.map((doc) => doc.id.toString());
window.localStorage.setItem(GRAPHITE_AUTO_SAVE_ORDER_KEY, JSON.stringify(documentOrder));
}
async function removeDocument(id: string, db: IDBDatabase): Promise<void> {
const transaction = db.transaction(GRAPHITE_AUTO_SAVE_STORE.name, "readwrite");
transaction.objectStore(GRAPHITE_AUTO_SAVE_STORE.name).delete(id);
storeDocumentOrder();
}
async function loadAutoSaveDocuments(db: IDBDatabase): Promise<void> {
let promiseResolve: (value: void | PromiseLike<void>) => void;
const promise = new Promise<void>((resolve): void => {
promiseResolve = resolve;
});
// Open auto-save documents
const transaction = db.transaction(GRAPHITE_AUTO_SAVE_STORE.name, "readonly");
const request = transaction.objectStore(GRAPHITE_AUTO_SAVE_STORE.name).getAll();
request.onsuccess = (): void => {
const previouslySavedDocuments: TriggerIndexedDbWriteDocument[] = request.result;
const documentOrder: string[] = JSON.parse(window.localStorage.getItem(GRAPHITE_AUTO_SAVE_ORDER_KEY) || "[]");
const orderedSavedDocuments = documentOrder
.map((id) => previouslySavedDocuments.find((autoSave) => autoSave.details.id === id))
.filter((x) => x !== undefined) as TriggerIndexedDbWriteDocument[];
const currentDocumentVersion = editor.instance.graphiteDocumentVersion();
orderedSavedDocuments.forEach(async (doc: TriggerIndexedDbWriteDocument) => {
if (doc.version === currentDocumentVersion) {
editor.instance.openAutoSavedDocument(BigInt(doc.details.id), doc.details.name, doc.details.isSaved, doc.document);
} else {
await removeDocument(doc.details.id, db);
}
});
promiseResolve();
};
await promise;
}
async function loadPreferences(db: IDBDatabase): Promise<void> {
let promiseResolve: (value: void | PromiseLike<void>) => void;
const promise = new Promise<void>((resolve): void => {
promiseResolve = resolve;
});
// Open auto-save documents
const transaction = db.transaction(GRAPHITE_EDITOR_PREFERENCES_STORE.name, "readonly");
const request = transaction.objectStore(GRAPHITE_EDITOR_PREFERENCES_STORE.name).getAll();
request.onsuccess = (): void => {
const preferenceEntries: { key: string; value: unknown }[] = request.result;
const preferences: Record<string, unknown> = {};
preferenceEntries.forEach(({ key, value }) => {
preferences[key] = value;
});
editor.instance.loadPreferences(JSON.stringify(preferences));
promiseResolve();
};
await promise;
}
// Subscribe to process backend events
editor.subscriptions.subscribeJsMessage(TriggerIndexedDbWriteDocument, async (autoSaveDocument) => {
const transaction = (await databaseConnection).transaction(GRAPHITE_AUTO_SAVE_STORE.name, "readwrite");
transaction.objectStore(GRAPHITE_AUTO_SAVE_STORE.name).put(autoSaveDocument);
storeDocumentOrder();
});
editor.subscriptions.subscribeJsMessage(TriggerIndexedDbRemoveDocument, async (removeAutoSaveDocument) => {
await removeDocument(removeAutoSaveDocument.documentId, await databaseConnection);
});
editor.subscriptions.subscribeJsMessage(TriggerLoadAutoSaveDocuments, async () => {
await loadAutoSaveDocuments(await databaseConnection);
});
editor.subscriptions.subscribeJsMessage(TriggerSavePreferences, async (preferences) => {
Object.entries(preferences.preferences).forEach(async ([key, value]) => {
const storedObject = { key, value };
const transaction = (await databaseConnection).transaction(GRAPHITE_EDITOR_PREFERENCES_STORE.name, "readwrite");
transaction.objectStore(GRAPHITE_EDITOR_PREFERENCES_STORE.name).put(storedObject);
});
});
editor.subscriptions.subscribeJsMessage(TriggerLoadPreferences, async () => {
await loadPreferences(await databaseConnection);
});
return closeDatabaseConnection;
const databaseConnection = initialize();
// Destructor
return () => {
databaseConnection.then((connection) => connection.close());
};
}

View file

@ -9,7 +9,19 @@ import { initWasm } from "@/wasm-communication/editor";
import App from "@/App.vue";
// Browser app entry point
(async (): Promise<void> => {
// Confirm the browser is compatible before initializing the application
if (!checkBrowserCompatibility()) return;
// Initialize the WASM module for the editor backend
await initWasm();
// Initialize the Vue application
createApp(App).mount("#app");
})();
function checkBrowserCompatibility(): boolean {
if (!("BigUint64Array" in window)) {
const body = document.body;
const message = stripIndents`
@ -23,12 +35,9 @@ import App from "@/App.vue";
JavaScript API must be supported by the browser for Graphite to function.)</p>
`;
body.innerHTML = message + body.innerHTML;
return;
return false;
}
// Initialize the WASM module for the editor backend
await initWasm();
// Initialize the Vue application
createApp(App).mount("#app");
})();
return true;
}

View file

@ -2,6 +2,7 @@
import { reactive, readonly } from "vue";
import { downloadFileText, downloadFileBlob, upload } from "@/utility-functions/files";
import { imaginateGenerate, imaginateCheckConnection, imaginateTerminate } from "@/utility-functions/imaginate";
import { rasterizeSVG } from "@/utility-functions/rasterization";
import { type Editor } from "@/wasm-communication/editor";
import {
@ -10,8 +11,13 @@ import {
TriggerImport,
TriggerOpenDocument,
TriggerRasterDownload,
TriggerImaginateGenerate,
TriggerImaginateTerminate,
TriggerImaginateCheckServerStatus,
UpdateActiveDocument,
UpdateOpenDocumentsList,
UpdateImageData,
TriggerRevokeBlobUrl,
} from "@/wasm-communication/messages";
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
@ -55,6 +61,47 @@ export function createPortfolioState(editor: Editor) {
// Have the browser download the file to the user's disk
downloadFileBlob(name, blob);
});
editor.subscriptions.subscribeJsMessage(TriggerImaginateCheckServerStatus, async (triggerImaginateCheckServerStatus) => {
const { hostname } = triggerImaginateCheckServerStatus;
imaginateCheckConnection(hostname, editor);
});
editor.subscriptions.subscribeJsMessage(TriggerImaginateGenerate, async (triggerImaginateGenerate) => {
const { documentId, layerPath, hostname, refreshFrequency, baseImage, parameters } = triggerImaginateGenerate;
// Handle img2img mode
let image: Blob | undefined;
if (parameters.denoisingStrength !== undefined && baseImage !== undefined) {
// Rasterize the SVG to an image file
image = await rasterizeSVG(baseImage.svg, baseImage.size[0], baseImage.size[1], "image/png");
const blobURL = URL.createObjectURL(image);
editor.instance.setImaginateBlobURL(documentId, layerPath, blobURL, baseImage.size[0], baseImage.size[1]);
}
imaginateGenerate(parameters, image, hostname, refreshFrequency, documentId, layerPath, editor);
});
editor.subscriptions.subscribeJsMessage(TriggerImaginateTerminate, async (triggerImaginateTerminate) => {
const { documentId, layerPath, hostname } = triggerImaginateTerminate;
imaginateTerminate(hostname, documentId, layerPath, editor);
});
editor.subscriptions.subscribeJsMessage(UpdateImageData, (updateImageData) => {
updateImageData.imageData.forEach(async (element) => {
const buffer = new Uint8Array(element.imageData.values()).buffer;
const blob = new Blob([buffer], { type: element.mime });
const blobURL = URL.createObjectURL(blob);
const image = await createImageBitmap(blob);
editor.instance.setImageBlobURL(updateImageData.documentId, element.path, blobURL, image.width, image.height);
});
});
editor.subscriptions.subscribeJsMessage(TriggerRevokeBlobUrl, async (triggerRevokeBlobUrl) => {
URL.revokeObjectURL(triggerRevokeBlobUrl.url);
});
return {
state: readonly(state) as typeof state,

View file

@ -0,0 +1,14 @@
/* eslint-disable no-useless-escape */
/* eslint-disable quotes */
export function escapeJSON(str: string): string {
return str
.replace(/[\\]/g, "\\\\")
.replace(/[\"]/g, '\\"')
.replace(/[\/]/g, "\\/")
.replace(/[\b]/g, "\\b")
.replace(/[\f]/g, "\\f")
.replace(/[\n]/g, "\\n")
.replace(/[\r]/g, "\\r")
.replace(/[\t]/g, "\\t");
}

View file

@ -52,3 +52,31 @@ export async function upload<T extends "text" | "data">(acceptedExtensions: stri
}
export type UploadResult<T> = { filename: string; type: string; content: UploadResultType<T> };
type UploadResultType<T> = T extends "text" ? string : T extends "data" ? Uint8Array : never;
export function blobToBase64(blob: Blob): Promise<string> {
return new Promise((resolve) => {
const reader = new FileReader();
reader.onloadend = (): void => resolve(typeof reader.result === "string" ? reader.result : "");
reader.readAsDataURL(blob);
});
}
export async function replaceBlobURLsWithBase64(svg: string): Promise<string> {
const splitByBlobs = svg.split(/(?<=")(blob:.*?)(?=")/);
const onlyBlobs = splitByBlobs.filter((_, i) => i % 2 === 1);
const onlyBlobsConverted = onlyBlobs.map(async (blobURL) => {
const data = await fetch(blobURL);
const dataBlob = await data.blob();
return blobToBase64(dataBlob);
});
const base64Images = await Promise.all(onlyBlobsConverted);
const substituted = splitByBlobs.map((segment, i) => {
if (i % 2 === 0) return segment;
const blobsIndex = Math.floor(i / 2);
return base64Images[blobsIndex];
});
return substituted.join("");
}

View file

@ -101,6 +101,7 @@ import NodeColorCorrection from "@/../assets/icon-16px-solid/node-color-correcti
import NodeFolder from "@/../assets/icon-16px-solid/node-folder.svg";
import NodeGradient from "@/../assets/icon-16px-solid/node-gradient.svg";
import NodeImage from "@/../assets/icon-16px-solid/node-image.svg";
import NodeImaginate from "@/../assets/icon-16px-solid/node-imaginate.svg";
import NodeMagicWand from "@/../assets/icon-16px-solid/node-magic-wand.svg";
import NodeMask from "@/../assets/icon-16px-solid/node-mask.svg";
import NodeMotionBlur from "@/../assets/icon-16px-solid/node-motion-blur.svg";
@ -109,6 +110,12 @@ import NodeShape from "@/../assets/icon-16px-solid/node-shape.svg";
import NodeText from "@/../assets/icon-16px-solid/node-text.svg";
import NodeTransform from "@/../assets/icon-16px-solid/node-transform.svg";
import Paste from "@/../assets/icon-16px-solid/paste.svg";
import Random from "@/../assets/icon-16px-solid/random.svg";
import Regenerate from "@/../assets/icon-16px-solid/regenerate.svg";
import Reload from "@/../assets/icon-16px-solid/reload.svg";
import Rescale from "@/../assets/icon-16px-solid/rescale.svg";
import Reset from "@/../assets/icon-16px-solid/reset.svg";
import Settings from "@/../assets/icon-16px-solid/settings.svg";
import Trash from "@/../assets/icon-16px-solid/trash.svg";
import ViewModeNormal from "@/../assets/icon-16px-solid/view-mode-normal.svg";
import ViewModeOutline from "@/../assets/icon-16px-solid/view-mode-outline.svg";
@ -142,6 +149,7 @@ const SOLID_16PX = {
FlipVertical: { component: FlipVertical, size: 16 },
Folder: { component: Folder, size: 16 },
GraphiteLogo: { component: GraphiteLogo, size: 16 },
NodeImaginate: { component: NodeImaginate, size: 16 },
NodeArtboard: { component: NodeArtboard, size: 16 },
NodeBlur: { component: NodeBlur, size: 16 },
NodeBrushwork: { component: NodeBrushwork, size: 16 },
@ -157,6 +165,12 @@ const SOLID_16PX = {
NodeText: { component: NodeText, size: 16 },
NodeTransform: { component: NodeTransform, size: 16 },
Paste: { component: Paste, size: 16 },
Random: { component: Random, size: 16 },
Regenerate: { component: Regenerate, size: 16 },
Reload: { component: Reload, size: 16 },
Rescale: { component: Rescale, size: 16 },
Reset: { component: Reset, size: 16 },
Settings: { component: Settings, size: 16 },
Trash: { component: Trash, size: 16 },
ViewModeNormal: { component: ViewModeNormal, size: 16 },
ViewModeOutline: { component: ViewModeOutline, size: 16 },
@ -205,6 +219,7 @@ import RasterBrushTool from "@/../assets/icon-24px-two-tone/raster-brush-tool.sv
import RasterCloneTool from "@/../assets/icon-24px-two-tone/raster-clone-tool.svg";
import RasterDetailTool from "@/../assets/icon-24px-two-tone/raster-detail-tool.svg";
import RasterHealTool from "@/../assets/icon-24px-two-tone/raster-heal-tool.svg";
import RasterImaginateTool from "@/../assets/icon-24px-two-tone/raster-imaginate-tool.svg";
import RasterPatchTool from "@/../assets/icon-24px-two-tone/raster-patch-tool.svg";
import RasterRelightTool from "@/../assets/icon-24px-two-tone/raster-relight-tool.svg";
import VectorEllipseTool from "@/../assets/icon-24px-two-tone/vector-ellipse-tool.svg";
@ -220,10 +235,11 @@ import VectorTextTool from "@/../assets/icon-24px-two-tone/vector-text-tool.svg"
const TWO_TONE_24PX = {
GeneralArtboardTool: { component: GeneralArtboardTool, size: 24 },
GeneralEyedropperTool: { component: GeneralEyedropperTool, size: 24 },
GeneralNavigateTool: { component: GeneralNavigateTool, size: 24 },
GeneralSelectTool: { component: GeneralSelectTool, size: 24 },
GeneralFillTool: { component: GeneralFillTool, size: 24 },
GeneralGradientTool: { component: GeneralGradientTool, size: 24 },
GeneralNavigateTool: { component: GeneralNavigateTool, size: 24 },
GeneralSelectTool: { component: GeneralSelectTool, size: 24 },
RasterImaginateTool: { component: RasterImaginateTool, size: 24 },
RasterBrushTool: { component: RasterBrushTool, size: 24 },
RasterCloneTool: { component: RasterCloneTool, size: 24 },
RasterDetailTool: { component: RasterDetailTool, size: 24 },
@ -256,7 +272,6 @@ export const ICON_COMPONENTS = Object.fromEntries(Object.entries(ICONS).map(([na
export type IconName = keyof typeof ICONS;
export type IconSize = undefined | 12 | 16 | 24 | 32;
export type IconStyle = "Normal" | "Node";
// The following helper type declarations allow us to avoid manually maintaining the `IconName` type declaration as a string union paralleling the keys of the
// icon definitions. It lets TypeScript do that for us. Our goal is to define the big key-value pair of icons by constraining its values, but inferring its keys.

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

View file

@ -0,0 +1,33 @@
export type RequestResult = { body: string; status: number };
// Special implementation using the legacy XMLHttpRequest API that provides callbacks to get:
// - Calls with the percent progress uploading the request to the server
// - Calls when downloading the result from the server, after the server has begun streaming back the response data
// It returns a tuple of the promise as well as the XHR which can be used to call the `.abort()` method on it.
export function requestWithUploadDownloadProgress(
url: string,
method: "GET" | "HEAD" | "POST" | "PUT" | "DELETE" | "CONNECT" | "OPTIONS" | "TRACE" | "PATCH",
body: string,
uploadProgress: (progress: number) => void,
downloadOccurring: () => void
): [Promise<RequestResult>, XMLHttpRequest | undefined] {
let xhrValue: XMLHttpRequest | undefined;
const promise = new Promise<RequestResult>((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.upload.addEventListener("progress", (e) => uploadProgress(e.loaded / e.total));
xhr.addEventListener("progress", () => downloadOccurring());
xhr.addEventListener("load", () => resolve({ status: xhr.status, body: xhr.responseText }));
xhr.addEventListener("abort", () => resolve({ status: xhr.status, body: xhr.responseText }));
xhr.addEventListener("error", () => reject(new Error("Request error")));
xhr.open(method, url, true);
xhr.setRequestHeader("accept", "*/*");
xhr.setRequestHeader("accept-language", "en-US,en;q=0.9");
xhr.setRequestHeader("content-type", "application/json");
xhrValue = xhr;
xhr.send(body);
});
return [promise, xhrValue];
}

View file

@ -1,5 +1,7 @@
import { replaceBlobURLsWithBase64 } from "@/utility-functions/files";
// Rasterize the string of an SVG document at a given width and height and turn it into the blob data of an image file matching the given MIME type
export function rasterizeSVG(svg: string, width: number, height: number, mime: string, backgroundColor?: string): Promise<Blob> {
export async function rasterizeSVG(svg: string, width: number, height: number, mime: string, backgroundColor?: string): Promise<Blob> {
let promiseResolve: (value: Blob | PromiseLike<Blob>) => void | undefined;
let promiseReject: () => void | undefined;
const promise = new Promise<Blob>((resolve, reject) => {
@ -21,9 +23,12 @@ export function rasterizeSVG(svg: string, width: number, height: number, mime: s
context.fillRect(0, 0, width, height);
}
// This SVG rasterization scheme has the limitation that it cannot access blob URLs, so they must be inlined to base64 URLs
const svgWithBase64Images = await replaceBlobURLsWithBase64(svg);
// Create a blob URL for our SVG
const image = new Image();
const svgBlob = new Blob([svg], { type: "image/svg+xml;charset=utf-8" });
const svgBlob = new Blob([svgWithBase64Images], { type: "image/svg+xml;charset=utf-8" });
const url = URL.createObjectURL(svgBlob);
image.onload = (): void => {
// Draw our SVG to the canvas

View file

@ -2,7 +2,7 @@
import { Transform, Type, plainToClass } from "class-transformer";
import { type IconName, type IconSize, type IconStyle } from "@/utility-functions/icons";
import { type IconName, type IconSize } from "@/utility-functions/icons";
import { type WasmEditorInstance, type WasmRawInstance } from "@/wasm-communication/editor";
import type MenuList from "@/components/floating-menus/MenuList.vue";
@ -165,7 +165,8 @@ export class UpdateDocumentArtboards extends JsMessage {
readonly svg!: string;
}
const TupleToVec2 = Transform(({ value }: { value: [number, number] }) => ({ x: value[0], y: value[1] }));
const TupleToVec2 = Transform(({ value }: { value: [number, number] | undefined }) => (value === undefined ? undefined : { x: value[0], y: value[1] }));
const BigIntTupleToNumberTuple = Transform(({ value }: { value: [bigint, bigint] | undefined }) => (value === undefined ? undefined : [Number(value[0]), Number(value[1])]));
export type XY = { x: number; y: number };
@ -215,6 +216,10 @@ export class TriggerFileDownload extends JsMessage {
readonly name!: string;
}
export class TriggerLoadAutoSaveDocuments extends JsMessage {}
export class TriggerLoadPreferences extends JsMessage {}
export class TriggerOpenDocument extends JsMessage {}
export class TriggerImport extends JsMessage {}
@ -232,8 +237,73 @@ export class TriggerRasterDownload extends JsMessage {
readonly size!: XY;
}
export class TriggerImaginateCheckServerStatus extends JsMessage {
readonly hostname!: string;
}
export class TriggerImaginateGenerate extends JsMessage {
@Type(() => ImaginateGenerationParameters)
readonly parameters!: ImaginateGenerationParameters;
@Type(() => ImaginateBaseImage)
readonly baseImage!: ImaginateBaseImage | undefined;
readonly hostname!: string;
readonly refreshFrequency!: number;
readonly documentId!: bigint;
readonly layerPath!: BigUint64Array;
}
export class ImaginateBaseImage {
readonly svg!: string;
readonly size!: [number, number];
}
export class ImaginateGenerationParameters {
readonly seed!: number;
readonly samples!: number;
readonly samplingMethod!: string;
readonly denoisingStrength!: number | undefined;
readonly cfgScale!: number;
readonly prompt!: string;
readonly negativePrompt!: string;
@BigIntTupleToNumberTuple
readonly resolution!: [number, number];
readonly restoreFaces!: boolean;
readonly tiling!: boolean;
}
export class TriggerImaginateTerminate extends JsMessage {
readonly documentId!: bigint;
readonly layerPath!: BigUint64Array;
readonly hostname!: string;
}
export class TriggerRefreshBoundsOfViewports extends JsMessage {}
export class TriggerRevokeBlobUrl extends JsMessage {
readonly url!: string;
}
export class TriggerSavePreferences extends JsMessage {
readonly preferences!: Record<string, unknown>;
}
export class DocumentChanged extends JsMessage {}
export class UpdateDocumentLayerTreeStructure extends JsMessage {
@ -313,8 +383,10 @@ export class DisplayEditableTextbox extends JsMessage {
}
export class UpdateImageData extends JsMessage {
@Type(() => ImageData)
readonly imageData!: ImageData[];
readonly documentId!: bigint;
@Type(() => ImaginateImageData)
readonly imageData!: ImaginateImageData[];
}
export class DisplayRemoveEditableTextbox extends JsMessage {}
@ -327,7 +399,8 @@ export class UpdateDocumentLayerDetails extends JsMessage {
export class LayerPanelEntry {
name!: string;
tooltip!: string;
@Transform(({ value }: { value: string }) => (value.length > 0 ? value : undefined))
tooltip!: string | undefined;
visible!: boolean;
@ -348,9 +421,26 @@ export class LayerMetadata {
selected!: boolean;
}
export type LayerType = "Folder" | "Image" | "Shape" | "Text";
export type LayerType = "Imaginate" | "Folder" | "Image" | "Shape" | "Text";
export class ImageData {
export type LayerTypeData = {
name: string;
icon: IconName;
};
export function layerTypeData(layerType: LayerType): LayerTypeData | undefined {
const entries: Record<string, LayerTypeData> = {
Imaginate: { name: "Imaginate", icon: "NodeImaginate" },
Folder: { name: "Folder", icon: "NodeFolder" },
Image: { name: "Image", icon: "NodeImage" },
Shape: { name: "Shape", icon: "NodeShape" },
Text: { name: "Text", icon: "NodeText" },
};
return entries[layerType];
}
export class ImaginateImageData {
readonly path!: BigUint64Array;
readonly mime!: string;
@ -400,7 +490,8 @@ export class CheckboxInput extends WidgetProps {
icon!: IconName;
tooltip!: string;
@Transform(({ value }: { value: string }) => (value.length > 0 ? value : undefined))
tooltip!: string | undefined;
}
export class ColorInput extends WidgetProps {
@ -412,7 +503,8 @@ export class ColorInput extends WidgetProps {
disabled!: boolean;
tooltip!: string;
@Transform(({ value }: { value: string }) => (value.length > 0 ? value : undefined))
tooltip!: string | undefined;
}
type MenuEntryCommon = {
@ -435,6 +527,7 @@ export type MenuListEntry = MenuEntryCommon & {
shortcutRequiresLock?: boolean;
value?: string;
disabled?: boolean;
tooltip?: string;
font?: URL;
ref?: InstanceType<typeof MenuList>;
};
@ -449,6 +542,9 @@ export class DropdownInput extends WidgetProps {
interactive!: boolean;
disabled!: boolean;
@Transform(({ value }: { value: string }) => (value.length > 0 ? value : undefined))
tooltip!: string | undefined;
}
export class FontInput extends WidgetProps {
@ -459,6 +555,9 @@ export class FontInput extends WidgetProps {
isStyle!: boolean;
disabled!: boolean;
@Transform(({ value }: { value: string }) => (value.length > 0 ? value : undefined))
tooltip!: string | undefined;
}
export class IconButton extends WidgetProps {
@ -468,13 +567,15 @@ export class IconButton extends WidgetProps {
active!: boolean;
tooltip!: string;
@Transform(({ value }: { value: string }) => (value.length > 0 ? value : undefined))
tooltip!: string | undefined;
}
export class IconLabel extends WidgetProps {
icon!: IconName;
iconStyle!: IconStyle | undefined;
@Transform(({ value }: { value: string }) => (value.length > 0 ? value : undefined))
tooltip!: string | undefined;
}
export type IncrementBehavior = "Add" | "Multiply" | "Callback" | "None";
@ -501,6 +602,11 @@ export class NumberInput extends WidgetProps {
incrementFactor!: number;
disabled!: boolean;
minWidth!: number;
@Transform(({ value }: { value: string }) => (value.length > 0 ? value : undefined))
tooltip!: string | undefined;
}
export class OptionalInput extends WidgetProps {
@ -508,7 +614,8 @@ export class OptionalInput extends WidgetProps {
icon!: IconName;
tooltip!: string;
@Transform(({ value }: { value: string }) => (value.length > 0 ? value : undefined))
tooltip!: string | undefined;
}
export class PopoverButton extends WidgetProps {
@ -518,6 +625,9 @@ export class PopoverButton extends WidgetProps {
header!: string;
text!: string;
@Transform(({ value }: { value: string }) => (value.length > 0 ? value : undefined))
tooltip!: string | undefined;
}
export type RadioEntryData = {
@ -560,6 +670,9 @@ export class TextAreaInput extends WidgetProps {
label!: string | undefined;
disabled!: boolean;
@Transform(({ value }: { value: string }) => (value.length > 0 ? value : undefined))
tooltip!: string | undefined;
}
export class TextButton extends WidgetProps {
@ -572,6 +685,9 @@ export class TextButton extends WidgetProps {
minWidth!: number;
disabled!: boolean;
@Transform(({ value }: { value: string }) => (value.length > 0 ? value : undefined))
tooltip!: string | undefined;
}
export type TextButtonWidget = {
@ -585,6 +701,7 @@ export type TextButtonWidget = {
emphasized?: boolean;
minWidth?: number;
disabled?: boolean;
tooltip?: string;
// Callbacks
// `action` is used via `IconButtonWidget.callback`
@ -597,6 +714,11 @@ export class TextInput extends WidgetProps {
label!: string | undefined;
disabled!: boolean;
minWidth!: number;
@Transform(({ value }: { value: string }) => (value.length > 0 ? value : undefined))
tooltip!: string | undefined;
}
export class TextLabel extends WidgetProps {
@ -610,7 +732,12 @@ export class TextLabel extends WidgetProps {
tableAlign!: boolean;
minWidth!: number;
multiline!: boolean;
@Transform(({ value }: { value: string }) => (value.length > 0 ? value : undefined))
tooltip!: string | undefined;
}
export type PivotPosition = "None" | "TopLeft" | "TopCenter" | "TopRight" | "CenterLeft" | "Center" | "CenterRight" | "BottomLeft" | "BottomCenter" | "BottomRight";
@ -851,15 +978,22 @@ export const messageMakers: Record<string, MessageMaker> = {
DisplayEditableTextbox,
DisplayRemoveEditableTextbox,
TriggerAboutGraphiteLocalizedCommitDate,
TriggerOpenDocument,
TriggerImaginateCheckServerStatus,
TriggerImaginateGenerate,
TriggerImaginateTerminate,
TriggerFileDownload,
TriggerFontLoad,
TriggerImport,
TriggerIndexedDbRemoveDocument,
TriggerIndexedDbWriteDocument,
TriggerLoadAutoSaveDocuments,
TriggerLoadPreferences,
TriggerOpenDocument,
TriggerPaste,
TriggerRasterDownload,
TriggerRefreshBoundsOfViewports,
TriggerRevokeBlobUrl,
TriggerSavePreferences,
TriggerTextCommit,
TriggerTextCopy,
TriggerViewportResize,