Files
JaredTherriault-ComfyUI-JNodes/web/js/ImageDrawer/Contexts.js
T
JaredTherriault 34c1ce82dd Speed up loading of images in SubFolderExplorer contexts
-Speed up loading of images in SubFolderExplorer contexts like Input and Output (up to 6x!)
-Separate logic for loading images and gathering subdirectories
-Update some code to reflect the move to a unified usage of  "directory" rather than "folder" (WIP)
-Return more useful responses from python backend
2024-05-10 20:33:18 -07:00

762 lines
24 KiB
JavaScript

import { $el } from "/scripts/ui.js";
import { api } from "/scripts/api.js";
import * as ExtraNetworks from "./ExtraNetworks.js";
import * as ImageElements from "./ImageElements.js";
import * as Sorting from "./Sorting.js";
import { getCurrentContextName, getCurrentContextObject } from "./ContextSelector.js";
import { utilitiesInstance } from "../common/Utilities.js"
import {
createLabeledCheckboxToggle, createLabeledSliderRange, createVideoPlaybackOptionsFlyout,
options_LabeledCheckboxToggle, options_LabeledSliderRange, setting_ModelCardAspectRatio
} from "../common/SettingsManager.js";
import { imageDrawerComponentManagerInstance } from "./Core/ImageDrawerModule.js";
let Contexts;
export function initializeContexts() {
if (!Contexts) {
Contexts = {
feed: new ContextFeed(),
temp: new ContextTemp(),
input: new ContextInput(),
output: new ContextOutput(),
lora: new ContextLora(),
embeddings: new ContextEmbeddings(),
//savedPrompts: new ContextSavedPrompts(),
// metadata: new ContextMetadataReader(),
// compare: new ContextCompare(),
};
}
};
export function getContexts() {
return Contexts;
}
export function getContextObjectFromName(contextName) {
const contextValues = Object.values(Contexts);
let foundContext;
for (const context of contextValues) {
if (context.name == contextName) {
foundContext = context;
break;
}
}
if (foundContext) {
return foundContext;
} else {
console.error(`ImageDrawerContext with name '${contextName}' not found.`);
return null;
}
}
export class ImageDrawerContextCache {
constructor(scrollLevel, searchBarText, columnCount, drawerWidth, drawerHeight, imageListElements, sortType) {
this.scrollLevel = scrollLevel;
this.searchBarText = searchBarText;
this.columnCount = columnCount;
this.drawerWidth = drawerWidth;
this.drawerHeight = drawerHeight;
this.imageListElements = imageListElements;
this.sortType = sortType;
}
};
class ImageDrawerContext {
constructor(name, tooltip) {
this.name = name;
this.tooltip = tooltip;
this.cache = null;
}
hasCache() {
return this.cache != null;
}
setCache(newCache) {
if (!(newCache instanceof ImageDrawerContextCache)) {
console.error("Invalid cache type. Expected ImageDrawerContextCache.");
return;
}
this.cache = newCache;
}
reverseItemsInCache() {
if (this.cache && this.cache.imageListElements.length > 1) {
this.cache.imageListElements.reverse();
}
}
async switchToContext(bSkipRestore = false) {
const bSuccessfulRestore = bSkipRestore || await this.checkAndRestoreContextCache();
if (!bSuccessfulRestore) {
const imageDrawerSearchInstance = imageDrawerComponentManagerInstance.getComponentByName("ImageDrawerSearch");
imageDrawerSearchInstance.clearAndExecuteSearch(); // Reset search if no cache
}
const imageDrawerMainInstance = imageDrawerComponentManagerInstance.getComponentByName("ImageDrawerMain");
imageDrawerMainInstance.setContextToolbarWidget(await this.makeToolbar());
return bSuccessfulRestore;
}
async makeToolbar() {
return $el("div", { //Inner container so it can maintain 'flex' display attribute
style: {
alignItems: 'normal',
display: 'flex',
gap: '.5rem',
flex: '0 1 fit-content',
justifyContent: 'flex-end',
}
});
}
async checkAndRestoreContextCache() {
if (this.hasCache()) {
if (this.cache.imageListElements.length > 0) {
const imageDrawerListInstance = imageDrawerComponentManagerInstance.getComponentByName("ImageDrawerList");
const imageDrawerSearchInstance = imageDrawerComponentManagerInstance.getComponentByName("ImageDrawerSearch");
const imageDrawerMainInstance = imageDrawerComponentManagerInstance.getComponentByName("ImageDrawerMain");
// Replace children
imageDrawerListInstance.replaceImageListChildren(this.cache.imageListElements);
// Execute Search
imageDrawerSearchInstance.setSearchTextAndExecute(this.cache.searchBarText);
// Drawer column count and size
imageDrawerMainInstance.setColumnCount(this.cache.columnCount);
imageDrawerMainInstance.setDrawerWidth(this.cache.drawerWidth);
imageDrawerMainInstance.setDrawerHeight(this.cache.drawerHeight);
// Restore sort type
Sorting.setOptionSelectedFromOptionName(this.cache.sortType);
// Restore scroll level
imageDrawerListInstance.setImageListScrollLevel(this.cache.scrollLevel);
return true;
}
}
return false;
}
getSupportedSortTypes() {
return [Sorting.SortTypeFilename, Sorting.SortTypeDate];
}
getDesiredSortType() {
return this.cache?.sortType || this.getDefaultSortType();
}
getDefaultSortType() {
return { type: Sorting.SortTypeFilename, bIsAscending: true };
}
shouldCancelAsyncOperation() {
return getCurrentContextObject() != this; // By default, cancel async operations if the selected context has changed
}
}
class ContextClearable extends ImageDrawerContext {
async onClearClicked() { }
async makeToolbar() {
// Remove all images from the list
let clearButton = $el("button.JNodes-image-drawer-btn", {
textContent: "Clear",
onclick: async () => {
await this.onClearClicked();
clearButton.blur();
},
style: {
width: "fit-content",
padding: '3px',
},
});
const finalWidget = await super.makeToolbar();
finalWidget.appendChild(clearButton);
return finalWidget;
}
getSupportedSortTypes() {
const SortTypes = [Sorting.SortTypeFileSize, Sorting.SortTypeImageWidth, Sorting.SortTypeImageHeight, Sorting.SortTypeImageAspectRatio, Sorting.SortTypeFileType];
return super.getSupportedSortTypes().concat(SortTypes);
}
}
class ContextRefreshable extends ImageDrawerContext {
async onRefreshClicked() {
Sorting.sortWithCurrentType();
}
async makeToolbar() {
// Refresh button
let refreshButton = $el("button.JNodes-image-drawer-btn", {
textContent: "Refresh",
onclick: async () => {
await this.onRefreshClicked();
refreshButton.blur();
},
style: {
width: "fit-content",
padding: '3px',
},
});
const finalWidget = await super.makeToolbar();
finalWidget.appendChild(refreshButton);
return finalWidget;
}
}
class ContextModel extends ContextRefreshable {
constructor(name, description, type) {
super(name, description);
this.type = type;
}
async getModels(bForceRefresh = false) { }
async loadModels(bForceRefresh = false) {
const imageDrawerListInstance = imageDrawerComponentManagerInstance.getComponentByName("ImageDrawerList");
imageDrawerListInstance.clearImageListChildren();
await imageDrawerListInstance.addElementToImageList($el("label", { textContent: `Loading ${this.name}...` }));
let modelDicts = await this.getModels(bForceRefresh);
if (this.shouldCancelAsyncOperation()) { return; }
imageDrawerListInstance.clearImageListChildren(); // Remove loading indicator
//console.log("modelDicts: " + JSON.stringify(loraDicts));
const modelKeys = Object.keys(modelDicts);
if (modelKeys.length > 0) {
let count = 0;
let maxCount = 0;
imageDrawerListInstance.notifyStartChangingImageList();
for (const modelKey of modelKeys) {
if (this.shouldCancelAsyncOperation()) { break; }
if (maxCount > 0 && count > maxCount) { break; }
let element = await ExtraNetworks.createExtraNetworkCard(modelKey, modelDicts[modelKey], this.type);
if (element == undefined) {
console.log("Attempting to add undefined element for model named: " + modelKey + " with dict: " + JSON.stringify(modelDicts[modelKey]));
}
await imageDrawerListInstance.addElementToImageList(element);
count++;
}
imageDrawerListInstance.notifyFinishChangingImageList();
}
else {
imageDrawerListInstance.notifyStartChangingImageList();
await imageDrawerListInstance.addElementToImageList($el("label", { textContent: "No models were found." }));
imageDrawerListInstance.notifyFinishChangingImageList();
}
}
async switchToContext() {
if (!await super.switchToContext()) {
await this.loadModels();
}
}
async makeToolbar() {
const container = await super.makeToolbar();
let options = new options_LabeledSliderRange();
options.labelTextContent = 'Card Aspect Ratio:'
options.id = 'ModelCardAspectRatioSlider';
options.value = setting_ModelCardAspectRatio.value;
options.min = 0.1;
options.max = 2;
options.step = 0.01;
options.bIncludeValueLabel = true;
options.valueLabelFractionalDigits = 2;
options.oninput = async (e) => {
setting_ModelCardAspectRatio.value = e.target.valueAsNumber;
const imageDrawerListInstance = imageDrawerComponentManagerInstance.getComponentByName("ImageDrawerList");
for (let element of imageDrawerListInstance.getImageListChildren()) {
if (element.classList.contains('extraNetworksCard')) {
element.style.aspectRatio = setting_ModelCardAspectRatio.value;
}
}
};
container.insertBefore(createLabeledSliderRange(options), container.firstChild);
return container;
}
async onRefreshClicked() {
await this.loadModels(true);
super.onRefreshClicked();
}
getSupportedSortTypes() {
return super.getSupportedSortTypes().concat([Sorting.SortTypeFriendlyName]);
}
getDefaultSortType() {
return { type: Sorting.SortTypeFriendlyName, bIsAscending: true };
}
}
class ContextSubdirectoryExplorer extends ContextRefreshable {
constructor(name, description, directoryName, bShouldForceLoad = false) {
super(name, description);
this.rootDirectoryName = directoryName;
this.rootDirectoryDisplayName = '/root';
this.bIncludeSubdirectories = false;
this.fileList = null;
this.subdirectorySelector = null;
this.bShouldForceLoad = bShouldForceLoad; // Whether or not to lazy load. Lazy load = !bShouldForceLoad
}
async updateSubdirectorySelectorOptions() {
// Fill out combo box options based on paths
const lastSelectedValue = this.subdirectorySelector.value; // Cache last selection
this.subdirectorySelector.innerHTML = ""; // Clear combo box
// Indicate loading
const loadingIndicator = document.createElement("option");
loadingIndicator.value = "Loading...";
loadingIndicator.textContent = "Loading...";
loadingIndicator.title = "Loading...";
this.subdirectorySelector.appendChild(loadingIndicator);
this.subdirectorySelector.value = loadingIndicator.value;
const subdirectoriesResponse = await api.fetchApi(
`/jnodes_list_comfyui_subdirectories?root_directory=${this.rootDirectoryName}`, { method: "GET", cache: "no-store" });
let subdirectories;
try {
// Decode into a string
const decodedString = await utilitiesInstance.decodeReadableStream(subdirectoriesResponse.body);
const asJson = JSON.parse(decodedString);
if (asJson.success && asJson.payload) {
subdirectories = asJson.payload;
}
} catch (e) {
console.error(`Could not get list of files when loading "${this.rootDirectoryName}": ${e}`)
}
this.subdirectorySelector.innerHTML = ""; // Clear combo box
for (let directoryIndex = 0; directoryIndex < subdirectories.length; directoryIndex++) {
const bIsRoot = directoryIndex == 0;
const result = subdirectories[directoryIndex];
const option = document.createElement("option");
const path = bIsRoot ? this.rootDirectoryDisplayName : result;
option.value = bIsRoot ? '' : path;
option.textContent = path;
option.title = path;
this.subdirectorySelector.appendChild(option);
}
// Restore last selected value if it exists in the new combo options
for (let option of this.subdirectorySelector.options) {
// Check if the option's value is equal to the specific value
if (option.value === lastSelectedValue) {
// If found, set the flag to true and break out of the loop
this.subdirectorySelector.value = lastSelectedValue;
break;
}
}
}
// Get the image paths in the folder or directory specified at this.folderName
// as well as all subdirectories then load the images in a given subdirectory
async fetchFolderItems(selectedSubdirectory = "") {
const imageDrawerListInstance = imageDrawerComponentManagerInstance.getComponentByName("ImageDrawerList");
imageDrawerListInstance.clearImageListChildren();
const withOrWithout = this.bIncludeSubdirectories ? "with" : "without";
// todo: Python cancellation needs some work
// const cancelButton = $el("button.JNodes-image-drawer-btn", {
// textContent: 'Cancel',
// onclick: async () => {
// await api.fetchApi(
// '/jnodes_request_task_cancellation', { method: "POST" }); // Cancel any outstanding python task
// cancelButton.textContent = 'Canceling...';
// },
// style: {
// width: "fit-content",
// padding: '3px',
// },
// });
await imageDrawerListInstance.addElementToImageList(
$el('div', [
$el("label", {
textContent:
`Loading directory '${selectedSubdirectory || this.rootDirectoryName}' ${withOrWithout} subdirectories...`
}),
//cancelButton
])
);
await this.updateSubdirectorySelectorOptions();
const allItems = await api.fetchApi(
'/jnodes_get_comfyui_subdirectory_images' +
`?root_directory=${this.rootDirectoryName}` +
`&selected_subdirectory=${selectedSubdirectory}` +
`&recursive=${this.bIncludeSubdirectories}`, { cache: "no-store" });
imageDrawerListInstance.clearImageListChildren();
let decodedString;
try {
// Decode into a string
decodedString = await utilitiesInstance.decodeReadableStream(allItems.body);
const asJson = JSON.parse(decodedString);
this.fileList = asJson.payload;
} catch (e) {
console.error(`Could not get list of images when loading "${this.rootDirectoryName}": ${e}`)
this.fileList = []; // Set an empty list on failure. This allows the function to complete without further failures.
}
// Load root folder if no path is specified (even if there are no images within)
await this.loadImagesInFolder(selectedSubdirectory);
}
async loadImagesInFolder(selectedSubdirectory) {
const imageDrawerListInstance = imageDrawerComponentManagerInstance.getComponentByName("ImageDrawerList");
if (!this.fileList || this.fileList.length == 0) {
let directory = this.rootDirectoryDisplayName;
if (selectedSubdirectory) {
directory += `/${selectedSubdirectory}`;
}
if (file.subdirectory) {
directory += `/${file.subdirectory}`;
}
await imageDrawerListInstance.addElementToImageList(
$el('div', [
$el("label", {
textContent:
`No images or videos found in '${directory}'`
}),
//cancelButton
])
);
return; }
if (this.shouldCancelAsyncOperation()) { return; }
const createElementFromFile = async (file) => {
let element = await ImageElements.createImageElementFromFileInfo({
filename: file.item,
file: file,
type: this.rootDirectoryName,
subdirectory: file.subdirectory ? `${selectedSubdirectory}/${file.subdirectory}` : selectedSubdirectory,
bShouldForceLoad: this.bShouldForceLoad,
bShouldSort: false,
bShouldApplySearch: false,
});
if (element !== undefined) {
await imageDrawerListInstance.addElementToImageList(element);
} else {
console.log(`Attempted to add undefined image element in ${this.name}`);
}
};
const bUseBatching = false;
if (bUseBatching) {
const promises = [];
for (let valueIndex = 0; valueIndex < this.fileList.length; valueIndex++) {
if (this.shouldCancelAsyncOperation()) { break; }
const value = this.fileList[valueIndex];
const processBatch = async (fileBatch) => {
let processedElements = [];
const promises = fileBatch.map(async (file) => {
const element = await createElementFromFile(file, value);
if (element) {
processedElements.push(element);
}
});
await Promise.all(promises);
await Promise.all(processedElements.map(img => new Promise(resolve => {
if (img.complete) {
resolve();
} else {
img.onload = resolve;
}
})));
}
async function processFilesInBatches(files, batchSize, delayBetweenBatches) {
const batchPromises = [];
imageDrawerListInstance.notifyStartChangingImageList();
for (let i = 0; i < files.length; i += batchSize) {
const fileBatch = files.slice(i, i + batchSize);
batchPromises.push(processBatch(fileBatch));
await new Promise(resolve => setTimeout(resolve, delayBetweenBatches));
}
imageDrawerListInstance.notifyFinishChangingImageList();
return Promise.all(batchPromises);
}
const batchSize = 4;
const delayBetweenBatches = 0.1;
promises.push(processFilesInBatches(value.files, batchSize, delayBetweenBatches));
}
await Promise.all(promises);
} else {
imageDrawerListInstance.notifyStartChangingImageList();
for (let fileIndex = 0; fileIndex < this.fileList.length; fileIndex++) {
if (this.shouldCancelAsyncOperation()) { break; }
const file = this.fileList[fileIndex];
await createElementFromFile(file);
}
imageDrawerListInstance.notifyFinishChangingImageList();
}
Sorting.sortWithCurrentType();
}
async makeToolbar() {
const container = await super.makeToolbar();
const self = this;
const flyoutHandle = createVideoPlaybackOptionsFlyout().handle;
container.insertBefore(flyoutHandle, container.firstChild);
flyoutHandle.determineTransformLayout(); // Call immediately after parenting to avoid first caling being from the center
let includeSubdirectoriesToggleOptions = new options_LabeledCheckboxToggle();
includeSubdirectoriesToggleOptions.labelTextContent = 'Include Subdirectories';
includeSubdirectoriesToggleOptions.id = 'IncludeSubdirectoriesToggle';
includeSubdirectoriesToggleOptions.checked = self.bIncludeSubdirectories;
includeSubdirectoriesToggleOptions.oninput = async (e) => {
self.bIncludeSubdirectories = e.target.checked;
await self.fetchFolderItems(self.subdirectorySelector.value);
}
const IncludeSubdirectoriesToggle = createLabeledCheckboxToggle(includeSubdirectoriesToggleOptions);
container.insertBefore(IncludeSubdirectoriesToggle, container.firstChild);
if (!this.subdirectorySelector) {
this.subdirectorySelector = $el("select", { //Inner container so it can maintain 'flex' display attribute
style: {
width: '100%',
}
});
}
this.subdirectorySelector.addEventListener("change", async function () {
// Force subdirectory inclusion off to avoid OOM - user must opt-in explicitly each time
self.bIncludeSubdirectories = false;
IncludeSubdirectoriesToggle.getMainElement().checked = false;
const selectedValue = this.value;
// await api.fetchApi(
// '/jnodes_request_task_cancellation', { method: "POST" }); // Cancel any outstanding python task
await self.fetchFolderItems(selectedValue);
});
container.insertBefore(this.subdirectorySelector, container.firstChild);
return container;
}
async switchToContext() {
if (!await super.switchToContext()) {
await this.fetchFolderItems(); // updateSubdirectorySelector is called in fetchFolderItems
return;
}
await this.updateSubdirectorySelectorOptions(); // If we're not calling fetchFolderItems because we're restoring a cache, call updateSubdirectorySelector
}
async onRefreshClicked() {
await this.fetchFolderItems(this.subdirectorySelector.value);
await super.onRefreshClicked();
}
getSupportedSortTypes() {
const SortTypes = [Sorting.SortTypeFileSize, Sorting.SortTypeImageWidth, Sorting.SortTypeImageHeight, Sorting.SortTypeImageAspectRatio, Sorting.SortTypeFileType];
return super.getSupportedSortTypes().concat(SortTypes);
}
}
export class ContextFeed extends ContextClearable {
constructor() {
super("Feed", "The latest generations from this web session (cleared on page refresh)");
this.feedImages = [];
// Automatically update feed if it's the active context
api.addEventListener("executed", async ({ detail }) => {
const outImages = detail?.output?.images;
if (outImages) {
const node = app.graph.getNodeById(detail.node);
if (node.type == "PreviewImage") { return; } // todo: Make this configurable
for (const src of outImages) {
// Always add feed images to the record, but only add thumbs to the imageList if
// we're currently in feed mode. Otherwise they'll be added when switching to feed.
src.file_age = Date.now(); // Get time of creation since the last epoch, in milliseconds. For sorting.
this.feedImages.push(src);
}
if (getCurrentContextName() == this.name) {
await this.addNewUncachedFeedImages();
}
}
});
}
async addNewUncachedFeedImages(bShouldSort = true, bShouldApplySearch = true) {
const imageDrawerListInstance = imageDrawerComponentManagerInstance.getComponentByName("ImageDrawerList");
const imageListLength = imageDrawerListInstance.getImageListChildren().length;
if (imageListLength < this.feedImages.length) {
imageDrawerListInstance.notifyStartChangingImageList();
for (let imageIndex = imageListLength; imageIndex < this.feedImages.length; imageIndex++) {
if (this.shouldCancelAsyncOperation()) { break; }
let fileInfo = this.feedImages[imageIndex];
fileInfo.bShouldForceLoad = true; // Don't lazy load
fileInfo.bShouldSort = bShouldSort; // Optionally apply sort after image load
fileInfo.bShouldApplySearch = bShouldApplySearch; // Optionally apply search after image load
const element = await ImageElements.createImageElementFromFileInfo(fileInfo);
if (element == undefined) { console.log(`Attempting to add undefined image element in ${this.name}`); }
const bHandleSearch = false;
await imageDrawerListInstance.addElementToImageList(element, bHandleSearch);
}
imageDrawerListInstance.notifyFinishChangingImageList();
}
}
async switchToContext() {
if (!await super.switchToContext()) {
const imageDrawerListInstance = imageDrawerComponentManagerInstance.getComponentByName("ImageDrawerList");
imageDrawerListInstance.clearImageListChildren();
}
await this.addNewUncachedFeedImages(false);
}
async onClearClicked() {
const imageDrawerListInstance = imageDrawerComponentManagerInstance.getComponentByName("ImageDrawerList");
imageDrawerListInstance.clearImageListChildren();
this.feedImages = [];
}
getDefaultSortType() {
return { type: Sorting.SortTypeDate, bIsAscending: false };
}
}
export class ContextTemp extends ContextSubdirectoryExplorer {
constructor() {
const bShouldForceLoad = true; // These need to be searchable by meta data
super("Temp / History", "The generations you've created since the last comfyUI server restart", "temp", bShouldForceLoad);
}
getDefaultSortType() {
return { type: Sorting.SortTypeDate, bIsAscending: false };
}
}
export class ContextInput extends ContextSubdirectoryExplorer {
constructor() {
super("Input", "Images and videos found in your input folder", "input");
}
}
export class ContextOutput extends ContextSubdirectoryExplorer {
constructor() {
const bShouldForceLoad = true; // These need to be searchable by meta data
super("Output", "Images and videos found in your output folder", "output", bShouldForceLoad);
}
}
export class ContextLora extends ContextModel {
constructor() {
super("Lora / Lycoris", "Lora and Lycoris models found in your Lora directory", "loras");
}
async getModels(bForceRefresh = false) {
return await ExtraNetworks.getLoras(bForceRefresh);
}
}
export class ContextEmbeddings extends ContextModel {
constructor() {
super("Embeddings / Textual Inversions", "Embedding/textual inversion models found in your embeddings directory", "embeddings");
}
async getModels(bForceRefresh = false) {
return await ExtraNetworks.getEmbeddings(bForceRefresh);
}
}
export class ContextSavedPrompts extends ContextSubdirectoryExplorer {
constructor() {
super(
"Saved Prompts",
"Images and videos found in the JNodes/saved_prompts folder and its subdirectories. Title comes from filename",
"JNodes/saved_prompts");
}
}
export class ContextMetadataReader extends ImageDrawerContext {
constructor() {
super("Metadata Reader", "Read and display metadata from a generation");
}
getSupportedSortNames() {
return [];
}
}
export class ContextCompare extends ContextClearable {
constructor() {
super("Compare", "Compare generations sent to this context via menu. Does not persist on refresh.");
}
async switchToContext() {
if (!await super.switchToContext()) {
const imageDrawerListInstance = imageDrawerComponentManagerInstance.getComponentByName("ImageDrawerList");
imageDrawerListInstance.clearImageListChildren();
}
//await this.addNewUncachedFeedImages();
}
async onClearClicked() {
}
getSupportedSortNames() {
return [];
}
}