5885 lines
180 KiB
JavaScript
5885 lines
180 KiB
JavaScript
import { app } from '../../scripts/app.js';
|
||
import { api } from '../../scripts/api.js';
|
||
import { ComfyDialog, $el } from '../../scripts/ui.js';
|
||
import { ComfyButton } from '../../scripts/ui/components/button.js';
|
||
import { marked } from './marked.js';
|
||
import('./downshow.js');
|
||
|
||
function clamp(x, min, max) {
|
||
return Math.min(Math.max(x, min), max);
|
||
}
|
||
|
||
/**
|
||
* @param {string} url
|
||
* @param {any} [options=undefined]
|
||
* @returns {Promise}
|
||
*/
|
||
function comfyRequest(url, options = undefined) {
|
||
return new Promise((resolve, reject) => {
|
||
api
|
||
.fetchApi(url, options)
|
||
.then((response) => response.json())
|
||
.then(resolve)
|
||
.catch(reject);
|
||
});
|
||
}
|
||
|
||
/**
|
||
* @param {(...args) => Promise<void>} callback
|
||
* @param {number | undefined} delay
|
||
* @returns {(...args) => void}
|
||
*/
|
||
function debounce(callback, delay) {
|
||
let timeoutId = null;
|
||
return (...args) => {
|
||
window.clearTimeout(timeoutId);
|
||
timeoutId = window.setTimeout(() => {
|
||
callback(...args);
|
||
}, delay);
|
||
};
|
||
}
|
||
|
||
class KeyComboListener {
|
||
/** @type {string[]} */
|
||
#keyCodes = [];
|
||
|
||
/** @type {() => Promise<void>} */
|
||
action;
|
||
|
||
/** @type {Element} */
|
||
element;
|
||
|
||
/** @type {string[]} */
|
||
#combo = [];
|
||
|
||
/**
|
||
* @param {string[]} keyCodes
|
||
* @param {() => Promise<void>} action
|
||
* @param {Element} element
|
||
*/
|
||
constructor(keyCodes, action, element) {
|
||
this.#keyCodes = keyCodes;
|
||
this.action = action;
|
||
this.element = element;
|
||
|
||
document.addEventListener('keydown', (e) => {
|
||
const code = e.code;
|
||
const keyCodes = this.#keyCodes;
|
||
const combo = this.#combo;
|
||
if (keyCodes.includes(code) && !combo.includes(code)) {
|
||
combo.push(code);
|
||
}
|
||
if (combo.length === 0 || keyCodes.length !== combo.length) {
|
||
return;
|
||
}
|
||
for (let i = 0; i < combo.length; i++) {
|
||
if (keyCodes[i] !== combo[i]) {
|
||
return;
|
||
}
|
||
}
|
||
if (document.activeElement !== this.element) {
|
||
return;
|
||
}
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
this.action();
|
||
this.#combo.length = 0;
|
||
});
|
||
document.addEventListener('keyup', (e) => {
|
||
// Mac keyup doesn't fire when meta key is held: https://stackoverflow.com/a/73419500
|
||
const code = e.code;
|
||
if (code === 'MetaLeft' || code === 'MetaRight') {
|
||
this.#combo.length = 0;
|
||
} else {
|
||
this.#combo = this.#combo.filter((x) => x !== code);
|
||
}
|
||
});
|
||
}
|
||
}
|
||
|
||
// This is used in Firefox to bypass the ‘dragend’ event because it returns incorrect ‘clientX’ and ‘clientY’
|
||
const IS_FIREFOX = navigator.userAgent.indexOf('Firefox') > -1;
|
||
|
||
/**
|
||
* @param {string} url
|
||
*/
|
||
async function loadWorkflow(url) {
|
||
const fileNameIndex = Math.max(url.lastIndexOf('/'), url.lastIndexOf('\\')) + 1;
|
||
const fileName = url.substring(fileNameIndex);
|
||
const response = await fetch(url);
|
||
const data = await response.blob();
|
||
const file = new File([data], fileName, { type: data.type });
|
||
app.handleFile(file);
|
||
}
|
||
|
||
/**
|
||
* @param {string} modelSearchPath
|
||
* @returns {Promise<string|undefined>}
|
||
*/
|
||
async function tryGetModelWebUrl(modelSearchPath) {
|
||
const encodedPath = encodeURIComponent(modelSearchPath);
|
||
const response = await comfyRequest(`/model-manager/model/web-url?path=${encodedPath}`);
|
||
const url = response.url;
|
||
return url !== undefined && url !== '' ? url : undefined;
|
||
}
|
||
|
||
/**
|
||
* @param {string} url
|
||
* @param {string} name
|
||
* @returns {boolean}
|
||
*/
|
||
function tryOpenUrl(url, name="Url") {
|
||
try {
|
||
new URL(url);
|
||
}
|
||
catch (exception) {
|
||
return false;
|
||
}
|
||
try {
|
||
window.open(url, '_blank').focus();
|
||
}
|
||
catch (exception) {
|
||
// browser or ad-blocker blocking opening new window
|
||
modelManagerDialog.show($el("span",
|
||
[
|
||
$el("p", {
|
||
style: { color: "var(--input-text)" },
|
||
}, [name]),
|
||
$el("a", {
|
||
href: url,
|
||
target: "_blank",
|
||
}, [
|
||
$el("span", [
|
||
url,
|
||
$el("i.mdi.mdi-open-in-new"),
|
||
])
|
||
]),
|
||
]
|
||
));
|
||
}
|
||
return true;
|
||
}
|
||
|
||
const modelNodeType = {
|
||
checkpoints: 'CheckpointLoaderSimple',
|
||
clip: 'CLIPLoader',
|
||
clip_vision: 'CLIPVisionLoader',
|
||
controlnet: 'ControlNetLoader',
|
||
diffusers: 'DiffusersLoader',
|
||
embeddings: 'Embedding',
|
||
gligen: 'GLIGENLoader',
|
||
hypernetworks: 'HypernetworkLoader',
|
||
photomaker: 'PhotoMakerLoader',
|
||
loras: 'LoraLoader',
|
||
style_models: 'StyleModelLoader',
|
||
unet: 'UNETLoader',
|
||
upscale_models: 'UpscaleModelLoader',
|
||
vae: 'VAELoader',
|
||
vae_approx: undefined,
|
||
};
|
||
|
||
const MODEL_EXTENSIONS = [
|
||
'.bin',
|
||
'.ckpt',
|
||
'gguf',
|
||
'.onnx',
|
||
'.pt',
|
||
'.pth',
|
||
'.safetensors',
|
||
]; // TODO: ask server for?
|
||
const IMAGE_EXTENSIONS = [
|
||
'.png',
|
||
'.webp',
|
||
'.jpeg',
|
||
'.jpg',
|
||
'.jfif',
|
||
'.gif',
|
||
'.apng',
|
||
|
||
'.preview.png',
|
||
'.preview.webp',
|
||
'.preview.jpeg',
|
||
'.preview.jpg',
|
||
'.preview.jfif',
|
||
'.preview.gif',
|
||
'.preview.apng',
|
||
]; // TODO: /model-manager/image/extensions
|
||
|
||
/**
|
||
* @param {string} s
|
||
* @param {string} prefix
|
||
* @returns {string}
|
||
*/
|
||
function removePrefix(s, prefix) {
|
||
if (s.length >= prefix.length && s.startsWith(prefix)) {
|
||
return s.substring(prefix.length);
|
||
}
|
||
return s;
|
||
}
|
||
|
||
/**
|
||
* @param {string} s
|
||
* @param {string} suffix
|
||
* @returns {string}
|
||
*/
|
||
function removeSuffix(s, suffix) {
|
||
if (s.length >= suffix.length && s.endsWith(suffix)) {
|
||
return s.substring(0, s.length - suffix.length);
|
||
}
|
||
return s;
|
||
}
|
||
|
||
class SearchPath {
|
||
/**
|
||
* @param {string} path
|
||
* @returns {[string, string]}
|
||
*/
|
||
static split(path) {
|
||
const i = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\')) + 1;
|
||
return [path.slice(0, i), path.slice(i)];
|
||
}
|
||
|
||
/**
|
||
* @param {string} path
|
||
* @param {string[]} extensions
|
||
* @returns {[string, string]}
|
||
*/
|
||
static splitExtension(path) {
|
||
const i = path.lastIndexOf('.');
|
||
if (i === -1) {
|
||
return [path, ''];
|
||
}
|
||
return [path.slice(0, i), path.slice(i)];
|
||
}
|
||
|
||
/**
|
||
* @param {string} path
|
||
* @returns {string}
|
||
*/
|
||
static systemPath(path, searchSeparator, systemSeparator) {
|
||
const i1 = path.indexOf(searchSeparator, 1);
|
||
const i2 = path.indexOf(searchSeparator, i1 + 1);
|
||
return path.slice(i2 + 1).replaceAll(searchSeparator, systemSeparator);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* @param {string | undefined} [imageUriSearchPath=undefined]
|
||
* @param {string | undefined} [dateImageModified=undefined]
|
||
* @param {string | undefined} [width=undefined]
|
||
* @param {string | undefined} [height=undefined]
|
||
* @param {string | undefined} [imageFormat=undefined]
|
||
* @returns {string}
|
||
*/
|
||
function imageUri(
|
||
imageUriSearchPath = undefined,
|
||
dateImageModified = undefined,
|
||
width = undefined,
|
||
height = undefined,
|
||
imageFormat = undefined,
|
||
) {
|
||
const params = [];
|
||
if (width !== undefined && width !== null) {
|
||
params.push(`width=${width}`);
|
||
}
|
||
if (height !== undefined && height !== null) {
|
||
params.push(`height=${height}`);
|
||
}
|
||
if (dateImageModified !== undefined && dateImageModified !== null) {
|
||
params.push(`v=${dateImageModified}`);
|
||
}
|
||
if (imageFormat !== undefined && imageFormat !== null) {
|
||
params.push(`image-format=${imageFormat}`);
|
||
}
|
||
|
||
const path = imageUriSearchPath ?? 'no-preview';
|
||
const uri = `/model-manager/preview/get/${path}`;
|
||
if (params.length > 0) {
|
||
return uri + '?' + params.join('&');
|
||
}
|
||
return uri;
|
||
}
|
||
const PREVIEW_NONE_URI = imageUri();
|
||
const PREVIEW_THUMBNAIL_WIDTH = 320;
|
||
const PREVIEW_THUMBNAIL_HEIGHT = 480;
|
||
|
||
/**
|
||
*
|
||
* @param {HTMLButtonElement} element
|
||
* @returns {[HTMLButtonElement | undefined, HTMLElement | undefined, HTMLSpanElement | undefined]} [button, icon, span]
|
||
*/
|
||
function comfyButtonDisambiguate(element) {
|
||
// TODO: This likely can be removed by using a css rule that disables clicking on the inner elements of the button.
|
||
let button = undefined;
|
||
let icon = undefined;
|
||
let span = undefined;
|
||
const nodeName = element.nodeName.toLowerCase();
|
||
if (nodeName === 'button') {
|
||
button = element;
|
||
icon = button.getElementsByTagName('i')[0];
|
||
span = button.getElementsByTagName('span')[0];
|
||
} else if (nodeName === 'i') {
|
||
icon = element;
|
||
button = element.parentElement;
|
||
span = button.getElementsByTagName('span')[0];
|
||
} else if (nodeName === 'span') {
|
||
button = element.parentElement;
|
||
icon = button.getElementsByTagName('i')[0];
|
||
span = element;
|
||
}
|
||
return [button, icon, span];
|
||
}
|
||
|
||
/**
|
||
* @param {HTMLButtonElement} element
|
||
* @param {boolean} success
|
||
* @param {string?} successClassName
|
||
* @param {string?} failureClassName
|
||
* @param {boolean?} [disableCallback=false]
|
||
*/
|
||
function comfyButtonAlert(
|
||
element,
|
||
success,
|
||
successClassName = undefined,
|
||
failureClassName = undefined,
|
||
disableCallback = false,
|
||
) {
|
||
if (element === undefined || element === null) {
|
||
return;
|
||
}
|
||
|
||
const [button, icon, span] = comfyButtonDisambiguate(element);
|
||
if (button === undefined) {
|
||
console.warn('Unable to find button element!');
|
||
console.warn(element);
|
||
return;
|
||
}
|
||
|
||
// TODO: debounce would be nice, but needs some sort of "global" to avoid creating/destroying many objects
|
||
|
||
const colorClassName = success
|
||
? 'comfy-button-success'
|
||
: 'comfy-button-failure';
|
||
|
||
if (icon) {
|
||
const iconClassName = (success ? successClassName : failureClassName) ?? '';
|
||
if (iconClassName !== '') {
|
||
icon.classList.add(iconClassName);
|
||
}
|
||
icon.classList.add(colorClassName);
|
||
if (!disableCallback) {
|
||
window.setTimeout(
|
||
(element, iconClassName, colorClassName) => {
|
||
if (iconClassName !== '') {
|
||
element.classList.remove(iconClassName);
|
||
}
|
||
element.classList.remove(colorClassName);
|
||
},
|
||
1000,
|
||
icon,
|
||
iconClassName,
|
||
colorClassName,
|
||
);
|
||
}
|
||
}
|
||
|
||
button.classList.add(colorClassName);
|
||
if (!disableCallback) {
|
||
window.setTimeout(
|
||
(element, colorClassName) => {
|
||
element.classList.remove(colorClassName);
|
||
},
|
||
1000,
|
||
button,
|
||
colorClassName,
|
||
);
|
||
}
|
||
}
|
||
|
||
/**
|
||
*
|
||
* @param {string} modelPath
|
||
* @param {string} newValue
|
||
* @returns {Promise<boolean>}
|
||
*/
|
||
async function saveNotes(modelPath, newValue) {
|
||
const timestamp = await comfyRequest('/model-manager/timestamp').catch(
|
||
(err) => {
|
||
console.warn(err);
|
||
return false;
|
||
},
|
||
);
|
||
return await comfyRequest('/model-manager/notes/save', {
|
||
method: 'POST',
|
||
body: JSON.stringify({
|
||
path: modelPath,
|
||
notes: newValue,
|
||
}),
|
||
timestamp: timestamp,
|
||
})
|
||
.then((result) => {
|
||
const saved = result['success'];
|
||
const message = result['alert'];
|
||
if (message !== undefined) {
|
||
window.alert(message);
|
||
}
|
||
return saved;
|
||
})
|
||
.catch((err) => {
|
||
console.warn(err);
|
||
return false;
|
||
});
|
||
}
|
||
|
||
/**
|
||
* @returns {HTMLLabelElement}
|
||
*/
|
||
function $checkbox(x = { $: (el) => {}, textContent: '', checked: false }) {
|
||
const text = x.textContent;
|
||
const input = $el('input', {
|
||
type: 'checkbox',
|
||
name: text ?? 'checkbox',
|
||
checked: x.checked ?? false,
|
||
});
|
||
const label = $el('label', [
|
||
input,
|
||
text === '' || text === undefined || text === null ? '' : ' ' + text,
|
||
]);
|
||
if (x.$ !== undefined) {
|
||
x.$(input);
|
||
}
|
||
return label;
|
||
}
|
||
|
||
/**
|
||
* @returns {HTMLLabelElement}
|
||
*/
|
||
function $select(x = { $: (el) => {}, textContent: '', options: [''] }) {
|
||
const text = x.textContent;
|
||
const select = $el(
|
||
'select',
|
||
{
|
||
name: text ?? 'select',
|
||
},
|
||
x.options.map((option) => {
|
||
return $el(
|
||
'option',
|
||
{
|
||
value: option,
|
||
},
|
||
option,
|
||
);
|
||
}),
|
||
);
|
||
const label = $el('label', [
|
||
text === '' || text === undefined || text === null ? '' : ' ' + text,
|
||
select,
|
||
]);
|
||
if (x.$ !== undefined) {
|
||
x.$(select);
|
||
}
|
||
return label;
|
||
}
|
||
|
||
/**
|
||
* @param {Any} attr
|
||
* @returns {HTMLDivElement}
|
||
*/
|
||
function $radioGroup(attr) {
|
||
const { name = Date.now(), onchange, options = [], $ } = attr;
|
||
|
||
/** @type {HTMLDivElement[]} */
|
||
const radioGroup = options.map((item, index) => {
|
||
const inputRef = { value: null };
|
||
|
||
return $el('div.comfy-radio', { onclick: () => inputRef.value.click() }, [
|
||
$el('input.radio-input', {
|
||
type: 'radio',
|
||
name: name,
|
||
value: item.value,
|
||
checked: index === 0,
|
||
$: (el) => (inputRef.value = el),
|
||
}),
|
||
$el('label.no-highlight', item.label ?? item.value),
|
||
]);
|
||
});
|
||
|
||
const element = $el('input', {
|
||
name: name + '-group',
|
||
value: options[0]?.value,
|
||
});
|
||
$?.(element);
|
||
|
||
radioGroup.forEach((radio) => {
|
||
radio.addEventListener('change', (event) => {
|
||
const selectedValue = event.target.value;
|
||
element.value = selectedValue;
|
||
onchange?.(selectedValue);
|
||
});
|
||
});
|
||
|
||
return $el('div.comfy-radio-group', radioGroup);
|
||
}
|
||
|
||
/**
|
||
* @param {{name: string, icon: string, tabContent: HTMLDivElement}[]} tabData
|
||
* @returns {[HTMLDivElement[], HTMLDivElement[]]}
|
||
*/
|
||
function GenerateTabGroup(tabData) {
|
||
const ACTIVE_TAB_CLASS = 'active';
|
||
|
||
/** @type {HTMLDivElement[]} */
|
||
const tabButtons = [];
|
||
|
||
/** @type {HTMLDivElement[]} */
|
||
const tabContents = [];
|
||
|
||
tabData.forEach((data) => {
|
||
const name = data.name;
|
||
const icon = data.icon;
|
||
/** @type {HTMLDivElement} */
|
||
const tab = new ComfyButton({
|
||
icon: icon,
|
||
tooltip: 'Open ' + name.toLowerCase() + ' tab',
|
||
classList: 'comfyui-button tab-button',
|
||
content: name,
|
||
action: () => {
|
||
tabButtons.forEach((tabButton) => {
|
||
if (name === tabButton.getAttribute('data-name')) {
|
||
tabButton.classList.add(ACTIVE_TAB_CLASS);
|
||
} else {
|
||
tabButton.classList.remove(ACTIVE_TAB_CLASS);
|
||
}
|
||
});
|
||
tabContents.forEach((tabContent) => {
|
||
if (name === tabContent.getAttribute('data-name')) {
|
||
tabContent.scrollTop = tabContent.dataset['scrollTop'] ?? 0;
|
||
tabContent.style.display = '';
|
||
} else {
|
||
tabContent.dataset['scrollTop'] = tabContent.scrollTop;
|
||
tabContent.style.display = 'none';
|
||
}
|
||
});
|
||
},
|
||
}).element;
|
||
tab.dataset.name = name;
|
||
const content = $el(
|
||
'div.tab-content',
|
||
{
|
||
dataset: {
|
||
name: data.name,
|
||
},
|
||
},
|
||
[data.tabContent],
|
||
);
|
||
tabButtons.push(tab);
|
||
tabContents.push(content);
|
||
});
|
||
|
||
return [tabButtons, tabContents];
|
||
}
|
||
|
||
/**
|
||
* @param {HTMLDivElement} element
|
||
* @param {Record<string, HTMLDivElement>[]} tabButtons
|
||
*/
|
||
function GenerateDynamicTabTextCallback(element, tabButtons, minWidth) {
|
||
return () => {
|
||
if (element.style.display === 'none') {
|
||
return;
|
||
}
|
||
const managerRect = element.getBoundingClientRect();
|
||
const isIcon = managerRect.width < minWidth; // TODO: `minWidth` is a magic value
|
||
const iconDisplay = isIcon ? '' : 'none';
|
||
const spanDisplay = isIcon ? 'none' : '';
|
||
tabButtons.forEach((tabButton) => {
|
||
tabButton.getElementsByTagName('i')[0].style.display = iconDisplay;
|
||
tabButton.getElementsByTagName('span')[0].style.display = spanDisplay;
|
||
});
|
||
};
|
||
}
|
||
|
||
/**
|
||
* @param {[String, int][]} map
|
||
* @returns {String}
|
||
*/
|
||
function TagCountMapToParagraph(map) {
|
||
let text = '<p>';
|
||
for (let i = 0; i < map.length; i++) {
|
||
const v = map[i];
|
||
const tag = v[0];
|
||
const count = v[1];
|
||
text += tag + '<span class="no-select"> (' + count + ')</span>';
|
||
if (i !== map.length - 1) {
|
||
text += ', ';
|
||
}
|
||
}
|
||
text += '</p>';
|
||
return text;
|
||
}
|
||
|
||
/**
|
||
* @param {String} p
|
||
* @returns {[String, int][]}
|
||
*/
|
||
function ParseTagParagraph(p) {
|
||
return p.split(',').map((x) => {
|
||
const text = x.endsWith(', ') ? x.substring(0, x.length - 2) : x;
|
||
const i = text.lastIndexOf('(');
|
||
const tag = text.substring(0, i).trim();
|
||
const frequency = parseInt(text.substring(i + 1, text.length - 1));
|
||
return [tag, frequency];
|
||
});
|
||
}
|
||
|
||
class ImageSelect {
|
||
/** @constant {string} */ #PREVIEW_DEFAULT = 'Default';
|
||
/** @constant {string} */ #PREVIEW_UPLOAD = 'Upload';
|
||
/** @constant {string} */ #PREVIEW_URL = 'URL';
|
||
/** @constant {string} */ #PREVIEW_NONE = 'No Preview';
|
||
|
||
elements = {
|
||
/** @type {HTMLDivElement} */ radioGroup: null,
|
||
/** @type {HTMLDivElement} */ radioButtons: null,
|
||
/** @type {HTMLDivElement} */ previews: null,
|
||
|
||
/** @type {HTMLImageElement} */ defaultPreviewNoImage: null,
|
||
/** @type {HTMLDivElement} */ defaultPreviews: null,
|
||
/** @type {HTMLDivElement} */ defaultUrl: null,
|
||
/** @type {HTMLDivElement} */ previewButtons: null,
|
||
|
||
/** @type {HTMLImageElement} */ customUrlPreview: null,
|
||
/** @type {HTMLInputElement} */ customUrl: null,
|
||
/** @type {HTMLDivElement} */ custom: null,
|
||
|
||
/** @type {HTMLImageElement} */ uploadPreview: null,
|
||
/** @type {HTMLInputElement} */ uploadFile: null,
|
||
/** @type {HTMLDivElement} */ upload: null,
|
||
};
|
||
|
||
/** @type {string} */
|
||
#name = null;
|
||
|
||
/** @returns {Promise<string> | Promise<File>} */
|
||
async getImage() {
|
||
const name = this.#name;
|
||
const value = document.querySelector(`input[name="${name}"]:checked`).value;
|
||
const elements = this.elements;
|
||
switch (value) {
|
||
case this.#PREVIEW_DEFAULT: {
|
||
const children = elements.defaultPreviews.children;
|
||
const noImage = PREVIEW_NONE_URI;
|
||
let url = '';
|
||
for (let i = 0; i < children.length; i++) {
|
||
const child = children[i];
|
||
if (
|
||
child.style.display !== 'none' &&
|
||
child.nodeName === 'IMG' &&
|
||
!child.src.endsWith(noImage)
|
||
) {
|
||
url = child.src;
|
||
}
|
||
}
|
||
if (url.startsWith(Civitai.imageUrlPrefix())) {
|
||
url = await Civitai.getFullSizeImageUrl(url).catch((err) => {
|
||
console.warn(err);
|
||
return url;
|
||
});
|
||
}
|
||
return url;
|
||
}
|
||
case this.#PREVIEW_URL: {
|
||
const value = elements.customUrl.value;
|
||
if (value.startsWith(Civitai.imagePostUrlPrefix())) {
|
||
try {
|
||
const imageInfo = await Civitai.getImageInfo(value);
|
||
const items = imageInfo['items'];
|
||
if (items.length === 0) {
|
||
console.warn('Civitai /api/v1/images returned 0 items.');
|
||
return value;
|
||
}
|
||
return items[0]['url'];
|
||
} catch (error) {
|
||
console.error('Failed to get image info from Civitai!', error);
|
||
return value;
|
||
}
|
||
}
|
||
return value;
|
||
}
|
||
case this.#PREVIEW_UPLOAD:
|
||
return elements.uploadFile.files[0] ?? '';
|
||
case this.#PREVIEW_NONE:
|
||
return PREVIEW_NONE_URI;
|
||
}
|
||
console.warn(`Invalid preview select type: ${value}`);
|
||
return PREVIEW_NONE_URI;
|
||
}
|
||
|
||
/**
|
||
* @param {String[]} defaultPreviewUrls
|
||
* @returns {void}
|
||
*/
|
||
resetModelInfoPreview(defaultPreviewUrls = []) {
|
||
let noimage = this.elements.defaultUrl.dataset.noimage;
|
||
[
|
||
this.elements.defaultPreviewNoImage,
|
||
this.elements.defaultPreviews,
|
||
this.elements.customUrlPreview,
|
||
this.elements.uploadPreview,
|
||
].forEach((el) => {
|
||
el.style.display = 'none';
|
||
if (this.elements.defaultPreviewNoImage !== el) {
|
||
if (el.nodeName === 'IMG') {
|
||
el.src = noimage;
|
||
} else {
|
||
el.children[0].src = noimage;
|
||
}
|
||
} else {
|
||
el.src = PREVIEW_NONE_URI;
|
||
}
|
||
});
|
||
const defaultPreviews = this.elements.defaultPreviews;
|
||
defaultPreviews.innerHTML = '';
|
||
if (defaultPreviewUrls.length > 0) {
|
||
ImageSelect.generateDefaultPreviews(defaultPreviewUrls).forEach(previewElement => {
|
||
defaultPreviews.appendChild(previewElement);
|
||
});
|
||
}
|
||
else {
|
||
const defaultImage = ImageSelect.generateDefaultPreviews([PREVIEW_NONE_URI]);
|
||
defaultPreviews.appendChild(defaultImage[0]);
|
||
}
|
||
this.elements.previewButtons.style.display = defaultPreviewUrls.length > 1 ? 'block' : 'none';
|
||
this.checkDefault();
|
||
this.elements.uploadFile.value = '';
|
||
this.elements.customUrl.value = '';
|
||
this.elements.upload.style.display = 'none';
|
||
this.elements.custom.style.display = 'none';
|
||
}
|
||
|
||
/** @returns {boolean} */
|
||
defaultIsChecked() {
|
||
const children = this.elements.radioButtons.children;
|
||
for (let i = 0; i < children.length; i++) {
|
||
const child = children[i];
|
||
const radioButton = child.children[0];
|
||
if (radioButton.value === this.#PREVIEW_DEFAULT) {
|
||
return radioButton.checked;
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
|
||
/** @returns {void} */
|
||
checkDefault() {
|
||
const children = this.elements.radioButtons.children;
|
||
for (let i = 0; i < children.length; i++) {
|
||
const child = children[i];
|
||
const radioButton = child.children[0];
|
||
if (radioButton.value === this.#PREVIEW_DEFAULT) {
|
||
this.elements.defaultPreviews.style.display = 'block';
|
||
radioButton.checked = true;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* @param {1 | -1} step
|
||
*/
|
||
stepDefaultPreviews(step) {
|
||
const children = this.elements.defaultPreviews.children;
|
||
if (children.length === 0) {
|
||
return;
|
||
}
|
||
let currentIndex = -step;
|
||
for (let i = 0; i < children.length; i++) {
|
||
const previewImage = children[i];
|
||
const display = previewImage.style.display;
|
||
if (display !== 'none') {
|
||
currentIndex = i;
|
||
}
|
||
previewImage.style.display = 'none';
|
||
}
|
||
currentIndex = currentIndex + step;
|
||
if (currentIndex >= children.length) {
|
||
currentIndex = 0;
|
||
} else if (currentIndex < 0) {
|
||
currentIndex = children.length - 1;
|
||
}
|
||
children[currentIndex].style.display = 'block';
|
||
}
|
||
|
||
/**
|
||
* @param {string[]|undefined} defaultPreviewUrls
|
||
* @returns {HTMLImageElement[]}
|
||
*/
|
||
static generateDefaultPreviews(defaultPreviewUrls) {
|
||
const imgs = defaultPreviewUrls.map((url) => {
|
||
return $el('img', {
|
||
loading:
|
||
'lazy' /* `loading` BEFORE `src`; Known bug in Firefox 124.0.2 and Safari for iOS 17.4.1 (https://stackoverflow.com/a/76252772) */,
|
||
src: url,
|
||
style: { display: 'none' },
|
||
onerror: (e) => {
|
||
e.target.src = PREVIEW_NONE_URI;
|
||
},
|
||
});
|
||
});
|
||
if (imgs.length > 0) {
|
||
imgs[0].style.display = 'block';
|
||
}
|
||
return imgs;
|
||
}
|
||
|
||
/**
|
||
* @param {string} radioGroupName - Should be unique for every radio group.
|
||
* @param {string[]|undefined} defaultPreviews
|
||
*/
|
||
constructor(radioGroupName, defaultPreviews = []) {
|
||
if (
|
||
(defaultPreviews === undefined) |
|
||
(defaultPreviews === null) |
|
||
(defaultPreviews.length === 0)
|
||
) {
|
||
defaultPreviews = [PREVIEW_NONE_URI];
|
||
}
|
||
this.#name = radioGroupName;
|
||
|
||
const el_defaultUri = $el('div', {
|
||
$: (el) => (this.elements.defaultUrl = el),
|
||
style: { display: 'none' },
|
||
'data-noimage': PREVIEW_NONE_URI,
|
||
});
|
||
|
||
const el_defaultPreviewNoImage = $el('img', {
|
||
$: (el) => (this.elements.defaultPreviewNoImage = el),
|
||
loading:
|
||
'lazy' /* `loading` BEFORE `src`; Known bug in Firefox 124.0.2 and Safari for iOS 17.4.1 (https://stackoverflow.com/a/76252772) */,
|
||
src: PREVIEW_NONE_URI,
|
||
style: { display: 'none' },
|
||
});
|
||
|
||
const el_defaultPreviews = $el(
|
||
'div',
|
||
{
|
||
$: (el) => (this.elements.defaultPreviews = el),
|
||
style: {
|
||
width: '100%',
|
||
height: '100%',
|
||
},
|
||
},
|
||
ImageSelect.generateDefaultPreviews(defaultPreviews),
|
||
);
|
||
|
||
const el_uploadPreview = $el('img', {
|
||
$: (el) => (this.elements.uploadPreview = el),
|
||
src: PREVIEW_NONE_URI,
|
||
style: { display: 'none' },
|
||
onerror: (e) => {
|
||
e.target.src = el_defaultUri.dataset.noimage ?? PREVIEW_NONE_URI;
|
||
},
|
||
});
|
||
const el_uploadFile = $el('input', {
|
||
$: (el) => (this.elements.uploadFile = el),
|
||
type: 'file',
|
||
name: 'upload preview image',
|
||
accept: IMAGE_EXTENSIONS.join(', '),
|
||
onchange: (e) => {
|
||
const file = e.target.files[0];
|
||
if (file) {
|
||
el_uploadPreview.src = URL.createObjectURL(file);
|
||
} else {
|
||
el_uploadPreview.src = el_defaultUri.dataset.noimage;
|
||
}
|
||
},
|
||
});
|
||
const el_upload = $el(
|
||
'div.row.tab-header-flex-block',
|
||
{
|
||
$: (el) => (this.elements.upload = el),
|
||
style: { display: 'none' },
|
||
},
|
||
[el_uploadFile],
|
||
);
|
||
|
||
/**
|
||
* @param {string} url
|
||
* @returns {Promise<string>}
|
||
*/
|
||
const getCustomPreviewUrl = async (url) => {
|
||
if (url.startsWith(Civitai.imagePostUrlPrefix())) {
|
||
return await Civitai.getImageInfo(url)
|
||
.then((imageInfo) => {
|
||
const items = imageInfo['items'];
|
||
if (items.length > 0) {
|
||
return items[0]['url'];
|
||
} else {
|
||
console.warn('Civitai /api/v1/images returned 0 items.');
|
||
return url;
|
||
}
|
||
})
|
||
.catch((error) => {
|
||
console.error('Failed to get image info from Civitai!', error);
|
||
return url;
|
||
});
|
||
} else {
|
||
return url;
|
||
}
|
||
};
|
||
|
||
const el_customUrlPreview = $el('img', {
|
||
$: (el) => (this.elements.customUrlPreview = el),
|
||
src: PREVIEW_NONE_URI,
|
||
style: { display: 'none' },
|
||
onerror: (e) => {
|
||
e.target.src = el_defaultUri.dataset.noimage ?? PREVIEW_NONE_URI;
|
||
},
|
||
});
|
||
const el_customUrl = $el('input.search-text-area', {
|
||
$: (el) => (this.elements.customUrl = el),
|
||
type: 'text',
|
||
name: 'custom preview image url',
|
||
autocomplete: 'off',
|
||
placeholder: 'https://custom-image-preview.png',
|
||
onkeydown: async (e) => {
|
||
if (e.key === 'Enter') {
|
||
const value = e.target.value;
|
||
el_customUrlPreview.src = await getCustomPreviewUrl(value);
|
||
e.stopPropagation();
|
||
e.target.blur();
|
||
}
|
||
},
|
||
});
|
||
const el_custom = $el(
|
||
'div.row.tab-header-flex-block',
|
||
{
|
||
$: (el) => (this.elements.custom = el),
|
||
style: { display: 'none' },
|
||
},
|
||
[
|
||
el_customUrl,
|
||
new ComfyButton({
|
||
icon: 'magnify',
|
||
tooltip: 'Search models',
|
||
classList: 'comfyui-button icon-button',
|
||
action: async (e) => {
|
||
const [button, icon, span] = comfyButtonDisambiguate(e.target);
|
||
button.disabled = true;
|
||
const value = el_customUrl.value;
|
||
el_customUrlPreview.src = await getCustomPreviewUrl(value);
|
||
e.stopPropagation();
|
||
el_customUrl.blur();
|
||
button.disabled = false;
|
||
},
|
||
}).element,
|
||
],
|
||
);
|
||
|
||
const el_previewButtons = $el(
|
||
'div.model-preview-overlay',
|
||
{
|
||
$: (el) => (this.elements.previewButtons = el),
|
||
style: {
|
||
display: el_defaultPreviews.children.length > 1 ? 'block' : 'none',
|
||
},
|
||
},
|
||
[
|
||
new ComfyButton({
|
||
icon: 'arrow-left',
|
||
tooltip: 'Previous image',
|
||
classList: 'comfyui-button icon-button model-preview-button-left',
|
||
action: () => this.stepDefaultPreviews(-1),
|
||
}).element,
|
||
new ComfyButton({
|
||
icon: 'arrow-right',
|
||
tooltip: 'Next image',
|
||
classList: 'comfyui-button icon-button model-preview-button-right',
|
||
action: () => this.stepDefaultPreviews(1),
|
||
}).element,
|
||
],
|
||
);
|
||
const el_previews = $el(
|
||
'div.item',
|
||
{
|
||
$: (el) => (this.elements.previews = el),
|
||
},
|
||
[
|
||
$el(
|
||
'div',
|
||
{
|
||
style: {
|
||
width: '100%',
|
||
height: '100%',
|
||
},
|
||
},
|
||
[
|
||
el_defaultPreviewNoImage,
|
||
el_defaultPreviews,
|
||
el_customUrlPreview,
|
||
el_uploadPreview,
|
||
],
|
||
),
|
||
el_previewButtons,
|
||
],
|
||
);
|
||
|
||
const el_radioButtons = $radioGroup({
|
||
name: radioGroupName,
|
||
onchange: (value) => {
|
||
el_custom.style.display = 'none';
|
||
el_upload.style.display = 'none';
|
||
|
||
el_defaultPreviews.style.display = 'none';
|
||
el_previewButtons.style.display = 'none';
|
||
|
||
el_defaultPreviewNoImage.style.display = 'none';
|
||
el_uploadPreview.style.display = 'none';
|
||
el_customUrlPreview.style.display = 'none';
|
||
|
||
switch (value) {
|
||
case this.#PREVIEW_DEFAULT:
|
||
el_defaultPreviews.style.display = 'block';
|
||
el_previewButtons.style.display =
|
||
el_defaultPreviews.children.length > 1 ? 'block' : 'none';
|
||
break;
|
||
case this.#PREVIEW_UPLOAD:
|
||
el_upload.style.display = 'flex';
|
||
el_uploadPreview.style.display = 'block';
|
||
break;
|
||
case this.#PREVIEW_URL:
|
||
el_custom.style.display = 'flex';
|
||
el_customUrlPreview.style.display = 'block';
|
||
break;
|
||
case this.#PREVIEW_NONE:
|
||
default:
|
||
el_defaultPreviewNoImage.style.display = 'block';
|
||
break;
|
||
}
|
||
},
|
||
options: [
|
||
this.#PREVIEW_DEFAULT,
|
||
this.#PREVIEW_URL,
|
||
this.#PREVIEW_UPLOAD,
|
||
this.#PREVIEW_NONE,
|
||
].map((value) => {
|
||
return { value: value };
|
||
}),
|
||
});
|
||
this.elements.radioButtons = el_radioButtons;
|
||
|
||
const children = el_radioButtons.children;
|
||
for (let i = 0; i < children.length; i++) {
|
||
const child = children[i];
|
||
const radioButton = child.children[0];
|
||
if (radioButton.value === this.#PREVIEW_DEFAULT) {
|
||
radioButton.checked = true;
|
||
break;
|
||
}
|
||
}
|
||
|
||
const el_radioGroup = $el(
|
||
'div.model-preview-select-radio-container',
|
||
{
|
||
$: (el) => (this.elements.radioGroup = el),
|
||
},
|
||
[
|
||
$el('div.row.tab-header-flex-block', [el_radioButtons]),
|
||
$el('div.model-preview-select-radio-inputs', [el_custom, el_upload]),
|
||
],
|
||
);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* @typedef {Object} DirectoryItem
|
||
* @property {String} name
|
||
* @property {number | undefined} childCount
|
||
* @property {number | undefined} childIndex
|
||
*/
|
||
|
||
class ModelDirectories {
|
||
/** @type {DirectoryItem[]} */
|
||
data = [];
|
||
|
||
/**
|
||
* @returns {number}
|
||
*/
|
||
rootIndex() {
|
||
return 0;
|
||
}
|
||
|
||
/**
|
||
* @param {any} index
|
||
* @returns {boolean}
|
||
*/
|
||
isValidIndex(index) {
|
||
return typeof index === 'number' && 0 <= index && index < this.data.length;
|
||
}
|
||
|
||
/**
|
||
* @param {number} index
|
||
* @returns {DirectoryItem}
|
||
*/
|
||
getItem(index) {
|
||
if (!this.isValidIndex(index)) {
|
||
throw new Error(`Index '${index}' is not valid!`);
|
||
}
|
||
return this.data[index];
|
||
}
|
||
|
||
/**
|
||
* @param {DirectoryItem | number} item
|
||
* @returns {boolean}
|
||
*/
|
||
isDirectory(item) {
|
||
if (typeof item === 'number') {
|
||
item = this.getItem(item);
|
||
}
|
||
const childCount = item.childCount;
|
||
return childCount !== undefined && childCount != null;
|
||
}
|
||
|
||
/**
|
||
* @param {DirectoryItem | number} item
|
||
* @returns {boolean}
|
||
*/
|
||
isEmpty(item) {
|
||
if (typeof item === 'number') {
|
||
item = this.getItem(item);
|
||
}
|
||
if (!this.isDirectory(item)) {
|
||
throw new Error('Item is not a directory!');
|
||
}
|
||
return item.childCount === 0;
|
||
}
|
||
|
||
/**
|
||
* Returns a slice of children from the directory list.
|
||
* @param {DirectoryItem | number} item
|
||
* @returns {DirectoryItem[]}
|
||
*/
|
||
getChildren(item) {
|
||
if (typeof item === 'number') {
|
||
item = this.getItem(item);
|
||
if (!this.isDirectory(item)) {
|
||
throw new Error('Item is not a directory!');
|
||
}
|
||
} else if (!this.isDirectory(item)) {
|
||
throw new Error('Item is not a directory!');
|
||
}
|
||
const count = item.childCount;
|
||
const index = item.childIndex;
|
||
return this.data.slice(index, index + count);
|
||
}
|
||
|
||
/**
|
||
* Returns index of child in parent directory. Returns -1 if DNE.
|
||
* @param {DirectoryItem | number} parent
|
||
* @param {string} name
|
||
* @returns {number}
|
||
*/
|
||
findChildIndex(parent, name) {
|
||
const item = this.getItem(parent);
|
||
if (!this.isDirectory(item)) {
|
||
throw new Error('Item is not a directory!');
|
||
}
|
||
const start = item.childIndex;
|
||
const children = this.getChildren(item);
|
||
const index = children.findIndex((item) => {
|
||
return item.name === name;
|
||
});
|
||
if (index === -1) {
|
||
return -1;
|
||
}
|
||
return index + start;
|
||
}
|
||
|
||
/**
|
||
* Returns a list of matching search results and valid path.
|
||
* @param {string} filter
|
||
* @param {string} searchSeparator
|
||
* @param {boolean} directoriesOnly
|
||
* @returns {[string[], string]}
|
||
*/
|
||
search(filter, searchSeparator, directoriesOnly) {
|
||
let cwd = this.rootIndex();
|
||
let indexLastWord = 1;
|
||
while (true) {
|
||
const indexNextWord = filter.indexOf(searchSeparator, indexLastWord);
|
||
if (indexNextWord === -1) {
|
||
// end of filter
|
||
break;
|
||
}
|
||
|
||
const item = this.getItem(cwd);
|
||
if (!this.isDirectory(item) || this.isEmpty(item)) {
|
||
break;
|
||
}
|
||
|
||
const word = filter.substring(indexLastWord, indexNextWord);
|
||
cwd = this.findChildIndex(cwd, word);
|
||
if (!this.isValidIndex(cwd)) {
|
||
return [[], ''];
|
||
}
|
||
indexLastWord = indexNextWord + 1;
|
||
}
|
||
//const cwdPath = filter.substring(0, indexLastWord);
|
||
|
||
const lastWord = filter.substring(indexLastWord);
|
||
const children = this.getChildren(cwd);
|
||
if (directoriesOnly) {
|
||
let indexPathEnd = indexLastWord;
|
||
const results = children
|
||
.filter((child) => {
|
||
return this.isDirectory(child) && child.name.startsWith(lastWord);
|
||
})
|
||
.map((directory) => {
|
||
const children = this.getChildren(directory);
|
||
const hasChildren = children.some((item) => {
|
||
return this.isDirectory(item);
|
||
});
|
||
const suffix = hasChildren ? searchSeparator : '';
|
||
//const suffix = searchSeparator;
|
||
if (directory.name == lastWord) {
|
||
indexPathEnd += searchSeparator.length + directory.name.length + 1;
|
||
}
|
||
return directory.name + suffix;
|
||
});
|
||
const path = filter.substring(0, indexPathEnd);
|
||
return [results, path];
|
||
} else {
|
||
let indexPathEnd = indexLastWord;
|
||
const results = children
|
||
.filter((child) => {
|
||
return child.name.startsWith(lastWord);
|
||
})
|
||
.map((item) => {
|
||
const isDir = this.isDirectory(item);
|
||
const isNonEmptyDirectory = isDir && item.childCount > 0;
|
||
const suffix = isNonEmptyDirectory ? searchSeparator : '';
|
||
//const suffix = isDir ? searchSeparator : "";
|
||
if (!isDir && item.name == lastWord) {
|
||
indexPathEnd += searchSeparator.length + item.name.length + 1;
|
||
}
|
||
return item.name + suffix;
|
||
});
|
||
const path = filter.substring(0, indexPathEnd);
|
||
return [results, path];
|
||
}
|
||
}
|
||
}
|
||
|
||
const DROPDOWN_DIRECTORY_SELECTION_KEY_CLASS =
|
||
'search-directory-dropdown-key-selected';
|
||
const DROPDOWN_DIRECTORY_SELECTION_MOUSE_CLASS =
|
||
'search-directory-dropdown-mouse-selected';
|
||
|
||
class ModelData {
|
||
/** @type {string} */
|
||
searchSeparator = '/'; // TODO: other client or server code may be assuming this to always be "/"
|
||
|
||
/** @type {string} */
|
||
systemSeparator = null;
|
||
|
||
/** @type {Object} */
|
||
models = {};
|
||
|
||
/** @type {ModelDirectories} */
|
||
directories = null;
|
||
|
||
constructor() {
|
||
this.directories = new ModelDirectories();
|
||
}
|
||
}
|
||
|
||
class DirectoryDropdown {
|
||
/** @type {HTMLDivElement} */
|
||
element = null;
|
||
|
||
/** @type {Boolean} */
|
||
showDirectoriesOnly = false;
|
||
|
||
/** @type {HTMLInputElement} */
|
||
#input = null;
|
||
|
||
/** @type {() => string} */
|
||
#getModelType = null;
|
||
|
||
/** @type {ModelData} */
|
||
#modelData = null; // READ ONLY
|
||
|
||
/** @type {() => void} */
|
||
#updateCallback = null;
|
||
|
||
/** @type {() => Promise<void>} */
|
||
#submitCallback = null;
|
||
|
||
/** @type {string} */
|
||
#deepestPreviousPath = '/';
|
||
|
||
/** @type {Any} */
|
||
#touchSelectionStart = null;
|
||
|
||
/** @type {() => Boolean} */
|
||
#isDynamicSearch = () => {
|
||
return false;
|
||
};
|
||
|
||
/**
|
||
* @param {ModelData} modelData
|
||
* @param {HTMLInputElement} input
|
||
* @param {Boolean} [showDirectoriesOnly=false]
|
||
* @param {() => string} [getModelType= () => { return ""; }]
|
||
* @param {() => void} [updateCallback= () => {}]
|
||
* @param {() => Promise<void>} [submitCallback= () => {}]
|
||
* @param {() => Boolean} [isDynamicSearch= () => { return false; }]
|
||
*/
|
||
constructor(
|
||
modelData,
|
||
input,
|
||
showDirectoriesOnly = false,
|
||
getModelType = () => {
|
||
return '';
|
||
},
|
||
updateCallback = () => {},
|
||
submitCallback = () => {},
|
||
isDynamicSearch = () => {
|
||
return false;
|
||
},
|
||
) {
|
||
/** @type {HTMLDivElement} */
|
||
const dropdown = $el('div.search-directory-dropdown', {
|
||
style: {
|
||
display: 'none',
|
||
},
|
||
});
|
||
this.element = dropdown;
|
||
this.#modelData = modelData;
|
||
this.#input = input;
|
||
this.#getModelType = getModelType;
|
||
this.#updateCallback = updateCallback;
|
||
this.#submitCallback = submitCallback;
|
||
this.showDirectoriesOnly = showDirectoriesOnly;
|
||
this.#isDynamicSearch = isDynamicSearch;
|
||
|
||
input.addEventListener('input', async (e) => {
|
||
const path = this.#updateOptions();
|
||
if (path !== undefined) {
|
||
this.#restoreSelectedOption(path);
|
||
this.#updateDeepestPath(path);
|
||
}
|
||
updateCallback();
|
||
if (isDynamicSearch()) {
|
||
await submitCallback();
|
||
}
|
||
});
|
||
input.addEventListener('focus', () => {
|
||
const path = this.#updateOptions();
|
||
if (path !== undefined) {
|
||
this.#deepestPreviousPath = path;
|
||
this.#restoreSelectedOption(path);
|
||
}
|
||
updateCallback();
|
||
});
|
||
input.addEventListener('blur', () => {
|
||
dropdown.style.display = 'none';
|
||
});
|
||
input.addEventListener('keydown', async (e) => {
|
||
const options = dropdown.children;
|
||
let iSelection;
|
||
for (iSelection = 0; iSelection < options.length; iSelection++) {
|
||
const selection = options[iSelection];
|
||
if (
|
||
selection.classList.contains(DROPDOWN_DIRECTORY_SELECTION_KEY_CLASS)
|
||
) {
|
||
break;
|
||
}
|
||
}
|
||
if (e.key === 'Escape') {
|
||
e.stopPropagation();
|
||
if (iSelection < options.length) {
|
||
const selection = options[iSelection];
|
||
selection.classList.remove(DROPDOWN_DIRECTORY_SELECTION_KEY_CLASS);
|
||
} else {
|
||
e.target.blur();
|
||
}
|
||
} else if (e.key === 'ArrowRight' && dropdown.style.display !== 'none') {
|
||
const selection = options[iSelection];
|
||
if (selection !== undefined && selection !== null) {
|
||
e.stopPropagation();
|
||
e.preventDefault(); // prevent cursor move
|
||
const input = e.target;
|
||
const searchSeparator = modelData.searchSeparator;
|
||
DirectoryDropdown.selectionToInput(
|
||
input,
|
||
selection,
|
||
searchSeparator,
|
||
DROPDOWN_DIRECTORY_SELECTION_KEY_CLASS,
|
||
);
|
||
const path = this.#updateOptions();
|
||
if (path !== undefined) {
|
||
this.#restoreSelectedOption(path);
|
||
this.#updateDeepestPath(path);
|
||
}
|
||
updateCallback();
|
||
if (isDynamicSearch()) {
|
||
await submitCallback();
|
||
}
|
||
}
|
||
} else if (e.key === 'ArrowLeft' && dropdown.style.display !== 'none') {
|
||
const input = e.target;
|
||
const oldFilterText = input.value;
|
||
const searchSeparator = modelData.searchSeparator;
|
||
const iSep = oldFilterText.lastIndexOf(
|
||
searchSeparator,
|
||
oldFilterText.length - 2,
|
||
);
|
||
const newFilterText = oldFilterText.substring(0, iSep + 1);
|
||
if (oldFilterText !== newFilterText) {
|
||
const delta = oldFilterText.substring(iSep + 1);
|
||
let isMatch = delta[delta.length - 1] === searchSeparator;
|
||
if (!isMatch) {
|
||
const options = dropdown.children;
|
||
for (let i = 0; i < options.length; i++) {
|
||
const option = options[i];
|
||
if (option.innerText.startsWith(delta)) {
|
||
isMatch = true;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
if (isMatch) {
|
||
e.stopPropagation();
|
||
e.preventDefault(); // prevent cursor move
|
||
input.value = newFilterText;
|
||
const path = this.#updateOptions();
|
||
if (path !== undefined) {
|
||
this.#restoreSelectedOption(path);
|
||
this.#updateDeepestPath(path);
|
||
}
|
||
updateCallback();
|
||
if (isDynamicSearch()) {
|
||
await submitCallback();
|
||
}
|
||
}
|
||
}
|
||
} else if (e.key === 'Enter') {
|
||
e.stopPropagation();
|
||
const input = e.target;
|
||
if (dropdown.style.display !== 'none') {
|
||
/*
|
||
// This is WAY too confusing.
|
||
const selection = options[iSelection];
|
||
if (selection !== undefined && selection !== null) {
|
||
DirectoryDropdown.selectionToInput(
|
||
input,
|
||
selection,
|
||
modelData.searchSeparator,
|
||
DROPDOWN_DIRECTORY_SELECTION_KEY_CLASS
|
||
);
|
||
const path = this.#updateOptions();
|
||
if (path !== undefined) {
|
||
this.#updateDeepestPath(path);
|
||
}
|
||
updateCallback();
|
||
}
|
||
*/
|
||
}
|
||
await submitCallback();
|
||
input.blur();
|
||
} else if (
|
||
(e.key === 'ArrowDown' || e.key === 'ArrowUp') &&
|
||
dropdown.style.display !== 'none'
|
||
) {
|
||
e.stopPropagation();
|
||
e.preventDefault(); // prevent cursor move
|
||
let iNext = options.length;
|
||
if (iSelection < options.length) {
|
||
const selection = options[iSelection];
|
||
selection.classList.remove(DROPDOWN_DIRECTORY_SELECTION_KEY_CLASS);
|
||
const delta = e.key === 'ArrowDown' ? 1 : -1;
|
||
iNext = iSelection + delta;
|
||
if (iNext < 0) {
|
||
iNext = options.length - 1;
|
||
} else if (iNext >= options.length) {
|
||
iNext = 0;
|
||
}
|
||
const selectionNext = options[iNext];
|
||
selectionNext.classList.add(DROPDOWN_DIRECTORY_SELECTION_KEY_CLASS);
|
||
} else if (iSelection === options.length) {
|
||
// none
|
||
iNext = e.key === 'ArrowDown' ? 0 : options.length - 1;
|
||
const selection = options[iNext];
|
||
selection.classList.add(DROPDOWN_DIRECTORY_SELECTION_KEY_CLASS);
|
||
}
|
||
if (0 <= iNext && iNext < options.length) {
|
||
DirectoryDropdown.#clampDropdownScrollTop(dropdown, options[iNext]);
|
||
} else {
|
||
dropdown.scrollTop = 0;
|
||
const options = dropdown.children;
|
||
for (iSelection = 0; iSelection < options.length; iSelection++) {
|
||
const selection = options[iSelection];
|
||
if (
|
||
selection.classList.contains(
|
||
DROPDOWN_DIRECTORY_SELECTION_KEY_CLASS,
|
||
)
|
||
) {
|
||
selection.classList.remove(
|
||
DROPDOWN_DIRECTORY_SELECTION_KEY_CLASS,
|
||
);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
/**
|
||
* @param {HTMLInputElement} input
|
||
* @param {HTMLParagraphElement | undefined | null} selection
|
||
* @param {String} searchSeparator
|
||
* @param {String} className
|
||
* @returns {boolean} changed
|
||
*/
|
||
static selectionToInput(input, selection, searchSeparator, className) {
|
||
selection.classList.remove(className);
|
||
const selectedText = selection.innerText;
|
||
const oldFilterText = input.value;
|
||
const iSep = oldFilterText.lastIndexOf(searchSeparator);
|
||
const previousPath = oldFilterText.substring(0, iSep + 1);
|
||
const newFilterText = previousPath + selectedText;
|
||
input.value = newFilterText;
|
||
return newFilterText !== oldFilterText;
|
||
}
|
||
|
||
/**
|
||
* @param {string} path
|
||
*/
|
||
#updateDeepestPath = (path) => {
|
||
const deepestPath = this.#deepestPreviousPath;
|
||
if (path.length > deepestPath.length || !deepestPath.startsWith(path)) {
|
||
this.#deepestPreviousPath = path;
|
||
}
|
||
};
|
||
|
||
/**
|
||
* @param {HTMLDivElement} dropdown
|
||
* @param {HTMLParagraphElement} selection
|
||
*/
|
||
static #clampDropdownScrollTop = (dropdown, selection) => {
|
||
let dropdownTop = dropdown.scrollTop;
|
||
const dropdownHeight = dropdown.offsetHeight;
|
||
const selectionHeight = selection.offsetHeight;
|
||
const selectionTop = selection.offsetTop;
|
||
dropdownTop = Math.max(
|
||
dropdownTop,
|
||
selectionTop - dropdownHeight + selectionHeight,
|
||
);
|
||
dropdownTop = Math.min(dropdownTop, selectionTop);
|
||
dropdown.scrollTop = dropdownTop;
|
||
};
|
||
|
||
/**
|
||
* @param {string} path
|
||
*/
|
||
#restoreSelectedOption(path) {
|
||
const searchSeparator = this.#modelData.searchSeparator;
|
||
const deepest = this.#deepestPreviousPath;
|
||
if (deepest.length >= path.length && deepest.startsWith(path)) {
|
||
let name = deepest.substring(path.length);
|
||
name = removePrefix(name, searchSeparator);
|
||
const i1 = name.indexOf(searchSeparator);
|
||
if (i1 !== -1) {
|
||
name = name.substring(0, i1);
|
||
}
|
||
|
||
const dropdown = this.element;
|
||
const options = dropdown.children;
|
||
let iSelection;
|
||
for (iSelection = 0; iSelection < options.length; iSelection++) {
|
||
const selection = options[iSelection];
|
||
let text = removeSuffix(selection.innerText, searchSeparator);
|
||
if (text === name) {
|
||
selection.classList.add(DROPDOWN_DIRECTORY_SELECTION_KEY_CLASS);
|
||
dropdown.scrollTop = dropdown.scrollHeight; // snap to top
|
||
DirectoryDropdown.#clampDropdownScrollTop(dropdown, selection);
|
||
break;
|
||
}
|
||
}
|
||
if (iSelection === options.length) {
|
||
dropdown.scrollTop = 0;
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Returns path if update was successful.
|
||
* @returns {string | undefined}
|
||
*/
|
||
#updateOptions() {
|
||
const dropdown = this.element;
|
||
const input = this.#input;
|
||
|
||
const searchSeparator = this.#modelData.searchSeparator;
|
||
const filter = input.value;
|
||
if (filter[0] !== searchSeparator) {
|
||
dropdown.style.display = 'none';
|
||
return undefined;
|
||
}
|
||
|
||
const modelType = this.#getModelType();
|
||
const searchPrefix = modelType !== '' ? searchSeparator + modelType : '';
|
||
const directories = this.#modelData.directories;
|
||
const [options, path] = directories.search(
|
||
searchPrefix + filter,
|
||
searchSeparator,
|
||
this.showDirectoriesOnly,
|
||
);
|
||
if (options.length === 0) {
|
||
dropdown.style.display = 'none';
|
||
return undefined;
|
||
}
|
||
|
||
const mouse_selection_select = (e) => {
|
||
const selection = e.target;
|
||
if (e.movementX === 0 && e.movementY === 0) {
|
||
return;
|
||
}
|
||
if (
|
||
!selection.classList.contains(DROPDOWN_DIRECTORY_SELECTION_MOUSE_CLASS)
|
||
) {
|
||
// assumes only one will ever selected at a time
|
||
e.stopPropagation();
|
||
const children = dropdown.children;
|
||
for (let iChild = 0; iChild < children.length; iChild++) {
|
||
const child = children[iChild];
|
||
child.classList.remove(DROPDOWN_DIRECTORY_SELECTION_MOUSE_CLASS);
|
||
}
|
||
selection.classList.add(DROPDOWN_DIRECTORY_SELECTION_MOUSE_CLASS);
|
||
}
|
||
};
|
||
const mouse_selection_deselect = (e) => {
|
||
e.stopPropagation();
|
||
e.target.classList.remove(DROPDOWN_DIRECTORY_SELECTION_MOUSE_CLASS);
|
||
};
|
||
const selection_submit = async (e) => {
|
||
e.stopPropagation();
|
||
e.preventDefault();
|
||
const selection = e.target;
|
||
const changed = DirectoryDropdown.selectionToInput(
|
||
input,
|
||
selection,
|
||
searchSeparator,
|
||
DROPDOWN_DIRECTORY_SELECTION_MOUSE_CLASS,
|
||
);
|
||
if (!changed) {
|
||
dropdown.style.display = 'none';
|
||
input.blur();
|
||
} else {
|
||
const path = this.#updateOptions(); // TODO: is this needed?
|
||
if (path !== undefined) {
|
||
this.#updateDeepestPath(path);
|
||
}
|
||
}
|
||
this.#updateCallback();
|
||
if (this.#isDynamicSearch()) {
|
||
await this.#submitCallback();
|
||
}
|
||
};
|
||
const touch_selection_select = async (e) => {
|
||
const [startX, startY] = this.#touchSelectionStart;
|
||
const [endX, endY] = [
|
||
e.changedTouches[0].clientX,
|
||
e.changedTouches[0].clientY,
|
||
];
|
||
if (startX === endX && startY === endY) {
|
||
const touch = e.changedTouches[0];
|
||
const box = dropdown.getBoundingClientRect();
|
||
if (
|
||
touch.clientX >= box.left &&
|
||
touch.clientX <= box.right &&
|
||
touch.clientY >= box.top &&
|
||
touch.clientY <= box.bottom
|
||
) {
|
||
selection_submit(e);
|
||
}
|
||
}
|
||
};
|
||
const touch_start = (e) => {
|
||
this.#touchSelectionStart = [
|
||
e.changedTouches[0].clientX,
|
||
e.changedTouches[0].clientY,
|
||
];
|
||
};
|
||
dropdown.innerHTML = '';
|
||
dropdown.append.apply(
|
||
dropdown,
|
||
options.map((text) => {
|
||
/** @type {HTMLParagraphElement} */
|
||
const p = $el(
|
||
'p',
|
||
{
|
||
onmouseenter: (e) => mouse_selection_select(e),
|
||
onmousemove: (e) => mouse_selection_select(e),
|
||
onmouseleave: (e) => mouse_selection_deselect(e),
|
||
onmousedown: (e) => selection_submit(e),
|
||
ontouchstart: (e) => touch_start(e),
|
||
ontouchmove: (e) => touch_move(e),
|
||
ontouchend: (e) => touch_selection_select(e),
|
||
},
|
||
[text],
|
||
);
|
||
return p;
|
||
}),
|
||
);
|
||
// TODO: handle when dropdown is near the bottom of the window
|
||
const inputRect = input.getBoundingClientRect();
|
||
dropdown.style.width = inputRect.width + 'px';
|
||
dropdown.style.top = input.offsetTop + inputRect.height + 'px';
|
||
dropdown.style.left = input.offsetLeft + 'px';
|
||
dropdown.style.display = 'block';
|
||
|
||
return path;
|
||
}
|
||
}
|
||
|
||
const MODEL_SORT_DATE_CREATED = 'dateCreated';
|
||
const MODEL_SORT_DATE_MODIFIED = 'dateModified';
|
||
const MODEL_SORT_SIZE_BYTES = 'sizeBytes';
|
||
const MODEL_SORT_DATE_NAME = 'name';
|
||
|
||
class ModelGrid {
|
||
/**
|
||
* @param {string} nodeType
|
||
* @returns {int}
|
||
*/
|
||
static modelWidgetIndex(nodeType) {
|
||
return nodeType === undefined ? -1 : 0;
|
||
}
|
||
|
||
/**
|
||
* @param {string} text
|
||
* @param {string} file
|
||
* @param {boolean} removeExtension
|
||
* @returns {string}
|
||
*/
|
||
static insertEmbeddingIntoText(text, file, removeExtension) {
|
||
let name = file;
|
||
if (removeExtension) {
|
||
name = SearchPath.splitExtension(name)[0];
|
||
}
|
||
const sep = text.length === 0 || text.slice(-1).match(/\s/) ? '' : ' ';
|
||
return text + sep + '(embedding:' + name + ':1.0)';
|
||
}
|
||
|
||
/**
|
||
* @param {Array} list
|
||
* @param {string} searchString
|
||
* @returns {Array}
|
||
*/
|
||
static #filter(list, searchString) {
|
||
/** @type {string[]} */
|
||
const keywords = searchString
|
||
//.replace("*", " ") // TODO: this is wrong for wildcards
|
||
.split(/(-?".*?"|[^\s"]+)+/g)
|
||
.map((item) =>
|
||
item
|
||
.trim()
|
||
.replace(/(?:")+/g, '')
|
||
.toLowerCase(),
|
||
)
|
||
.filter(Boolean);
|
||
|
||
const regexSHA256 = /^[a-f0-9]{64}$/gi;
|
||
const fields = ['name', 'path'];
|
||
return list.filter((element) => {
|
||
const text = fields
|
||
.reduce((memo, field) => memo + ' ' + element[field], '')
|
||
.toLowerCase();
|
||
return keywords.reduce((memo, target) => {
|
||
const excludeTarget = target[0] === '-';
|
||
if (excludeTarget && target.length === 1) {
|
||
return memo;
|
||
}
|
||
const filteredTarget = excludeTarget ? target.slice(1) : target;
|
||
if (
|
||
element['SHA256'] !== undefined &&
|
||
regexSHA256.test(filteredTarget)
|
||
) {
|
||
return (
|
||
memo && excludeTarget !== (filteredTarget === element['SHA256'])
|
||
);
|
||
} else {
|
||
return memo && excludeTarget !== text.includes(filteredTarget);
|
||
}
|
||
}, true);
|
||
});
|
||
}
|
||
|
||
/**
|
||
* In-place sort. Returns an array alias.
|
||
* @param {Array} list
|
||
* @param {string} sortBy
|
||
* @param {bool} [reverse=false]
|
||
* @returns {Array}
|
||
*/
|
||
static #sort(list, sortBy, reverse = false) {
|
||
let compareFn = null;
|
||
switch (sortBy) {
|
||
case MODEL_SORT_DATE_NAME:
|
||
compareFn = (a, b) => {
|
||
return a[MODEL_SORT_DATE_NAME].localeCompare(b[MODEL_SORT_DATE_NAME]);
|
||
};
|
||
break;
|
||
case MODEL_SORT_DATE_MODIFIED:
|
||
compareFn = (a, b) => {
|
||
return b[MODEL_SORT_DATE_MODIFIED] - a[MODEL_SORT_DATE_MODIFIED];
|
||
};
|
||
break;
|
||
case MODEL_SORT_DATE_CREATED:
|
||
compareFn = (a, b) => {
|
||
return b[MODEL_SORT_DATE_CREATED] - a[MODEL_SORT_DATE_CREATED];
|
||
};
|
||
break;
|
||
case MODEL_SORT_SIZE_BYTES:
|
||
compareFn = (a, b) => {
|
||
return b[MODEL_SORT_SIZE_BYTES] - a[MODEL_SORT_SIZE_BYTES];
|
||
};
|
||
break;
|
||
default:
|
||
console.warn("Invalid filter sort value: '" + sortBy + "'");
|
||
return list;
|
||
}
|
||
const sorted = list.sort(compareFn);
|
||
return reverse ? sorted.reverse() : sorted;
|
||
}
|
||
|
||
/**
|
||
* @param {Event} event
|
||
* @param {string} modelType
|
||
* @param {string} path
|
||
* @param {boolean} removeEmbeddingExtension
|
||
* @param {int} addOffset
|
||
*/
|
||
static #addModel(
|
||
event,
|
||
modelType,
|
||
path,
|
||
removeEmbeddingExtension,
|
||
addOffset,
|
||
) {
|
||
let success = false;
|
||
if (modelType !== 'embeddings') {
|
||
const nodeType = modelNodeType[modelType];
|
||
const widgetIndex = ModelGrid.modelWidgetIndex(nodeType);
|
||
let node = LiteGraph.createNode(nodeType, null, []);
|
||
if (widgetIndex !== -1 && node) {
|
||
node.widgets[widgetIndex].value = path;
|
||
const selectedNodes = app.canvas.selected_nodes;
|
||
let isSelectedNode = false;
|
||
for (var i in selectedNodes) {
|
||
const selectedNode = selectedNodes[i];
|
||
node.pos[0] = selectedNode.pos[0] + addOffset;
|
||
node.pos[1] = selectedNode.pos[1] + addOffset;
|
||
isSelectedNode = true;
|
||
break;
|
||
}
|
||
if (!isSelectedNode) {
|
||
const graphMouse = app.canvas.graph_mouse;
|
||
node.pos[0] = graphMouse[0];
|
||
node.pos[1] = graphMouse[1];
|
||
}
|
||
app.graph.add(node, { doProcessChange: true });
|
||
app.canvas.selectNode(node);
|
||
success = true;
|
||
}
|
||
event.stopPropagation();
|
||
} else if (modelType === 'embeddings') {
|
||
const [embeddingDirectory, embeddingFile] = SearchPath.split(path);
|
||
const selectedNodes = app.canvas.selected_nodes;
|
||
for (var i in selectedNodes) {
|
||
const selectedNode = selectedNodes[i];
|
||
const nodeType = modelNodeType[modelType];
|
||
const widgetIndex = ModelGrid.modelWidgetIndex(nodeType);
|
||
const target = selectedNode?.widgets[widgetIndex]?.element;
|
||
if (target && target.type === 'textarea') {
|
||
// TODO: If the node has >1 text areas, the textarea element must be selected
|
||
target.value = ModelGrid.insertEmbeddingIntoText(
|
||
target.value,
|
||
embeddingFile,
|
||
removeEmbeddingExtension,
|
||
);
|
||
success = true;
|
||
}
|
||
}
|
||
if (!success) {
|
||
window.alert('No selected nodes have a text area!');
|
||
}
|
||
event.stopPropagation();
|
||
}
|
||
comfyButtonAlert(
|
||
event.target,
|
||
success,
|
||
'mdi-check-bold',
|
||
'mdi-close-thick',
|
||
);
|
||
}
|
||
|
||
static #getWidgetComboIndices(node, value) {
|
||
const widgetIndices = [];
|
||
node?.widgets?.forEach((widget, index) => {
|
||
if (widget.type === 'combo' && widget.options.values?.includes(value)) {
|
||
widgetIndices.push(index);
|
||
}
|
||
});
|
||
return widgetIndices;
|
||
}
|
||
|
||
/**
|
||
* @param {DragEvent} event
|
||
* @param {string} modelType
|
||
* @param {string} path
|
||
* @param {boolean} removeEmbeddingExtension
|
||
* @param {boolean} strictlyOnWidget
|
||
*/
|
||
static dragAddModel(
|
||
event,
|
||
modelType,
|
||
path,
|
||
removeEmbeddingExtension,
|
||
strictlyOnWidget,
|
||
) {
|
||
const target = document.elementFromPoint(event.clientX, event.clientY);
|
||
if (modelType !== 'embeddings' && target.id === 'graph-canvas') {
|
||
const pos = app.canvas.convertEventToCanvasOffset(event);
|
||
|
||
const node = app.graph.getNodeOnPos(
|
||
pos[0],
|
||
pos[1],
|
||
app.canvas.visible_nodes,
|
||
);
|
||
|
||
let widgetIndex = -1;
|
||
if (widgetIndex === -1) {
|
||
const widgetIndices = this.#getWidgetComboIndices(node, path);
|
||
if (widgetIndices.length === 0) {
|
||
widgetIndex = -1;
|
||
} else if (widgetIndices.length === 1) {
|
||
widgetIndex = widgetIndices[0];
|
||
if (strictlyOnWidget) {
|
||
const draggedWidget = app.canvas.processNodeWidgets(
|
||
node,
|
||
pos,
|
||
event,
|
||
);
|
||
const widget = node.widgets[widgetIndex];
|
||
if (draggedWidget != widget) {
|
||
// != check NOT same object
|
||
widgetIndex = -1;
|
||
}
|
||
}
|
||
} else {
|
||
// ambiguous widget (strictlyOnWidget always true)
|
||
const draggedWidget = app.canvas.processNodeWidgets(node, pos, event);
|
||
widgetIndex = widgetIndices.findIndex((index) => {
|
||
return draggedWidget == node.widgets[index]; // == check same object
|
||
});
|
||
}
|
||
}
|
||
|
||
if (widgetIndex !== -1) {
|
||
node.widgets[widgetIndex].value = path;
|
||
app.canvas.selectNode(node);
|
||
} else {
|
||
const expectedNodeType = modelNodeType[modelType];
|
||
const newNode = LiteGraph.createNode(expectedNodeType, null, []);
|
||
let newWidgetIndex = ModelGrid.modelWidgetIndex(expectedNodeType);
|
||
if (newWidgetIndex === -1) {
|
||
newWidgetIndex = this.#getWidgetComboIndices(newNode, path)[0] ?? -1;
|
||
}
|
||
if (
|
||
newNode !== undefined &&
|
||
newNode !== null &&
|
||
newWidgetIndex !== -1
|
||
) {
|
||
newNode.pos[0] = pos[0];
|
||
newNode.pos[1] = pos[1];
|
||
newNode.widgets[newWidgetIndex].value = path;
|
||
app.graph.add(newNode, { doProcessChange: true });
|
||
app.canvas.selectNode(newNode);
|
||
}
|
||
}
|
||
event.stopPropagation();
|
||
} else if (modelType === 'embeddings' && target.type === 'textarea') {
|
||
const pos = app.canvas.convertEventToCanvasOffset(event);
|
||
const nodeAtPos = app.graph.getNodeOnPos(
|
||
pos[0],
|
||
pos[1],
|
||
app.canvas.visible_nodes,
|
||
);
|
||
if (nodeAtPos) {
|
||
app.canvas.selectNode(nodeAtPos);
|
||
const [embeddingDirectory, embeddingFile] = SearchPath.split(path);
|
||
target.value = ModelGrid.insertEmbeddingIntoText(
|
||
target.value,
|
||
embeddingFile,
|
||
removeEmbeddingExtension,
|
||
);
|
||
event.stopPropagation();
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* @param {Event} event
|
||
* @param {string} modelType
|
||
* @param {string} path
|
||
* @param {boolean} removeEmbeddingExtension
|
||
*/
|
||
static #copyModelToClipboard(
|
||
event,
|
||
modelType,
|
||
path,
|
||
removeEmbeddingExtension,
|
||
) {
|
||
const nodeType = modelNodeType[modelType];
|
||
let success = false;
|
||
if (nodeType === 'Embedding') {
|
||
if (navigator.clipboard) {
|
||
const [embeddingDirectory, embeddingFile] = SearchPath.split(path);
|
||
const embeddingText = ModelGrid.insertEmbeddingIntoText(
|
||
'',
|
||
embeddingFile,
|
||
removeEmbeddingExtension,
|
||
);
|
||
navigator.clipboard.writeText(embeddingText);
|
||
success = true;
|
||
} else {
|
||
console.warn(
|
||
'Cannot copy the embedding to the system clipboard; Try dragging it instead.',
|
||
);
|
||
}
|
||
} else if (nodeType) {
|
||
const node = LiteGraph.createNode(nodeType, null, []);
|
||
const widgetIndex = ModelGrid.modelWidgetIndex(nodeType);
|
||
if (widgetIndex !== -1) {
|
||
node.widgets[widgetIndex].value = path;
|
||
app.canvas.copyToClipboard([node]);
|
||
success = true;
|
||
}
|
||
} else {
|
||
console.warn(`Unable to copy unknown model type '${modelType}.`);
|
||
}
|
||
comfyButtonAlert(
|
||
event.target,
|
||
success,
|
||
'mdi-check-bold',
|
||
'mdi-close-thick',
|
||
);
|
||
}
|
||
|
||
/**
|
||
* @param {Array} models
|
||
* @param {string} modelType
|
||
* @param {Object.<HTMLInputElement>} settingsElements
|
||
* @param {String} searchSeparator
|
||
* @param {String} systemSeparator
|
||
* @param {(searchPath: string) => Promise<void>} showModelInfo
|
||
* @returns {HTMLElement[]}
|
||
*/
|
||
static #generateInnerHtml(
|
||
models,
|
||
modelType,
|
||
settingsElements,
|
||
searchSeparator,
|
||
systemSeparator,
|
||
showModelInfo,
|
||
) {
|
||
// TODO: separate text and model logic; getting too messy
|
||
// TODO: fallback on button failure to copy text?
|
||
const canShowButtons = modelNodeType[modelType] !== undefined;
|
||
const shouldShowTryOpenModelUrl =
|
||
canShowButtons &&
|
||
settingsElements['model-show-open-model-url-button'].checked;
|
||
const showLoadWorkflowButton =
|
||
canShowButtons &&
|
||
settingsElements['model-show-load-workflow-button'].checked;
|
||
const showAddButton =
|
||
canShowButtons && settingsElements['model-show-add-button'].checked;
|
||
const showCopyButton =
|
||
canShowButtons && settingsElements['model-show-copy-button'].checked;
|
||
const strictDragToAdd =
|
||
settingsElements['model-add-drag-strict-on-field'].checked;
|
||
const addOffset = parseInt(settingsElements['model-add-offset'].value);
|
||
const showModelExtension =
|
||
settingsElements['model-show-label-extensions'].checked;
|
||
const modelInfoButtonOnLeft =
|
||
!settingsElements['model-info-button-on-left'].checked;
|
||
const removeEmbeddingExtension =
|
||
!settingsElements['model-add-embedding-extension'].checked;
|
||
const previewThumbnailFormat =
|
||
settingsElements['model-preview-thumbnail-type'].value;
|
||
if (models.length > 0) {
|
||
|
||
const $overlay = IS_FIREFOX
|
||
? ((
|
||
modelType,
|
||
path,
|
||
removeEmbeddingExtension,
|
||
strictDragToAdd,
|
||
) => {
|
||
return $el('div.model-preview-overlay', {
|
||
ondragstart: (e) =>{
|
||
const data = {
|
||
modelType: modelType,
|
||
path: path,
|
||
removeEmbeddingExtension: removeEmbeddingExtension,
|
||
strictDragToAdd: strictDragToAdd,
|
||
};
|
||
e.dataTransfer.setData('manager-model', JSON.stringify(data));
|
||
e.dataTransfer.setData('text/plain', '');
|
||
},
|
||
draggable: true,
|
||
});
|
||
})
|
||
: ((
|
||
modelType,
|
||
path,
|
||
removeEmbeddingExtension,
|
||
strictDragToAdd,
|
||
) => {
|
||
return $el('div.model-preview-overlay', {
|
||
ondragend: (e) =>
|
||
ModelGrid.dragAddModel(
|
||
e,
|
||
modelType,
|
||
path,
|
||
removeEmbeddingExtension,
|
||
strictDragToAdd,
|
||
),
|
||
draggable: true,
|
||
});
|
||
});
|
||
|
||
return models.map((item) => {
|
||
const previewInfo = item.preview;
|
||
const previewThumbnail = $el('img.model-preview', {
|
||
loading:
|
||
'lazy' /* `loading` BEFORE `src`; Known bug in Firefox 124.0.2 and Safari for iOS 17.4.1 (https://stackoverflow.com/a/76252772) */,
|
||
src: imageUri(
|
||
previewInfo?.path ? encodeURIComponent(previewInfo.path) : undefined,
|
||
previewInfo?.dateModified ? encodeURIComponent(previewInfo.dateModified) : undefined,
|
||
PREVIEW_THUMBNAIL_WIDTH,
|
||
PREVIEW_THUMBNAIL_HEIGHT,
|
||
previewThumbnailFormat,
|
||
),
|
||
draggable: false,
|
||
});
|
||
const searchPath = item.path;
|
||
const path = SearchPath.systemPath(
|
||
searchPath,
|
||
searchSeparator,
|
||
systemSeparator,
|
||
);
|
||
let actionButtons = [];
|
||
if (shouldShowTryOpenModelUrl) {
|
||
actionButtons.push(
|
||
new ComfyButton({
|
||
icon: 'open-in-new',
|
||
tooltip: 'Attempt to open model url page in a new tab.',
|
||
classList: 'comfyui-button icon-button model-button',
|
||
action: async (e) => {
|
||
const [button, icon, span] = comfyButtonDisambiguate(e.target);
|
||
button.disabled = true;
|
||
const webUrl = await tryGetModelWebUrl(searchPath);
|
||
const success = tryOpenUrl(webUrl, searchPath);
|
||
comfyButtonAlert(e.target, success, 'mdi-check-bold', 'mdi-close-thick');
|
||
button.disabled = false;
|
||
},
|
||
}).element
|
||
);
|
||
}
|
||
if (showLoadWorkflowButton) {
|
||
actionButtons.push(
|
||
new ComfyButton({
|
||
icon: 'arrow-bottom-left-bold-box-outline',
|
||
tooltip: 'Load preview workflow',
|
||
classList: 'comfyui-button icon-button model-button',
|
||
action: async (e) => {
|
||
const urlString = previewThumbnail.src;
|
||
const url = new URL(urlString);
|
||
const urlSearchParams = url.searchParams;
|
||
const uri = urlSearchParams.get('uri');
|
||
const v = urlSearchParams.get('v');
|
||
const urlFull =
|
||
urlString.substring(0, urlString.indexOf('?')) +
|
||
'?uri=' +
|
||
uri +
|
||
'&v=' +
|
||
v;
|
||
await loadWorkflow(urlFull);
|
||
},
|
||
}).element,
|
||
);
|
||
}
|
||
if (showAddButton) {
|
||
actionButtons.push(
|
||
new ComfyButton({
|
||
icon: 'plus-box-outline',
|
||
tooltip: 'Add model to node grid',
|
||
classList: 'comfyui-button icon-button model-button',
|
||
action: (e) =>
|
||
ModelGrid.#addModel(
|
||
e,
|
||
modelType,
|
||
path,
|
||
removeEmbeddingExtension,
|
||
addOffset,
|
||
),
|
||
}).element,
|
||
);
|
||
}
|
||
if (
|
||
showCopyButton &&
|
||
!(modelType === 'embeddings' && !navigator.clipboard)
|
||
) {
|
||
actionButtons.push(
|
||
new ComfyButton({
|
||
icon: 'content-copy',
|
||
tooltip: 'Copy model to clipboard',
|
||
classList: 'comfyui-button icon-button model-button',
|
||
action: (e) =>
|
||
ModelGrid.#copyModelToClipboard(
|
||
e,
|
||
modelType,
|
||
path,
|
||
removeEmbeddingExtension,
|
||
),
|
||
}).element,
|
||
);
|
||
}
|
||
const infoButtons = [
|
||
new ComfyButton({
|
||
icon: 'information-outline',
|
||
tooltip: 'View model information',
|
||
classList: 'comfyui-button icon-button model-button',
|
||
action: async(e) => {
|
||
const [button, icon, span] = comfyButtonDisambiguate(e.target);
|
||
button.disabled = true;
|
||
await showModelInfo(searchPath);
|
||
button.disabled = false;
|
||
},
|
||
}).element,
|
||
];
|
||
return $el('div.item', {}, [
|
||
previewThumbnail,
|
||
$overlay(
|
||
modelType,
|
||
path,
|
||
removeEmbeddingExtension,
|
||
strictDragToAdd,
|
||
),
|
||
$el(
|
||
'div.model-preview-top-right',
|
||
{
|
||
draggable: false,
|
||
},
|
||
modelInfoButtonOnLeft ? infoButtons : actionButtons,
|
||
),
|
||
$el(
|
||
'div.model-preview-top-left',
|
||
{
|
||
draggable: false,
|
||
},
|
||
modelInfoButtonOnLeft ? actionButtons : infoButtons,
|
||
),
|
||
$el(
|
||
'div.model-label',
|
||
{
|
||
draggable: false,
|
||
},
|
||
[
|
||
$el('p', [
|
||
showModelExtension
|
||
? item.name
|
||
: SearchPath.splitExtension(item.name)[0],
|
||
]),
|
||
],
|
||
),
|
||
]);
|
||
});
|
||
} else {
|
||
return [$el('h2', ['No Models'])];
|
||
}
|
||
}
|
||
|
||
/**
|
||
* @param {HTMLDivElement} modelGrid
|
||
* @param {ModelData} modelData
|
||
* @param {HTMLSelectElement} modelSelect
|
||
* @param {Object.<{value: string}>} previousModelType
|
||
* @param {Object} settings
|
||
* @param {string} sortBy
|
||
* @param {boolean} reverseSort
|
||
* @param {Array} previousModelFilters
|
||
* @param {HTMLInputElement} modelFilter
|
||
* @param {(searchPath: string) => Promise<void>} showModelInfo
|
||
*/
|
||
static update(
|
||
modelGrid,
|
||
modelData,
|
||
modelSelect,
|
||
previousModelType,
|
||
settings,
|
||
sortBy,
|
||
reverseSort,
|
||
previousModelFilters,
|
||
modelFilter,
|
||
showModelInfo,
|
||
) {
|
||
const models = modelData.models;
|
||
let modelType = modelSelect.value;
|
||
if (models[modelType] === undefined) {
|
||
modelType = settings['model-default-browser-model-type'].value;
|
||
}
|
||
if (models[modelType] === undefined) {
|
||
modelType = 'checkpoints'; // panic fallback
|
||
}
|
||
|
||
if (modelType !== previousModelType.value) {
|
||
if (settings['model-persistent-search'].checked) {
|
||
previousModelFilters.splice(0, previousModelFilters.length); // TODO: make sure this actually worked!
|
||
} else {
|
||
// cache previous filter text
|
||
previousModelFilters[previousModelType.value] = modelFilter.value;
|
||
// read cached filter text
|
||
modelFilter.value = previousModelFilters[modelType] ?? '';
|
||
}
|
||
previousModelType.value = modelType;
|
||
}
|
||
|
||
let modelTypeOptions = [];
|
||
for (const key of Object.keys(models)) {
|
||
const el = $el('option', [key]);
|
||
modelTypeOptions.push(el);
|
||
}
|
||
modelTypeOptions.sort((a, b) =>
|
||
a.innerText.localeCompare(
|
||
b.innerText,
|
||
undefined,
|
||
{sensitivity : 'base'},
|
||
)
|
||
);
|
||
modelSelect.innerHTML = "";
|
||
modelTypeOptions.forEach(option => modelSelect.add(option));
|
||
modelSelect.value = modelType;
|
||
|
||
const searchAppend = settings['model-search-always-append'].value;
|
||
const searchText = modelFilter.value + ' ' + searchAppend;
|
||
const modelList = ModelGrid.#filter(models[modelType], searchText);
|
||
ModelGrid.#sort(modelList, sortBy, reverseSort);
|
||
|
||
modelGrid.innerHTML = '';
|
||
const modelGridModels = ModelGrid.#generateInnerHtml(
|
||
modelList,
|
||
modelType,
|
||
settings,
|
||
modelData.searchSeparator,
|
||
modelData.systemSeparator,
|
||
showModelInfo,
|
||
);
|
||
modelGrid.append.apply(modelGrid, modelGridModels);
|
||
}
|
||
}
|
||
|
||
class ModelInfo {
|
||
/** @type {HTMLDivElement} */
|
||
element = null;
|
||
|
||
elements = {
|
||
/** @type {HTMLDivElement[]} */ tabButtons: null,
|
||
/** @type {HTMLDivElement[]} */ tabContents: null,
|
||
/** @type {HTMLDivElement} */ info: null,
|
||
/** @type {HTMLTextAreaElement} */ notes: null,
|
||
/** @type {HTMLButtonElement} */ setPreviewButton: null,
|
||
/** @type {HTMLInputElement} */ moveDestinationInput: null,
|
||
};
|
||
|
||
/** @type {ImageSelect} */
|
||
previewSelect = null;
|
||
|
||
/** @type {string} */
|
||
#savedNotesValue = null;
|
||
|
||
/** @type {[HTMLElement][]} */
|
||
#settingsElements = null;
|
||
|
||
/** @type {() -> Promise<void>} */
|
||
#tryHideModelInfo = () => {};
|
||
|
||
/**
|
||
* @param {ModelData} modelData
|
||
* @param {() => Promise<void>} updateModels
|
||
* @param {any} settingsElements
|
||
* @param {() => Promise<void>} tryHideModelInfo
|
||
*/
|
||
constructor(modelData, updateModels, settingsElements, tryHideModelInfo) {
|
||
this.#settingsElements = settingsElements;
|
||
this.#tryHideModelInfo = tryHideModelInfo;
|
||
const moveDestinationInput = $el('input.search-text-area', {
|
||
name: 'move directory',
|
||
autocomplete: 'off',
|
||
placeholder: modelData.searchSeparator,
|
||
value: modelData.searchSeparator,
|
||
});
|
||
this.elements.moveDestinationInput = moveDestinationInput;
|
||
|
||
const searchDropdown = new DirectoryDropdown(
|
||
modelData,
|
||
moveDestinationInput,
|
||
true,
|
||
);
|
||
|
||
const previewSelect = new ImageSelect('model-info-preview-model-FYUIKMNVB');
|
||
this.previewSelect = previewSelect;
|
||
previewSelect.elements.previews.style.display = 'flex';
|
||
|
||
const setPreviewButton = new ComfyButton({
|
||
tooltip: 'Overwrite current preview with selected image',
|
||
content: 'Set as Preview',
|
||
action: async (e) => {
|
||
const [button, icon, span] = comfyButtonDisambiguate(e.target);
|
||
button.disabled = true;
|
||
const confirmation = window.confirm(
|
||
'Change preview image(s) PERMANENTLY?',
|
||
);
|
||
let updatedPreview = false;
|
||
if (confirmation) {
|
||
const container = this.elements.info;
|
||
const path = container.dataset.path;
|
||
const imageUrl = await previewSelect.getImage();
|
||
if (imageUrl === PREVIEW_NONE_URI) {
|
||
const encodedPath = encodeURIComponent(path);
|
||
updatedPreview = await comfyRequest(
|
||
`/model-manager/preview/delete?path=${encodedPath}`,
|
||
{
|
||
method: 'POST',
|
||
body: JSON.stringify({}),
|
||
},
|
||
)
|
||
.then((result) => {
|
||
const message = result['alert'];
|
||
if (message !== undefined) {
|
||
window.alert(message);
|
||
}
|
||
return result['success'];
|
||
})
|
||
.catch((err) => {
|
||
return false;
|
||
});
|
||
} else {
|
||
const formData = new FormData();
|
||
formData.append('path', path);
|
||
const image = imageUrl[0] == '/' ? '' : imageUrl;
|
||
formData.append('image', image);
|
||
updatedPreview = await comfyRequest(`/model-manager/preview/set`, {
|
||
method: 'POST',
|
||
body: formData,
|
||
})
|
||
.then((result) => {
|
||
const message = result['alert'];
|
||
if (message !== undefined) {
|
||
window.alert(message);
|
||
}
|
||
return result['success'];
|
||
})
|
||
.catch((err) => {
|
||
return false;
|
||
});
|
||
}
|
||
if (updatedPreview) {
|
||
updateModels();
|
||
const previewSelect = this.previewSelect;
|
||
previewSelect.elements.defaultUrl.dataset.noimage =
|
||
PREVIEW_NONE_URI;
|
||
previewSelect.resetModelInfoPreview();
|
||
this.element.style.display = 'none';
|
||
}
|
||
}
|
||
comfyButtonAlert(e.target, updatedPreview);
|
||
button.disabled = false;
|
||
},
|
||
}).element;
|
||
this.elements.setPreviewButton = setPreviewButton;
|
||
|
||
this.element = $el(
|
||
'div',
|
||
{
|
||
style: { display: 'none' },
|
||
},
|
||
[
|
||
$el(
|
||
'div.row.tab-header',
|
||
{
|
||
display: 'block',
|
||
},
|
||
[
|
||
$el('div.row.tab-header-flex-block', [
|
||
new ComfyButton({
|
||
icon: 'trash-can-outline',
|
||
tooltip: 'Delete model FOREVER',
|
||
classList: 'comfyui-button icon-button',
|
||
action: async (e) => {
|
||
const [button, icon, span] = comfyButtonDisambiguate(
|
||
e.target,
|
||
);
|
||
button.disabled = true;
|
||
const affirmation = 'delete';
|
||
const confirmation = window.prompt(
|
||
'Type "' +
|
||
affirmation +
|
||
'" to delete the model PERMANENTLY.\n\nThis includes all image or text files.',
|
||
);
|
||
let deleted = false;
|
||
if (confirmation === affirmation) {
|
||
const container = this.elements.info;
|
||
const path = encodeURIComponent(container.dataset.path);
|
||
deleted = await comfyRequest(
|
||
`/model-manager/model/delete?path=${path}`,
|
||
{
|
||
method: 'POST',
|
||
},
|
||
)
|
||
.then((result) => {
|
||
const deleted = result['success'];
|
||
const message = result['alert'];
|
||
if (message !== undefined) {
|
||
window.alert(message);
|
||
}
|
||
if (deleted) {
|
||
container.innerHTML = '';
|
||
this.element.style.display = 'none';
|
||
updateModels();
|
||
}
|
||
return deleted;
|
||
})
|
||
.catch((err) => {
|
||
return false;
|
||
});
|
||
}
|
||
if (!deleted) {
|
||
comfyButtonAlert(e.target, false);
|
||
}
|
||
button.disabled = false;
|
||
},
|
||
}).element,
|
||
$el('div.search-models.input-dropdown-container', [
|
||
// TODO: magic class
|
||
moveDestinationInput,
|
||
searchDropdown.element,
|
||
]),
|
||
new ComfyButton({
|
||
icon: 'file-move-outline',
|
||
tooltip: 'Move file',
|
||
action: async (e) => {
|
||
const [button, icon, span] = comfyButtonDisambiguate(
|
||
e.target,
|
||
);
|
||
button.disabled = true;
|
||
const confirmation = window.confirm('Move this file?');
|
||
let moved = false;
|
||
if (confirmation) {
|
||
const container = this.elements.info;
|
||
const oldFile = container.dataset.path;
|
||
const [oldFilePath, oldFileName] =
|
||
SearchPath.split(oldFile);
|
||
const newFile =
|
||
moveDestinationInput.value +
|
||
modelData.searchSeparator +
|
||
oldFileName;
|
||
moved = await comfyRequest(`/model-manager/model/move`, {
|
||
method: 'POST',
|
||
body: JSON.stringify({
|
||
oldFile: oldFile,
|
||
newFile: newFile,
|
||
}),
|
||
})
|
||
.then((result) => {
|
||
const moved = result['success'];
|
||
const message = result['alert'];
|
||
if (message !== undefined) {
|
||
window.alert(message);
|
||
}
|
||
if (moved) {
|
||
moveDestinationInput.value = '';
|
||
container.innerHTML = '';
|
||
this.element.style.display = 'none';
|
||
updateModels();
|
||
}
|
||
return moved;
|
||
})
|
||
.catch((err) => {
|
||
return false;
|
||
});
|
||
}
|
||
comfyButtonAlert(e.target, moved);
|
||
button.disabled = false;
|
||
},
|
||
}).element,
|
||
]),
|
||
],
|
||
),
|
||
$el('div.model-info-container', {
|
||
$: (el) => (this.elements.info = el),
|
||
'data-path': '',
|
||
}),
|
||
],
|
||
);
|
||
|
||
[this.elements.tabButtons, this.elements.tabContents] = GenerateTabGroup([
|
||
{
|
||
name: 'Overview',
|
||
icon: 'information-box-outline',
|
||
tabContent: this.element,
|
||
},
|
||
{
|
||
name: 'Notes',
|
||
icon: 'pencil-outline',
|
||
tabContent: $el('div', ['Notes']),
|
||
},
|
||
{
|
||
name: 'Tags',
|
||
icon: 'tag-outline',
|
||
tabContent: $el('div', ['Tags']),
|
||
},
|
||
{
|
||
name: 'Metadata',
|
||
icon: 'file-document-outline',
|
||
tabContent: $el('div', ['Metadata']),
|
||
},
|
||
]);
|
||
}
|
||
|
||
/** @returns {void} */
|
||
show() {
|
||
this.element.style = '';
|
||
this.element.scrollTop = 0;
|
||
}
|
||
|
||
/**
|
||
* @param {boolean} promptUser
|
||
* @returns {Promise<boolean>}
|
||
*/
|
||
async trySave(promptUser) {
|
||
if (this.element.style.display === 'none') {
|
||
return true;
|
||
}
|
||
|
||
const noteValue = this.elements.notes.value;
|
||
const savedNotesValue = this.#savedNotesValue;
|
||
if (noteValue.trim() === savedNotesValue.trim()) {
|
||
return true;
|
||
}
|
||
const saveChanges = !promptUser || window.confirm('Save notes?');
|
||
if (saveChanges) {
|
||
const path = this.elements.info.dataset.path;
|
||
const saved = await saveNotes(path, noteValue);
|
||
if (!saved) {
|
||
window.alert('Failed to save notes!');
|
||
return false;
|
||
}
|
||
this.#savedNotesValue = noteValue;
|
||
this.elements.markdown.innerHTML = marked.parse(noteValue);
|
||
} else {
|
||
const discardChanges = window.confirm('Discard changes?');
|
||
if (!discardChanges) {
|
||
return false;
|
||
} else {
|
||
this.elements.notes.value = savedNotesValue;
|
||
}
|
||
}
|
||
return true;
|
||
}
|
||
|
||
/**
|
||
* @param {boolean?} promptSave
|
||
* @returns {Promise<boolean>}
|
||
*/
|
||
async tryHide(promptSave = true) {
|
||
const notes = this.elements.notes;
|
||
if (promptSave && notes !== undefined && notes !== null) {
|
||
const saved = await this.trySave(promptSave);
|
||
if (!saved) {
|
||
return false;
|
||
}
|
||
this.#savedNotesValue = '';
|
||
this.elements.notes.value = '';
|
||
}
|
||
this.element.style.display = 'none';
|
||
return true;
|
||
}
|
||
|
||
/**
|
||
* @param {string} searchPath
|
||
* @param {() => Promise<void>} updateModels
|
||
* @param {string} searchSeparator
|
||
*/
|
||
async update(searchPath, updateModels, searchSeparator) {
|
||
const path = encodeURIComponent(searchPath);
|
||
const [info, metadata, tags, noteText, url, webPreviews] = await comfyRequest(
|
||
`/model-manager/model/info/${path}`,
|
||
)
|
||
.then((result) => {
|
||
const success = result['success'];
|
||
const message = result['alert'];
|
||
if (message !== undefined) {
|
||
window.alert(message);
|
||
}
|
||
if (!success) {
|
||
return undefined;
|
||
}
|
||
return [
|
||
result['info'],
|
||
result['metadata'],
|
||
result['tags'],
|
||
result['notes'],
|
||
result['url'],
|
||
result['webPreviews'],
|
||
];
|
||
})
|
||
.catch((err) => {
|
||
console.log(err);
|
||
return undefined;
|
||
});
|
||
if (info === undefined || info === null) {
|
||
return;
|
||
}
|
||
const infoHtml = this.elements.info;
|
||
infoHtml.innerHTML = '';
|
||
infoHtml.dataset.path = searchPath;
|
||
const innerHtml = [];
|
||
const filename = info['File Name'];
|
||
if (filename !== undefined && filename !== null && filename !== '') {
|
||
innerHtml.push(
|
||
$el(
|
||
'div.row',
|
||
{
|
||
style: { margin: '8px 0 16px 0' },
|
||
},
|
||
[
|
||
$el(
|
||
'h1',
|
||
{
|
||
style: { margin: '0' },
|
||
},
|
||
[filename],
|
||
),
|
||
$el('div', [
|
||
new ComfyButton({
|
||
icon: 'pencil',
|
||
tooltip: 'Change file name',
|
||
classList: 'comfyui-button icon-button',
|
||
action: async (e) => {
|
||
const [button, icon, span] = comfyButtonDisambiguate(
|
||
e.target,
|
||
);
|
||
button.disabled = true;
|
||
const container = this.elements.info;
|
||
const oldFile = container.dataset.path;
|
||
const [oldFilePath, oldFileName] = SearchPath.split(oldFile);
|
||
const oldName = SearchPath.splitExtension(oldFileName)[0];
|
||
const newName = window.prompt('New model name:', oldName);
|
||
let renamed = false;
|
||
if (
|
||
newName !== null &&
|
||
newName !== '' &&
|
||
newName != oldName
|
||
) {
|
||
const newFile =
|
||
oldFilePath +
|
||
searchSeparator +
|
||
newName +
|
||
SearchPath.splitExtension(oldFile)[1];
|
||
renamed = await comfyRequest(`/model-manager/model/move`, {
|
||
method: 'POST',
|
||
body: JSON.stringify({
|
||
oldFile: oldFile,
|
||
newFile: newFile,
|
||
}),
|
||
})
|
||
.then((result) => {
|
||
const renamed = result['success'];
|
||
const message = result['alert'];
|
||
if (message !== undefined) {
|
||
window.alert(message);
|
||
}
|
||
if (renamed) {
|
||
container.innerHTML = '';
|
||
this.element.style.display = 'none';
|
||
updateModels();
|
||
}
|
||
return renamed;
|
||
})
|
||
.catch((err) => {
|
||
console.log(err);
|
||
return false;
|
||
});
|
||
}
|
||
comfyButtonAlert(e.target, renamed);
|
||
button.disabled = false;
|
||
},
|
||
}).element,
|
||
]),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
const fileDirectory = info['File Directory'];
|
||
if (
|
||
fileDirectory !== undefined &&
|
||
fileDirectory !== null &&
|
||
fileDirectory !== ''
|
||
) {
|
||
this.elements.moveDestinationInput.placeholder = fileDirectory;
|
||
this.elements.moveDestinationInput.value = fileDirectory; // TODO: noise vs convenience
|
||
} else {
|
||
this.elements.moveDestinationInput.placeholder = searchSeparator;
|
||
this.elements.moveDestinationInput.value = searchSeparator;
|
||
}
|
||
|
||
const previewSelect = this.previewSelect;
|
||
const defaultUrl = previewSelect.elements.defaultUrl;
|
||
if (info['Preview']) {
|
||
const imagePath = encodeURIComponent(info['Preview']['path']);
|
||
const imageDateModified = encodeURIComponent(info['Preview']['dateModified']);
|
||
defaultUrl.dataset.noimage = imageUri(imagePath, imageDateModified);
|
||
} else {
|
||
defaultUrl.dataset.noimage = PREVIEW_NONE_URI;
|
||
}
|
||
previewSelect.resetModelInfoPreview(webPreviews);
|
||
const setPreviewDiv = $el('div.row.tab-header', {
|
||
style: {
|
||
display: "none"
|
||
}
|
||
}, [
|
||
$el('div.row.tab-header-flex-block', [
|
||
previewSelect.elements.radioGroup,
|
||
]),
|
||
$el('div.row.tab-header-flex-block', [
|
||
this.elements.setPreviewButton,
|
||
]),
|
||
]);
|
||
previewSelect.elements.previews.style.display = "none";
|
||
|
||
let previewUri;
|
||
if (info['Preview']) {
|
||
const imagePath = encodeURIComponent(info['Preview']['path']);
|
||
const imageDateModified = encodeURIComponent(info['Preview']['dateModified']);
|
||
previewUri = imageUri(imagePath, imageDateModified);
|
||
} else {
|
||
previewUri = PREVIEW_NONE_URI;
|
||
}
|
||
const previewImage = $el('img.model-preview-full', {
|
||
loading:
|
||
'lazy' /* `loading` BEFORE `src`; Known bug in Firefox 124.0.2 and Safari for iOS 17.4.1 (https://stackoverflow.com/a/76252772) */,
|
||
src: previewUri,
|
||
});
|
||
|
||
innerHtml.push(
|
||
$el('div', [
|
||
$el('div.row.tab-header', { style: { "flex-direction": "row" } }, [
|
||
new ComfyButton({
|
||
icon: 'arrow-bottom-left-bold-box-outline',
|
||
tooltip: 'Attempt to load preview image workflow',
|
||
classList: 'comfyui-button icon-button',
|
||
action: async () => {
|
||
await loadWorkflow(previewImage.src);
|
||
},
|
||
}).element,
|
||
new ComfyButton({
|
||
icon: 'open-in-new',
|
||
tooltip: 'Attempt to open model url page in a new tab.',
|
||
classList: 'comfyui-button icon-button',
|
||
action: async (e) => {
|
||
const [button, icon, span] = comfyButtonDisambiguate(e.target);
|
||
button.disabled = true;
|
||
let webUrl;
|
||
if (url !== undefined && url !== "") {
|
||
webUrl = url;
|
||
}
|
||
else {
|
||
webUrl = await tryGetModelWebUrl(searchPath);
|
||
}
|
||
const success = tryOpenUrl(webUrl, searchPath);
|
||
comfyButtonAlert(e.target, success, "mdi-check-bold", "mdi-close-thick");
|
||
button.disabled = false;
|
||
},
|
||
}).element,
|
||
new ComfyButton({
|
||
icon: 'earth-arrow-down',
|
||
tooltip: 'Hash model and try to download model info.',
|
||
classList: 'comfyui-button icon-button',
|
||
action: async(e) => {
|
||
const confirm = window.confirm('Overwrite model info?');
|
||
if (!confirm) {
|
||
comfyButtonAlert(e.target, false, 'mdi-check-bold', 'mdi-close-thick');
|
||
return;
|
||
}
|
||
const [button, icon, span] = comfyButtonDisambiguate(e.target);
|
||
button.disabled = true;
|
||
const success = await comfyRequest(
|
||
`/model-manager/model/download/info?path=${path}`,
|
||
{
|
||
method: 'POST',
|
||
body: {},
|
||
}
|
||
).then((data) => {
|
||
const success = data['success'];
|
||
const message = data['alert'];
|
||
if (message !== undefined) {
|
||
window.alert(message);
|
||
}
|
||
return success;
|
||
}).catch((err) => {
|
||
return false;
|
||
});
|
||
if (success) {
|
||
this.#tryHideModelInfo();
|
||
}
|
||
comfyButtonAlert(e.target, success, 'mdi-check-bold', 'mdi-close-thick');
|
||
button.disabled = false;
|
||
},
|
||
}).element,
|
||
new ComfyButton({
|
||
icon: 'image-edit-outline',
|
||
tooltip: 'Open preview edit dialog.',
|
||
classList: 'comfyui-button icon-button',
|
||
action: () => {
|
||
// TODO: toggle button border highlight
|
||
if (previewImage.style.display === "none") {
|
||
setPreviewDiv.style.display = "none";
|
||
previewSelect.elements.previews.style.display = "none";
|
||
previewImage.style.display = "";
|
||
}
|
||
else {
|
||
previewImage.style.display = "none";
|
||
previewSelect.elements.previews.style.display = "";
|
||
setPreviewDiv.style.display = "";
|
||
if (previewSelect.elements.defaultPreviews.children[0].src.includes(PREVIEW_NONE_URI)) {
|
||
window.alert("No model previews found!\nTry downloading model info first!");
|
||
}
|
||
}
|
||
},
|
||
}).element,
|
||
]),
|
||
previewImage,
|
||
previewSelect.elements.previews,
|
||
setPreviewDiv,
|
||
$el('h2', ['File Info:']),
|
||
$el(
|
||
'div',
|
||
(() => {
|
||
const elements = [];
|
||
for (const [key, value] of Object.entries(info)) {
|
||
if (value === undefined || value === null) {
|
||
continue;
|
||
}
|
||
|
||
if (Array.isArray(value)) {
|
||
// currently only used for "Bucket Resolutions"
|
||
if (value.length > 0) {
|
||
elements.push($el('h2', [key + ':']));
|
||
const text = TagCountMapToParagraph(value);
|
||
const div = $el('div');
|
||
div.innerHTML = text;
|
||
elements.push(div);
|
||
}
|
||
} else {
|
||
if (key === 'Description') {
|
||
if (value !== '') {
|
||
elements.push($el('h2', [key + ':']));
|
||
elements.push($el('p', [value]));
|
||
}
|
||
} else if (key === 'Preview') {
|
||
//
|
||
} else {
|
||
if (value !== '') {
|
||
elements.push($el('p', [key + ': ' + value]));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
return elements;
|
||
})(),
|
||
),
|
||
]),
|
||
);
|
||
infoHtml.append.apply(infoHtml, innerHtml);
|
||
// TODO: set default value of dropdown and value to model type?
|
||
|
||
//
|
||
// NOTES
|
||
//
|
||
|
||
const saveIcon = 'content-save';
|
||
const savingIcon = 'cloud-upload-outline';
|
||
|
||
const saveNotesButton = new ComfyButton({
|
||
icon: saveIcon,
|
||
tooltip: 'Save note',
|
||
classList: 'comfyui-button icon-button',
|
||
action: async (e) => {
|
||
const [button, icon, span] = comfyButtonDisambiguate(e.target);
|
||
button.disabled = true;
|
||
const saved = await this.trySave(false);
|
||
comfyButtonAlert(e.target, saved);
|
||
button.disabled = false;
|
||
},
|
||
}).element;
|
||
|
||
const downloadNotesButton = new ComfyButton({
|
||
icon: 'earth-arrow-down',
|
||
tooltip: 'Attempt to download model info from the internet.',
|
||
classList: 'comfyui-button icon-button',
|
||
action: async (e) => {
|
||
if (this.#savedNotesValue !== '') {
|
||
const overwriteNoteConfirmation = window.confirm('Overwrite note?');
|
||
if (!overwriteNoteConfirmation) {
|
||
comfyButtonAlert(e.target, false, 'mdi-check-bold', 'mdi-close-thick');
|
||
return;
|
||
}
|
||
}
|
||
|
||
const [button, icon, span] = comfyButtonDisambiguate(e.target);
|
||
button.disabled = true;
|
||
const [success, downloadedNotesValue] = await comfyRequest(
|
||
`/model-manager/notes/download?path=${path}&overwrite=True`,
|
||
{
|
||
method: 'POST',
|
||
body: {},
|
||
}
|
||
).then((data) => {
|
||
const success = data['success'];
|
||
const message = data['alert'];
|
||
if (message !== undefined) {
|
||
window.alert(message);
|
||
}
|
||
return [success, data['notes']];
|
||
}).catch((err) => {
|
||
return [false, ''];
|
||
});
|
||
if (success) {
|
||
this.#savedNotesValue = downloadedNotesValue;
|
||
this.elements.notes.value = downloadedNotesValue;
|
||
this.elements.markdown.innerHTML = marked.parse(downloadedNotesValue);
|
||
}
|
||
comfyButtonAlert(e.target, success, 'mdi-check-bold', 'mdi-close-thick');
|
||
button.disabled = false;
|
||
},
|
||
}).element;
|
||
|
||
const saveDebounce = debounce(async () => {
|
||
const saveIconClass = 'mdi-' + saveIcon;
|
||
const savingIconClass = 'mdi-' + savingIcon;
|
||
const iconElement = saveNotesButton.getElementsByTagName('i')[0];
|
||
iconElement.classList.remove(saveIconClass);
|
||
iconElement.classList.add(savingIconClass);
|
||
const saved = await this.trySave(false);
|
||
iconElement.classList.remove(savingIconClass);
|
||
iconElement.classList.add(saveIconClass);
|
||
}, 1000);
|
||
|
||
/** @type {HTMLDivElement} */
|
||
const notesElement = this.elements.tabContents[1]; // TODO: remove magic value
|
||
notesElement.innerHTML = '';
|
||
const markdown = $el('div', {}, '');
|
||
markdown.innerHTML = marked.parse(noteText);
|
||
|
||
notesElement.append.apply(
|
||
notesElement,
|
||
(() => {
|
||
const notes = $el('textarea.comfy-multiline-input', {
|
||
name: 'model notes',
|
||
value: noteText,
|
||
oninput: (e) => {
|
||
if (this.#settingsElements['model-info-autosave-notes'].checked) {
|
||
saveDebounce();
|
||
}
|
||
},
|
||
});
|
||
|
||
if (navigator.userAgent.includes('Mac')) {
|
||
new KeyComboListener(['MetaLeft', 'KeyS'], saveDebounce, notes);
|
||
new KeyComboListener(['MetaRight', 'KeyS'], saveDebounce, notes);
|
||
} else {
|
||
new KeyComboListener(['ControlLeft', 'KeyS'], saveDebounce, notes);
|
||
new KeyComboListener(['ControlRight', 'KeyS'], saveDebounce, notes);
|
||
}
|
||
|
||
this.elements.notes = notes;
|
||
this.elements.markdown = markdown;
|
||
this.#savedNotesValue = noteText;
|
||
|
||
const notesEditor = $el(
|
||
'div',
|
||
{
|
||
style: {
|
||
display: noteText == '' ? 'flex' : 'none',
|
||
height: '100%',
|
||
'min-height': '60px',
|
||
},
|
||
},
|
||
notes,
|
||
);
|
||
const notesViewer = $el(
|
||
'div',
|
||
{
|
||
style: {
|
||
display: noteText == '' ? 'none' : 'flex',
|
||
height: '100%',
|
||
'min-height': '60px',
|
||
overflow: 'scroll',
|
||
'overflow-wrap': 'anywhere',
|
||
},
|
||
},
|
||
markdown,
|
||
);
|
||
|
||
const editNotesButton = new ComfyButton({
|
||
icon: 'pencil',
|
||
tooltip: 'Change file name',
|
||
classList: 'comfyui-button icon-button',
|
||
action: async () => {
|
||
notesEditor.style.display =
|
||
notesEditor.style.display == 'flex' ? 'none' : 'flex';
|
||
notesViewer.style.display =
|
||
notesViewer.style.display == 'none' ? 'flex' : 'none';
|
||
},
|
||
}).element;
|
||
|
||
return [
|
||
$el(
|
||
'div.row',
|
||
{
|
||
style: { 'align-items': 'center' },
|
||
},
|
||
[$el('h1', ['Notes']), downloadNotesButton, saveNotesButton, editNotesButton],
|
||
),
|
||
notesEditor,
|
||
notesViewer,
|
||
];
|
||
})(),
|
||
);
|
||
|
||
//
|
||
// TAGS
|
||
//
|
||
|
||
/** @type {HTMLDivElement} */
|
||
const tagsElement = this.elements.tabContents[2]; // TODO: remove magic value
|
||
const isTags = Array.isArray(tags) && tags.length > 0;
|
||
const tagsParagraph = $el(
|
||
'div',
|
||
(() => {
|
||
const elements = [];
|
||
if (isTags) {
|
||
let text = TagCountMapToParagraph(tags);
|
||
const div = $el('div');
|
||
div.innerHTML = text;
|
||
elements.push(div);
|
||
}
|
||
return elements;
|
||
})(),
|
||
);
|
||
const tagGeneratorRandomizedOutput = $el('textarea.comfy-multiline-input', {
|
||
name: 'random tag generator output',
|
||
rows: 4,
|
||
});
|
||
const TAG_GENERATOR_SAMPLER_NAME = 'model manager tag generator sampler';
|
||
const tagGenerationCount = $el('input', {
|
||
type: 'number',
|
||
name: 'tag generator count',
|
||
step: 1,
|
||
min: 1,
|
||
value: this.#settingsElements['tag-generator-count'].value,
|
||
});
|
||
const tagGenerationThreshold = $el('input', {
|
||
type: 'number',
|
||
name: 'tag generator threshold',
|
||
step: 1,
|
||
min: 1,
|
||
value: this.#settingsElements['tag-generator-threshold'].value,
|
||
});
|
||
const selectedSamplerOption =
|
||
this.#settingsElements['tag-generator-sampler-method'].value;
|
||
const samplerOptions = ['Frequency', 'Uniform'];
|
||
const samplerRadioGroup = $radioGroup({
|
||
name: TAG_GENERATOR_SAMPLER_NAME,
|
||
onchange: (value) => {},
|
||
options: samplerOptions.map((option) => {
|
||
return { value: option };
|
||
}),
|
||
});
|
||
const samplerOptionInputs = samplerRadioGroup.getElementsByTagName('input');
|
||
for (let i = 0; i < samplerOptionInputs.length; i++) {
|
||
const samplerOptionInput = samplerOptionInputs[i];
|
||
if (samplerOptionInput.value === selectedSamplerOption) {
|
||
samplerOptionInput.click();
|
||
break;
|
||
}
|
||
}
|
||
const tagGenerator = $el('div', [
|
||
$el('h1', ['Tags']),
|
||
$el('h2', { style: { margin: '0px 0px 16px 0px' } }, [
|
||
'Random Tag Generator',
|
||
]),
|
||
$el('div', [
|
||
$el(
|
||
'details.tag-generator-settings',
|
||
{
|
||
style: { margin: '10px 0', display: 'none' },
|
||
open: false,
|
||
},
|
||
[
|
||
$el('summary', ['Settings']),
|
||
$el('div', ['Sampling Method', samplerRadioGroup]),
|
||
$el('label', ['Count', tagGenerationCount]),
|
||
$el('label', ['Threshold', tagGenerationThreshold]),
|
||
],
|
||
),
|
||
tagGeneratorRandomizedOutput,
|
||
new ComfyButton({
|
||
content: 'Randomize',
|
||
tooltip: 'Randomly generate subset of tags',
|
||
action: () => {
|
||
const samplerName = document.querySelector(
|
||
`input[name="${TAG_GENERATOR_SAMPLER_NAME}"]:checked`,
|
||
).value;
|
||
const sampler =
|
||
samplerName === 'Frequency'
|
||
? ModelInfo.ProbabilisticTagSampling
|
||
: ModelInfo.UniformTagSampling;
|
||
const sampleCount = tagGenerationCount.value;
|
||
const frequencyThreshold = tagGenerationThreshold.value;
|
||
const tags = ParseTagParagraph(tagsParagraph.innerText);
|
||
const sampledTags = sampler(tags, sampleCount, frequencyThreshold);
|
||
tagGeneratorRandomizedOutput.value = sampledTags.join(', ');
|
||
},
|
||
}).element,
|
||
]),
|
||
]);
|
||
tagsElement.innerHTML = '';
|
||
tagsElement.append.apply(tagsElement, [
|
||
tagGenerator,
|
||
$el('div', [
|
||
$el(
|
||
'h2',
|
||
{
|
||
style: {
|
||
margin: '24px 0px 8px 0px',
|
||
},
|
||
},
|
||
['Tags'],
|
||
),
|
||
tagsParagraph,
|
||
]),
|
||
]);
|
||
const tagButton = this.elements.tabButtons[2]; // TODO: remove magic value
|
||
tagButton.style.display = isTags ? '' : 'none';
|
||
|
||
//
|
||
// METADATA
|
||
//
|
||
|
||
/** @type {HTMLDivElement} */
|
||
const metadataElement = this.elements.tabContents[3]; // TODO: remove magic value
|
||
const isMetadata =
|
||
typeof metadata === 'object' &&
|
||
metadata !== null &&
|
||
Object.keys(metadata).length > 0;
|
||
metadataElement.innerHTML = '';
|
||
metadataElement.append.apply(metadataElement, [
|
||
$el('h1', ['Metadata']),
|
||
$el(
|
||
'div',
|
||
(() => {
|
||
const tableRows = [];
|
||
if (isMetadata) {
|
||
for (const [key, value] of Object.entries(metadata)) {
|
||
if (value === undefined || value === null) {
|
||
continue;
|
||
}
|
||
if (value !== '') {
|
||
tableRows.push(
|
||
$el('tr', [
|
||
$el('th.model-metadata-key', [key]),
|
||
$el('th.model-metadata-value', [value]),
|
||
]),
|
||
);
|
||
}
|
||
}
|
||
}
|
||
return $el('table.model-metadata', tableRows);
|
||
})(),
|
||
),
|
||
]);
|
||
const metadataButton = this.elements.tabButtons[3]; // TODO: remove magic value
|
||
metadataButton.style.display = isMetadata ? '' : 'none';
|
||
}
|
||
|
||
static UniformTagSampling(
|
||
tagsAndCounts,
|
||
sampleCount,
|
||
frequencyThreshold = 0,
|
||
) {
|
||
const data = tagsAndCounts.filter((x) => x[1] >= frequencyThreshold);
|
||
let count = data.length;
|
||
const samples = [];
|
||
for (let i = 0; i < sampleCount; i++) {
|
||
if (count === 0) {
|
||
break;
|
||
}
|
||
const index = Math.floor(Math.random() * count);
|
||
const pair = data.splice(index, 1)[0];
|
||
samples.push(pair);
|
||
count -= 1;
|
||
}
|
||
const sortedSamples = samples.sort((x1, x2) => {
|
||
return parseInt(x2[1]) - parseInt(x1[1]);
|
||
});
|
||
return sortedSamples.map((x) => x[0]);
|
||
}
|
||
|
||
static ProbabilisticTagSampling(
|
||
tagsAndCounts,
|
||
sampleCount,
|
||
frequencyThreshold = 0,
|
||
) {
|
||
const data = tagsAndCounts.filter((x) => x[1] >= frequencyThreshold);
|
||
let tagFrequenciesSum = data.reduce(
|
||
(accumulator, x) => accumulator + x[1],
|
||
0,
|
||
);
|
||
let count = data.length;
|
||
const samples = [];
|
||
for (let i = 0; i < sampleCount; i++) {
|
||
if (count === 0) {
|
||
break;
|
||
}
|
||
const index = (() => {
|
||
let frequencyIndex = Math.floor(Math.random() * tagFrequenciesSum);
|
||
return data.findIndex((x) => {
|
||
const frequency = x[1];
|
||
if (frequency > frequencyIndex) {
|
||
return true;
|
||
}
|
||
frequencyIndex = frequencyIndex - frequency;
|
||
return false;
|
||
});
|
||
})();
|
||
const pair = data.splice(index, 1)[0];
|
||
samples.push(pair);
|
||
tagFrequenciesSum -= pair[1];
|
||
count -= 1;
|
||
}
|
||
const sortedSamples = samples.sort((x1, x2) => {
|
||
return parseInt(x2[1]) - parseInt(x1[1]);
|
||
});
|
||
return sortedSamples.map((x) => x[0]);
|
||
}
|
||
}
|
||
|
||
class Civitai {
|
||
/**
|
||
* Get model info from Civitai.
|
||
*
|
||
* @param {string} id - Model ID.
|
||
* @param {string} apiPath - Civitai request subdirectory. "models" for 'model' urls. "model-version" for 'api' urls.
|
||
*
|
||
* @returns {Promise<Object>} Dictionary containing received model info. Returns an empty if fails.
|
||
*/
|
||
static async requestInfo(id, apiPath) {
|
||
const url = 'https://civitai.com/api/v1/' + apiPath + '/' + id;
|
||
try {
|
||
const response = await fetch(url);
|
||
const data = await response.json();
|
||
return data;
|
||
} catch (error) {
|
||
console.error('Failed to get model info from Civitai!', error);
|
||
return {};
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Extract file information from the given model version information.
|
||
*
|
||
* @param {Object} modelVersionInfo - Model version information.
|
||
* @param {(string|null)} [type=null] - Optional select by model type.
|
||
* @param {(string|null)} [fp=null] - Optional select by floating point quantization.
|
||
* @param {(string|null)} [size=null] - Optional select by sizing.
|
||
* @param {(string|null)} [format=null] - Optional select by file format.
|
||
*
|
||
* @returns {Object} - Extracted list of information on each file of the given model version.
|
||
*/
|
||
static getModelFilesInfo(
|
||
modelVersionInfo,
|
||
type = null,
|
||
fp = null,
|
||
size = null,
|
||
format = null,
|
||
) {
|
||
const files = [];
|
||
const modelVersionFiles = modelVersionInfo['files'];
|
||
for (let i = 0; i < modelVersionFiles.length; i++) {
|
||
const modelVersionFile = modelVersionFiles[i];
|
||
|
||
const fileType = modelVersionFile['type'];
|
||
if (type instanceof String && type != fileType) {
|
||
continue;
|
||
}
|
||
|
||
const fileMeta = modelVersionFile['metadata'];
|
||
|
||
const fileFp = fileMeta['fp'];
|
||
if (fp instanceof String && fp != fileFp) {
|
||
continue;
|
||
}
|
||
|
||
const fileSize = fileMeta['size'];
|
||
if (size instanceof String && size != fileSize) {
|
||
continue;
|
||
}
|
||
|
||
const fileFormat = fileMeta['format'];
|
||
if (format instanceof String && format != fileFormat) {
|
||
continue;
|
||
}
|
||
|
||
files.push({
|
||
downloadUrl: modelVersionFile['downloadUrl'],
|
||
format: fileFormat,
|
||
fp: fileFp,
|
||
hashes: modelVersionFile['hashes'],
|
||
name: modelVersionFile['name'],
|
||
size: fileSize,
|
||
sizeKB: modelVersionFile['sizeKB'],
|
||
type: fileType,
|
||
});
|
||
}
|
||
return {
|
||
files: files,
|
||
id: modelVersionInfo['id'],
|
||
images: modelVersionInfo['images'].map((image) => {
|
||
// TODO: do I need to double-check image matches resource?
|
||
return image['url'];
|
||
}),
|
||
name: modelVersionInfo['name'],
|
||
description: modelVersionInfo['description'],
|
||
tags: modelVersionInfo['trainedWords'],
|
||
};
|
||
}
|
||
|
||
/**
|
||
* @param {string} stringUrl - Model url.
|
||
*
|
||
* @returns {Promise<Object>} - Download information for the given url.
|
||
*/
|
||
static async getFilteredInfo(stringUrl) {
|
||
const url = new URL(stringUrl);
|
||
if (url.hostname != 'civitai.com') {
|
||
return {};
|
||
}
|
||
if (url.pathname == '/') {
|
||
return {};
|
||
}
|
||
const urlPath = url.pathname;
|
||
if (urlPath.startsWith('/api')) {
|
||
const idEnd = urlPath.length - (urlPath.at(-1) == '/' ? 1 : 0);
|
||
const idStart = urlPath.lastIndexOf('/', idEnd - 1) + 1;
|
||
const modelVersionId = urlPath.substring(idStart, idEnd);
|
||
if (parseInt(modelVersionId, 10) == NaN) {
|
||
return {};
|
||
}
|
||
const modelVersionInfo = await Civitai.requestInfo(
|
||
modelVersionId,
|
||
'model-versions',
|
||
);
|
||
if (Object.keys(modelVersionInfo).length == 0) {
|
||
return {};
|
||
}
|
||
const searchParams = url.searchParams;
|
||
const filesInfo = Civitai.getModelFilesInfo(
|
||
modelVersionInfo,
|
||
searchParams.get('type'),
|
||
searchParams.get('fp'),
|
||
searchParams.get('size'),
|
||
searchParams.get('format'),
|
||
);
|
||
return {
|
||
name: modelVersionInfo['model']['name'],
|
||
type: modelVersionInfo['model']['type'],
|
||
description: modelVersionInfo['description'],
|
||
tags: modelVersionInfo['trainedWords'],
|
||
versions: [filesInfo],
|
||
};
|
||
} else if (urlPath.startsWith('/models')) {
|
||
const idStart = urlPath.indexOf('models/') + 'models/'.length;
|
||
const idEnd = (() => {
|
||
const idEnd = urlPath.indexOf('/', idStart);
|
||
return idEnd === -1 ? urlPath.length : idEnd;
|
||
})();
|
||
const modelId = urlPath.substring(idStart, idEnd);
|
||
if (parseInt(modelId, 10) == NaN) {
|
||
return {};
|
||
}
|
||
const modelInfo = await Civitai.requestInfo(modelId, 'models');
|
||
if (Object.keys(modelInfo).length == 0) {
|
||
return {};
|
||
}
|
||
const modelVersionId = parseInt(url.searchParams.get('modelVersionId'));
|
||
const modelVersions = [];
|
||
const modelVersionInfos = modelInfo['modelVersions'];
|
||
for (let i = 0; i < modelVersionInfos.length; i++) {
|
||
const versionInfo = modelVersionInfos[i];
|
||
if (!Number.isNaN(modelVersionId)) {
|
||
if (modelVersionId != versionInfo['id']) {
|
||
continue;
|
||
}
|
||
}
|
||
const filesInfo = Civitai.getModelFilesInfo(versionInfo);
|
||
modelVersions.push(filesInfo);
|
||
}
|
||
return {
|
||
name: modelInfo['name'],
|
||
type: modelInfo['type'],
|
||
description: modelInfo['description'],
|
||
versions: modelVersions,
|
||
};
|
||
} else {
|
||
return {};
|
||
}
|
||
}
|
||
|
||
/**
|
||
* @returns {string}
|
||
*/
|
||
static imagePostUrlPrefix() {
|
||
return 'https://civitai.com/images/';
|
||
}
|
||
|
||
/**
|
||
* @returns {string}
|
||
*/
|
||
static imageUrlPrefix() {
|
||
return 'https://image.civitai.com/';
|
||
}
|
||
|
||
/**
|
||
* @param {string} stringUrl - https://civitai.com/images/{imageId}.
|
||
*
|
||
* @returns {Promise<Object>} - Image information.
|
||
*/
|
||
static async getImageInfo(stringUrl) {
|
||
const imagePostUrlPrefix = Civitai.imagePostUrlPrefix();
|
||
if (!stringUrl.startsWith(imagePostUrlPrefix)) {
|
||
return {};
|
||
}
|
||
const id = stringUrl.substring(imagePostUrlPrefix.length).match(/^\d+/)[0];
|
||
const url = `https://civitai.com/api/v1/images?imageId=${id}`;
|
||
try {
|
||
const response = await fetch(url);
|
||
const data = await response.json();
|
||
return data;
|
||
} catch (error) {
|
||
console.error('Failed to get image info from Civitai!', error);
|
||
return {};
|
||
}
|
||
}
|
||
|
||
/**
|
||
* @param {string} stringUrl - https://image.civitai.com/...
|
||
*
|
||
* @returns {Promise<string>}
|
||
*/
|
||
static async getFullSizeImageUrl(stringUrl) {
|
||
const imageUrlPrefix = Civitai.imageUrlPrefix();
|
||
if (!stringUrl.startsWith(imageUrlPrefix)) {
|
||
return '';
|
||
}
|
||
const i0 = stringUrl.lastIndexOf('/');
|
||
const i1 = stringUrl.lastIndexOf('.');
|
||
if (i0 === -1 || i1 === -1) {
|
||
return '';
|
||
}
|
||
const id = parseInt(stringUrl.substring(i0 + 1, i1)).toString();
|
||
const url = `https://civitai.com/api/v1/images?imageId=${id}`;
|
||
try {
|
||
const response = await fetch(url);
|
||
const imageInfo = await response.json();
|
||
const items = imageInfo['items'];
|
||
if (items.length === 0) {
|
||
console.warn('Civitai /api/v1/images returned 0 items.');
|
||
return stringUrl;
|
||
}
|
||
return items[0]['url'];
|
||
} catch (error) {
|
||
console.error('Failed to get image info from Civitai!', error);
|
||
return stringUrl;
|
||
}
|
||
}
|
||
}
|
||
|
||
class HuggingFace {
|
||
/**
|
||
* Get model info from Huggingface.
|
||
*
|
||
* @param {string} id - Model ID.
|
||
* @param {string} apiPath - API path.
|
||
*
|
||
* @returns {Promise<Object>} Dictionary containing received model info. Returns an empty if fails.
|
||
*/
|
||
static async requestInfo(id, apiPath = 'models') {
|
||
const url = 'https://huggingface.co/api/' + apiPath + '/' + id;
|
||
try {
|
||
const response = await fetch(url);
|
||
const data = await response.json();
|
||
return data;
|
||
} catch (error) {
|
||
console.error('Failed to get model info from HuggingFace!', error);
|
||
return {};
|
||
}
|
||
}
|
||
|
||
/**
|
||
*
|
||
*
|
||
* @param {string} stringUrl - Model url.
|
||
*
|
||
* @returns {Promise<Object>}
|
||
*/
|
||
static async getFilteredInfo(stringUrl) {
|
||
const url = new URL(stringUrl);
|
||
if (url.hostname != 'huggingface.co') {
|
||
return {};
|
||
}
|
||
if (url.pathname == '/') {
|
||
return {};
|
||
}
|
||
const urlPath = url.pathname;
|
||
const i0 = 1;
|
||
const i1 = urlPath.indexOf('/', i0);
|
||
if (i1 == -1 || urlPath.length - 1 == i1) {
|
||
// user-name only
|
||
return {};
|
||
}
|
||
let i2 = urlPath.indexOf('/', i1 + 1);
|
||
if (i2 == -1) {
|
||
// model id only
|
||
i2 = urlPath.length;
|
||
}
|
||
const modelId = urlPath.substring(i0, i2);
|
||
const urlPathEnd = urlPath.substring(i2);
|
||
|
||
const isValidBranch =
|
||
urlPathEnd.startsWith('/resolve') ||
|
||
urlPathEnd.startsWith('/blob') ||
|
||
urlPathEnd.startsWith('/tree');
|
||
|
||
let branch = '/main';
|
||
let filePath = '';
|
||
if (isValidBranch) {
|
||
const i0 = branch.length;
|
||
const i1 = urlPathEnd.indexOf('/', i0 + 1);
|
||
if (i1 == -1) {
|
||
if (i0 != urlPathEnd.length) {
|
||
// ends with branch
|
||
branch = urlPathEnd.substring(i0);
|
||
}
|
||
} else {
|
||
branch = urlPathEnd.substring(i0, i1);
|
||
if (urlPathEnd.length - 1 > i1) {
|
||
filePath = urlPathEnd.substring(i1);
|
||
}
|
||
}
|
||
}
|
||
|
||
const modelInfo = await HuggingFace.requestInfo(modelId);
|
||
//const modelInfo = await requestInfo(modelId + "/tree" + branch); // this only gives you the files at the given branch path...
|
||
// oid: SHA-1?, lfs.oid: SHA-256
|
||
|
||
const clippedFilePath = filePath.substring(filePath[0] === '/' ? 1 : 0);
|
||
const modelFiles = modelInfo['siblings']
|
||
.filter((sib) => {
|
||
const filename = sib['rfilename'];
|
||
for (let i = 0; i < MODEL_EXTENSIONS.length; i++) {
|
||
if (filename.endsWith(MODEL_EXTENSIONS[i])) {
|
||
return filename.startsWith(clippedFilePath);
|
||
}
|
||
}
|
||
return false;
|
||
})
|
||
.map((sib) => {
|
||
const filename = sib['rfilename'];
|
||
return filename;
|
||
});
|
||
if (modelFiles.length === 0) {
|
||
return {};
|
||
}
|
||
|
||
const baseDownloadUrl =
|
||
url.origin + urlPath.substring(0, i2) + '/resolve' + branch;
|
||
|
||
const images = modelInfo['siblings']
|
||
.filter((sib) => {
|
||
const filename = sib['rfilename'];
|
||
for (let i = 0; i < IMAGE_EXTENSIONS.length; i++) {
|
||
if (filename.endsWith(IMAGE_EXTENSIONS[i])) {
|
||
return filename.startsWith(clippedFilePath);
|
||
}
|
||
}
|
||
return false;
|
||
})
|
||
.map((sib) => {
|
||
return baseDownloadUrl + '/' + sib['rfilename'];
|
||
});
|
||
|
||
return {
|
||
baseDownloadUrl: baseDownloadUrl,
|
||
modelFiles: modelFiles,
|
||
images: images,
|
||
name: modelId,
|
||
};
|
||
}
|
||
}
|
||
|
||
/**
|
||
* @param {string} urlText
|
||
* @returns {Promise<[string, any[]]>} [name, modelInfos]
|
||
*/
|
||
async function getModelInfos(urlText) {
|
||
// TODO: class for proper return type
|
||
return await (async () => {
|
||
if (urlText.startsWith('https://civitai.com')) {
|
||
const civitaiInfo = await Civitai.getFilteredInfo(urlText);
|
||
if (Object.keys(civitaiInfo).length === 0) {
|
||
return ['', []];
|
||
}
|
||
const name = civitaiInfo['name'];
|
||
const infos = [];
|
||
const type = civitaiInfo['type'];
|
||
|
||
civitaiInfo['versions'].forEach((version) => {
|
||
const images = version['images'];
|
||
const tags = version['tags']?.map((tag) =>
|
||
tag.trim().replace(/,$/, ''),
|
||
);
|
||
const description = [
|
||
tags !== undefined ? '# Trigger Words' : undefined,
|
||
tags?.join(
|
||
tags.some((tag) => {
|
||
return tag.includes(',');
|
||
})
|
||
? '\n'
|
||
: ', ',
|
||
),
|
||
version['description'] !== undefined
|
||
? '# About this version '
|
||
: undefined,
|
||
version['description'],
|
||
civitaiInfo['description'] !== undefined ? '# ' + name : undefined,
|
||
civitaiInfo['description'],
|
||
]
|
||
.filter((x) => x !== undefined)
|
||
.join('\n\n');
|
||
version['files'].forEach((file) => {
|
||
infos.push({
|
||
images: images,
|
||
fileName: file['name'],
|
||
modelType: type,
|
||
downloadUrl: file['downloadUrl'],
|
||
downloadFilePath: '',
|
||
description: downshow(description),
|
||
details: {
|
||
fileSizeKB: file['sizeKB'],
|
||
fileType: file['type'],
|
||
fp: file['fp'],
|
||
quant: file['size'],
|
||
fileFormat: file['format'],
|
||
},
|
||
});
|
||
});
|
||
});
|
||
return [name, infos];
|
||
}
|
||
if (urlText.startsWith('https://huggingface.co')) {
|
||
const hfInfo = await HuggingFace.getFilteredInfo(urlText);
|
||
if (Object.keys(hfInfo).length === 0) {
|
||
return ['', []];
|
||
}
|
||
const files = hfInfo['modelFiles'];
|
||
if (files.length === 0) {
|
||
return ['', []];
|
||
}
|
||
const name = hfInfo['name'];
|
||
const baseDownloadUrl = hfInfo['baseDownloadUrl'];
|
||
const infos = hfInfo['modelFiles'].map((file) => {
|
||
const indexSep = file.lastIndexOf('/');
|
||
const filename = file.substring(indexSep + 1);
|
||
return {
|
||
images: hfInfo['images'],
|
||
fileName: filename,
|
||
modelType: '',
|
||
downloadUrl: baseDownloadUrl + '/' + file + '?download=true',
|
||
downloadFilePath: file.substring(0, indexSep + 1),
|
||
description: '',
|
||
details: {
|
||
fileSizeKB: undefined, // TODO: too hard?
|
||
},
|
||
};
|
||
});
|
||
return [name, infos];
|
||
}
|
||
if (urlText.endsWith('.json')) {
|
||
const indexInfo = await (async () => {
|
||
try {
|
||
const response = await fetch(url);
|
||
const data = await response.json();
|
||
return data;
|
||
} catch {
|
||
return [];
|
||
}
|
||
})();
|
||
const name = urlText.substring(math.max(urlText.lastIndexOf('/'), 0));
|
||
const infos = indexInfo.map((file) => {
|
||
return {
|
||
images: [],
|
||
fileName: file['name'],
|
||
modelType:
|
||
DownloadView.modelTypeToComfyUiDirectory(file['type'], '') ?? '',
|
||
downloadUrl: file['download'],
|
||
downloadFilePath: '',
|
||
description: file['description'],
|
||
details: {},
|
||
};
|
||
});
|
||
return [name, infos];
|
||
}
|
||
return ['', []];
|
||
})();
|
||
}
|
||
|
||
class DownloadView {
|
||
/** @type {HTMLDivElement} */
|
||
element = null;
|
||
|
||
elements = {
|
||
/** @type {HTMLInputElement} */ url: null,
|
||
/** @type {HTMLDivElement} */ infos: null,
|
||
/** @type {HTMLInputElement} */ overwrite: null,
|
||
/** @type {HTMLInputElement} */ downloadNotes: null,
|
||
/** @type {HTMLButtonElement} */ searchButton: null,
|
||
/** @type {HTMLButtonElement} */ clearSearchButton: null,
|
||
};
|
||
|
||
/** @type {DOMParser} */
|
||
#domParser = null;
|
||
|
||
/** @type {Object.<string, HTMLElement>} */
|
||
#settings = null;
|
||
|
||
/** @type {() => Promise<void>} */
|
||
#updateModels = () => {};
|
||
|
||
/**
|
||
* @param {ModelData} modelData
|
||
* @param {Object.<string, HTMLElement>} settings
|
||
* @param {() => Promise<void>} updateModels
|
||
*/
|
||
constructor(modelData, settings, updateModels) {
|
||
this.#domParser = new DOMParser();
|
||
this.#updateModels = updateModels;
|
||
const update = async () => {
|
||
await this.#update(modelData, settings);
|
||
};
|
||
const reset = () => {
|
||
this.elements.infos.innerHTML = '';
|
||
this.elements.infos.appendChild(
|
||
$el('h1', ['Input a URL to select a model to download.']),
|
||
);
|
||
};
|
||
|
||
const searchButton = new ComfyButton({
|
||
icon: 'magnify',
|
||
tooltip: 'Search url',
|
||
classList: 'comfyui-button icon-button',
|
||
action: async (e) => {
|
||
const [button, icon, span] = comfyButtonDisambiguate(e.target);
|
||
button.disabled = true;
|
||
if (this.elements.url.value === '') {
|
||
reset();
|
||
} else {
|
||
await update();
|
||
}
|
||
button.disabled = false;
|
||
},
|
||
}).element;
|
||
settings['model-real-time-search'].addEventListener('change', () => {
|
||
const hideSearchButton =
|
||
settings['text-input-always-hide-search-button'].checked;
|
||
searchButton.style.display = hideSearchButton ? 'none' : '';
|
||
});
|
||
settings['text-input-always-hide-search-button'].addEventListener(
|
||
'change',
|
||
() => {
|
||
const hideSearchButton =
|
||
settings['text-input-always-hide-search-button'].checked;
|
||
searchButton.style.display = hideSearchButton ? 'none' : '';
|
||
},
|
||
);
|
||
this.elements.searchButton = searchButton;
|
||
|
||
const clearSearchButton = new ComfyButton({
|
||
icon: 'close',
|
||
tooltip: 'Clear search',
|
||
classList: 'comfyui-button icon-button',
|
||
action: async (e) => {
|
||
e.stopPropagation();
|
||
this.elements.url.value = '';
|
||
reset();
|
||
},
|
||
}).element;
|
||
settings['text-input-always-hide-clear-button'].addEventListener(
|
||
'change',
|
||
() => {
|
||
const hideClearButton =
|
||
settings['text-input-always-hide-clear-button'].checked;
|
||
clearSearchButton.style.display = hideClearButton ? 'none' : '';
|
||
},
|
||
);
|
||
this.elements.clearSearchButton = clearSearchButton;
|
||
|
||
$el(
|
||
'div.tab-header',
|
||
{
|
||
$: (el) => (this.element = el),
|
||
},
|
||
[
|
||
$el('div.row.tab-header-flex-block', [
|
||
$el('input.search-text-area', {
|
||
$: (el) => (this.elements.url = el),
|
||
type: 'text',
|
||
name: 'model download url',
|
||
autocomplete: 'off',
|
||
placeholder: 'Search URL',
|
||
onkeydown: async (e) => {
|
||
if (e.key === 'Enter') {
|
||
e.stopPropagation();
|
||
searchButton.click();
|
||
e.target.blur();
|
||
}
|
||
},
|
||
}),
|
||
clearSearchButton,
|
||
searchButton,
|
||
]),
|
||
$el(
|
||
'div.download-model-infos',
|
||
{
|
||
$: (el) => (this.elements.infos = el),
|
||
},
|
||
[$el('h1', ['Input a URL to select a model to download.'])],
|
||
),
|
||
],
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Tries to return the related ComfyUI model directory if unambiguous.
|
||
*
|
||
* @param {string | undefined} modelType - Model type.
|
||
* @param {string | undefined} [fileType] - File type. Relevant for "Diffusers".
|
||
*
|
||
* @returns {(string | null)} Logical base directory name for model type. May be null if the directory is ambiguous or not a model type.
|
||
*/
|
||
static modelTypeToComfyUiDirectory(modelType, fileType) {
|
||
if (fileType !== undefined && fileType !== null) {
|
||
const f = fileType.toLowerCase();
|
||
if (f == 'diffusers') {
|
||
return 'diffusers';
|
||
} // TODO: is this correct?
|
||
}
|
||
|
||
if (modelType !== undefined && modelType !== null) {
|
||
const m = modelType.toLowerCase();
|
||
// TODO: somehow allow for SERVER to set dir?
|
||
// TODO: allow user to choose EXISTING folder override/null? (style_models, HuggingFace) (use an object/map instead so settings can be dynamically set)
|
||
if (m == 'aestheticGradient') {
|
||
return null;
|
||
} else if (m == 'checkpoint' || m == 'checkpoints') {
|
||
return 'checkpoints';
|
||
}
|
||
//else if (m == "") { return "clip"; }
|
||
//else if (m == "") { return "clip_vision"; }
|
||
else if (m == 'controlnet') {
|
||
return 'controlnet';
|
||
}
|
||
//else if (m == "Controlnet") { return "style_models"; } // are these controlnets? (TI-Adapter)
|
||
//else if (m == "") { return "gligen"; }
|
||
else if (m == 'hypernetwork' || m == 'hypernetworks') {
|
||
return 'hypernetworks';
|
||
} else if (m == 'lora' || m == 'loras') {
|
||
return 'loras';
|
||
} else if (m == 'locon') {
|
||
return 'loras';
|
||
} else if (m == 'motionmodule') {
|
||
return null;
|
||
} else if (m == 'other') {
|
||
return null;
|
||
} else if (m == 'pose') {
|
||
return null;
|
||
} else if (
|
||
m == 'textualinversion' ||
|
||
m == 'embedding' ||
|
||
m == 'embeddings'
|
||
) {
|
||
return 'embeddings';
|
||
}
|
||
//else if (m == "") { return "unet"; }
|
||
else if (
|
||
m == 'upscaler' ||
|
||
m == 'upscale_model' ||
|
||
m == 'upscale_models'
|
||
) {
|
||
return 'upscale_models';
|
||
} else if (m == 'vae') {
|
||
return 'vae';
|
||
} else if (m == 'wildcard' || m == 'wildcards') {
|
||
return null;
|
||
} else if (m == 'workflow' || m == 'workflows') {
|
||
return null;
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* Returns empty string on failure
|
||
* @param {float | undefined} fileSizeKB
|
||
* @returns {string}
|
||
*/
|
||
static #fileSizeToFormattedString(fileSizeKB) {
|
||
if (fileSizeKB === undefined) {
|
||
return '';
|
||
}
|
||
const sizes = ['KB', 'MB', 'GB', 'TB', 'PB'];
|
||
let fileSizeString = fileSizeKB.toString();
|
||
const index = fileSizeString.indexOf('.');
|
||
const indexMove = index % 3 === 0 ? 3 : index % 3;
|
||
const sizeIndex = Math.floor((index - indexMove) / 3);
|
||
if (sizeIndex >= sizes.length || sizeIndex < 0) {
|
||
fileSizeString = fileSizeString.substring(
|
||
0,
|
||
fileSizeString.indexOf('.') + 3,
|
||
);
|
||
return `(${fileSizeString} ${sizes[0]})`;
|
||
}
|
||
const split = fileSizeString.split('.');
|
||
fileSizeString =
|
||
split[0].substring(0, indexMove) +
|
||
'.' +
|
||
split[0].substring(indexMove) +
|
||
split[1];
|
||
fileSizeString = fileSizeString.substring(
|
||
0,
|
||
fileSizeString.indexOf('.') + 3,
|
||
);
|
||
return `(${fileSizeString} ${sizes[sizeIndex]})`;
|
||
}
|
||
|
||
/**
|
||
* @param {Object} info
|
||
* @param {ModelData} modelData
|
||
* @param {int} id
|
||
* @param {any} settings
|
||
* @returns {HTMLDivElement}
|
||
*/
|
||
#modelInfoHtml(info, modelData, id, settings) {
|
||
const downloadPreviewSelect = new ImageSelect(
|
||
'model-download-info-preview-model' + '-' + id,
|
||
info['images'],
|
||
);
|
||
|
||
const comfyUIModelType =
|
||
DownloadView.modelTypeToComfyUiDirectory(info['details']['fileType']) ??
|
||
DownloadView.modelTypeToComfyUiDirectory(info['modelType']) ??
|
||
'';
|
||
const searchSeparator = modelData.searchSeparator;
|
||
const defaultBasePath =
|
||
searchSeparator +
|
||
(comfyUIModelType === '' ? '' : comfyUIModelType + searchSeparator + '0');
|
||
|
||
const el_saveDirectoryPath = $el('input.search-text-area', {
|
||
type: 'text',
|
||
name: 'save directory',
|
||
autocomplete: 'off',
|
||
placeholder: defaultBasePath,
|
||
value: defaultBasePath,
|
||
});
|
||
const searchDropdown = new DirectoryDropdown(
|
||
modelData,
|
||
el_saveDirectoryPath,
|
||
true,
|
||
);
|
||
|
||
const default_name = (() => {
|
||
const filename = info['fileName'];
|
||
// TODO: only remove valid model file extensions
|
||
const i = filename.lastIndexOf('.');
|
||
return i === -1 ? filename : filename.substring(0, i);
|
||
})();
|
||
const el_filename = $el('input.plain-text-area', {
|
||
type: 'text',
|
||
name: 'model save file name',
|
||
autocomplete: 'off',
|
||
placeholder: default_name,
|
||
value: default_name,
|
||
onkeydown: (e) => {
|
||
if (e.key === 'Enter') {
|
||
e.stopPropagation();
|
||
e.target.blur();
|
||
}
|
||
},
|
||
});
|
||
|
||
const infoNotes = $el('textarea.comfy-multiline-input.model-info-notes', {
|
||
name: 'model info notes',
|
||
value: info['description'] ?? '',
|
||
rows: 6,
|
||
disabled: true,
|
||
style: {
|
||
display:
|
||
info['description'] === undefined || info['description'] === ''
|
||
? 'none'
|
||
: '',
|
||
},
|
||
});
|
||
|
||
const filepath = info['downloadFilePath'];
|
||
const modelInfo = $el('details.download-details', [
|
||
$el('summary', [filepath + info['fileName']]),
|
||
$el('div', [
|
||
downloadPreviewSelect.elements.previews,
|
||
$el('div.download-settings-wrapper', [
|
||
$el('div.download-settings', [
|
||
new ComfyButton({
|
||
icon: 'arrow-collapse-down',
|
||
tooltip: 'Download model',
|
||
content:
|
||
'Download ' +
|
||
DownloadView.#fileSizeToFormattedString(
|
||
info['details']['fileSizeKB'],
|
||
),
|
||
classList: 'comfyui-button download-button',
|
||
action: async (e) => {
|
||
const pathDirectory = el_saveDirectoryPath.value;
|
||
const modelName = (() => {
|
||
const filename = info['fileName'];
|
||
const name = el_filename.value;
|
||
if (name === '') {
|
||
return filename;
|
||
}
|
||
const ext =
|
||
MODEL_EXTENSIONS.find((ext) => {
|
||
return filename.endsWith(ext);
|
||
}) ?? '';
|
||
return name + ext;
|
||
})();
|
||
const formData = new FormData();
|
||
formData.append('download', info['downloadUrl']);
|
||
formData.append('path', pathDirectory);
|
||
formData.append('name', modelName);
|
||
const image = await downloadPreviewSelect.getImage();
|
||
formData.append(
|
||
'image',
|
||
image === PREVIEW_NONE_URI ? '' : image,
|
||
);
|
||
formData.append('overwrite', this.elements.overwrite.checked);
|
||
const [button, icon, span] = comfyButtonDisambiguate(e.target);
|
||
button.disabled = true;
|
||
const [success, resultText] = await comfyRequest(
|
||
'/model-manager/model/download',
|
||
{
|
||
method: 'POST',
|
||
body: formData,
|
||
},
|
||
)
|
||
.then((data) => {
|
||
const success = data['success'];
|
||
const message = data['alert'];
|
||
if (message !== undefined) {
|
||
window.alert(message);
|
||
}
|
||
return [success, success ? '✔' : '📥︎'];
|
||
})
|
||
.catch((err) => {
|
||
return [false, '📥︎'];
|
||
});
|
||
if (success) {
|
||
const description = infoNotes.value;
|
||
if (
|
||
this.elements.downloadNotes.checked &&
|
||
description !== ''
|
||
) {
|
||
const modelPath =
|
||
pathDirectory + searchSeparator + modelName;
|
||
const saved = await saveNotes(modelPath, description);
|
||
if (!saved) {
|
||
console.warn('Model description was not saved!');
|
||
}
|
||
}
|
||
this.#updateModels();
|
||
}
|
||
comfyButtonAlert(
|
||
e.target,
|
||
success,
|
||
'mdi-check-bold',
|
||
'mdi-close-thick',
|
||
success,
|
||
);
|
||
button.disabled = success;
|
||
},
|
||
}).element,
|
||
$el('div.row.tab-header-flex-block.input-dropdown-container', [
|
||
// TODO: magic class
|
||
el_saveDirectoryPath,
|
||
searchDropdown.element,
|
||
]),
|
||
$el('div.row.tab-header-flex-block', [el_filename]),
|
||
downloadPreviewSelect.elements.radioGroup,
|
||
infoNotes,
|
||
]),
|
||
]),
|
||
]),
|
||
]);
|
||
|
||
return modelInfo;
|
||
}
|
||
|
||
/**
|
||
* @param {ModelData} modelData
|
||
* @param {any} settings
|
||
*/
|
||
async #update(modelData, settings) {
|
||
const [name, modelInfos] = await getModelInfos(this.elements.url.value);
|
||
const modelInfosHtml = modelInfos
|
||
.filter((modelInfo) => {
|
||
const filename = modelInfo['fileName'];
|
||
return (
|
||
MODEL_EXTENSIONS.find((ext) => {
|
||
return filename.endsWith(ext);
|
||
}) ?? false
|
||
);
|
||
})
|
||
.map((modelInfo, id) => {
|
||
return this.#modelInfoHtml(modelInfo, modelData, id, settings);
|
||
});
|
||
if (modelInfosHtml.length === 0) {
|
||
modelInfosHtml.push($el('h1', ['No models found.']));
|
||
} else {
|
||
if (modelInfosHtml.length === 1) {
|
||
modelInfosHtml[0].open = true;
|
||
}
|
||
|
||
const header = $el('div', [
|
||
$el('h1', [name]),
|
||
$el('div.model-manager-settings', [
|
||
$checkbox({
|
||
$: (el) => {
|
||
this.elements.overwrite = el;
|
||
},
|
||
textContent: 'Overwrite Existing Files.',
|
||
checked: false,
|
||
}),
|
||
$checkbox({
|
||
$: (el) => {
|
||
this.elements.downloadNotes = el;
|
||
},
|
||
textContent: 'Save Notes.',
|
||
checked: false,
|
||
}),
|
||
]),
|
||
]);
|
||
modelInfosHtml.unshift(header);
|
||
}
|
||
|
||
const infosHtml = this.elements.infos;
|
||
infosHtml.innerHTML = '';
|
||
infosHtml.append.apply(infosHtml, modelInfosHtml);
|
||
|
||
const downloadNotes = this.elements.downloadNotes;
|
||
if (downloadNotes !== undefined && downloadNotes !== null) {
|
||
downloadNotes.addEventListener('change', (e) => {
|
||
const modelInfoNotes = infosHtml.querySelectorAll(
|
||
`textarea.model-info-notes`,
|
||
);
|
||
const disabled = !e.currentTarget.checked;
|
||
for (let i = 0; i < modelInfoNotes.length; i++) {
|
||
modelInfoNotes[i].disabled = disabled;
|
||
}
|
||
});
|
||
downloadNotes.checked =
|
||
settings['download-save-description-as-text-file'].checked;
|
||
downloadNotes.dispatchEvent(new Event('change'));
|
||
}
|
||
|
||
const hideSearchButtons =
|
||
settings['text-input-always-hide-search-button'].checked;
|
||
this.elements.searchButton.style.display = hideSearchButtons ? 'none' : '';
|
||
|
||
const hideClearSearchButtons =
|
||
settings['text-input-always-hide-clear-button'].checked;
|
||
this.elements.clearSearchButton.style.display = hideClearSearchButtons
|
||
? 'none'
|
||
: '';
|
||
}
|
||
}
|
||
|
||
class BrowseView {
|
||
/** @type {HTMLDivElement} */
|
||
element = null;
|
||
|
||
elements = {
|
||
/** @type {HTMLDivElement} */ modelGrid: null,
|
||
/** @type {HTMLSelectElement} */ modelTypeSelect: null,
|
||
/** @type {HTMLSelectElement} */ modelSortSelect: null,
|
||
/** @type {HTMLInputElement} */ modelContentFilter: null,
|
||
/** @type {HTMLButtonElement} */ searchButton: null,
|
||
/** @type {HTMLButtonElement} */ clearSearchButton: null,
|
||
};
|
||
|
||
/** @type {Array} */
|
||
previousModelFilters = [];
|
||
|
||
/** @type {Object.<{value: string}>} */
|
||
previousModelType = { value: null };
|
||
|
||
/** @type {DirectoryDropdown} */
|
||
directoryDropdown = null;
|
||
|
||
/** @type {ModelData} */
|
||
#modelData = null;
|
||
|
||
/** @type {@param {() => Promise<void>}} */
|
||
#updateModels = null;
|
||
|
||
/** */
|
||
#settingsElements = null;
|
||
|
||
/** @type {() => void} */
|
||
updateModelGrid = () => {};
|
||
|
||
/**
|
||
* @param {() => Promise<void>} updateModels
|
||
* @param {ModelData} modelData
|
||
* @param {(searchPath: string) => Promise<void>} showModelInfo
|
||
* @param {() => void} updateModelGridCallback
|
||
* @param {any} settingsElements
|
||
*/
|
||
constructor(
|
||
updateModels,
|
||
modelData,
|
||
showModelInfo,
|
||
updateModelGridCallback,
|
||
settingsElements,
|
||
) {
|
||
/** @type {HTMLDivElement} */
|
||
const modelGrid = $el('div.comfy-grid');
|
||
this.elements.modelGrid = modelGrid;
|
||
|
||
this.#updateModels = updateModels;
|
||
this.#modelData = modelData;
|
||
this.#settingsElements = settingsElements;
|
||
|
||
const searchInput = $el('input.search-text-area', {
|
||
$: (el) => (this.elements.modelContentFilter = el),
|
||
type: 'text',
|
||
name: 'model search',
|
||
autocomplete: 'off',
|
||
placeholder: '/Search',
|
||
});
|
||
|
||
const updatePreviousModelFilter = () => {
|
||
const modelType = this.elements.modelTypeSelect.value;
|
||
const value = this.elements.modelContentFilter.value;
|
||
this.previousModelFilters[modelType] = value;
|
||
};
|
||
|
||
const updateModelGrid = () => {
|
||
const sortValue = this.elements.modelSortSelect.value;
|
||
const reverseSort = sortValue[0] === '-';
|
||
const sortBy = reverseSort ? sortValue.substring(1) : sortValue;
|
||
ModelGrid.update(
|
||
this.elements.modelGrid,
|
||
this.#modelData,
|
||
this.elements.modelTypeSelect,
|
||
this.previousModelType,
|
||
this.#settingsElements,
|
||
sortBy,
|
||
reverseSort,
|
||
this.previousModelFilters,
|
||
this.elements.modelContentFilter,
|
||
showModelInfo,
|
||
);
|
||
updateModelGridCallback();
|
||
|
||
const hideSearchButtons =
|
||
this.#settingsElements['model-real-time-search'].checked |
|
||
this.#settingsElements['text-input-always-hide-search-button'].checked;
|
||
this.elements.searchButton.style.display = hideSearchButtons
|
||
? 'none'
|
||
: '';
|
||
|
||
const hideClearSearchButtons =
|
||
this.#settingsElements['text-input-always-hide-clear-button'].checked;
|
||
this.elements.clearSearchButton.style.display = hideClearSearchButtons
|
||
? 'none'
|
||
: '';
|
||
};
|
||
this.updateModelGrid = updateModelGrid;
|
||
|
||
const searchDropdown = new DirectoryDropdown(
|
||
modelData,
|
||
searchInput,
|
||
false,
|
||
() => {
|
||
return this.elements.modelTypeSelect.value;
|
||
},
|
||
updatePreviousModelFilter,
|
||
updateModelGrid,
|
||
() => {
|
||
return this.#settingsElements['model-real-time-search'].checked;
|
||
},
|
||
);
|
||
this.directoryDropdown = searchDropdown;
|
||
|
||
const searchButton = new ComfyButton({
|
||
icon: 'magnify',
|
||
tooltip: 'Search models',
|
||
classList: 'comfyui-button icon-button',
|
||
action: (e) => {
|
||
e.stopPropagation();
|
||
const [button, icon, span] = comfyButtonDisambiguate(e.target);
|
||
button.disabled = true;
|
||
updateModelGrid();
|
||
button.disabled = false;
|
||
},
|
||
}).element;
|
||
settingsElements['model-real-time-search'].addEventListener(
|
||
'change',
|
||
() => {
|
||
const hideSearchButton =
|
||
this.#settingsElements['text-input-always-hide-search-button']
|
||
.checked ||
|
||
this.#settingsElements['model-real-time-search'].checked;
|
||
searchButton.style.display = hideSearchButton ? 'none' : '';
|
||
},
|
||
);
|
||
settingsElements['text-input-always-hide-search-button'].addEventListener(
|
||
'change',
|
||
() => {
|
||
const hideSearchButton =
|
||
this.#settingsElements['text-input-always-hide-search-button']
|
||
.checked ||
|
||
this.#settingsElements['model-real-time-search'].checked;
|
||
searchButton.style.display = hideSearchButton ? 'none' : '';
|
||
},
|
||
);
|
||
this.elements.searchButton = searchButton;
|
||
|
||
const clearSearchButton = new ComfyButton({
|
||
icon: 'close',
|
||
tooltip: 'Clear search',
|
||
classList: 'comfyui-button icon-button',
|
||
action: (e) => {
|
||
e.stopPropagation();
|
||
const [button, icon, span] = comfyButtonDisambiguate(e.target);
|
||
button.disabled = true;
|
||
this.elements.modelContentFilter.value = '';
|
||
updateModelGrid();
|
||
button.disabled = false;
|
||
},
|
||
}).element;
|
||
settingsElements['text-input-always-hide-clear-button'].addEventListener(
|
||
'change',
|
||
() => {
|
||
const hideClearSearchButton =
|
||
this.#settingsElements['text-input-always-hide-clear-button'].checked;
|
||
clearSearchButton.style.display = hideClearSearchButton ? 'none' : '';
|
||
},
|
||
);
|
||
this.elements.clearSearchButton = clearSearchButton;
|
||
|
||
this.element = $el('div', [
|
||
$el('div.row.tab-header', [
|
||
$el('div.row.tab-header-flex-block', [
|
||
new ComfyButton({
|
||
icon: 'reload',
|
||
tooltip: 'Reload model grid',
|
||
classList: 'comfyui-button icon-button',
|
||
action: async (e) => {
|
||
const [button, icon, span] = comfyButtonDisambiguate(e.target);
|
||
button.disabled = true;
|
||
updateModels();
|
||
button.disabled = false;
|
||
},
|
||
}).element,
|
||
$el('select.model-select-dropdown', {
|
||
$: (el) => (this.elements.modelTypeSelect = el),
|
||
name: 'model-type',
|
||
onchange: (e) => {
|
||
const select = e.target;
|
||
select.disabled = true;
|
||
updateModelGrid();
|
||
select.disabled = false;
|
||
},
|
||
}),
|
||
$el(
|
||
'select.model-select-dropdown',
|
||
{
|
||
$: (el) => (this.elements.modelSortSelect = el),
|
||
name: 'model select dropdown',
|
||
onchange: (e) => {
|
||
const select = e.target;
|
||
select.disabled = true;
|
||
updateModelGrid();
|
||
select.disabled = false;
|
||
},
|
||
},
|
||
[
|
||
$el('option', { value: MODEL_SORT_DATE_CREATED }, [
|
||
'Created (newest first)',
|
||
]),
|
||
$el('option', { value: '-' + MODEL_SORT_DATE_CREATED }, [
|
||
'Created (oldest first)',
|
||
]),
|
||
$el('option', { value: MODEL_SORT_DATE_MODIFIED }, [
|
||
'Modified (newest first)',
|
||
]),
|
||
$el('option', { value: '-' + MODEL_SORT_DATE_MODIFIED }, [
|
||
'Modified (oldest first)',
|
||
]),
|
||
$el('option', { value: MODEL_SORT_DATE_NAME }, ['Name (A-Z)']),
|
||
$el('option', { value: '-' + MODEL_SORT_DATE_NAME }, [
|
||
'Name (Z-A)',
|
||
]),
|
||
$el('option', { value: MODEL_SORT_SIZE_BYTES }, [
|
||
'Size (largest first)',
|
||
]),
|
||
$el('option', { value: '-' + MODEL_SORT_SIZE_BYTES }, [
|
||
'Size (smallest first)',
|
||
]),
|
||
],
|
||
),
|
||
]),
|
||
$el('div.row.tab-header-flex-block', [
|
||
$el('div.search-models.input-dropdown-container', [
|
||
// TODO: magic class
|
||
searchInput,
|
||
searchDropdown.element,
|
||
]),
|
||
clearSearchButton,
|
||
searchButton,
|
||
]),
|
||
]),
|
||
modelGrid,
|
||
]);
|
||
}
|
||
}
|
||
|
||
class SettingsView {
|
||
/** @type {HTMLDivElement} */
|
||
element = null;
|
||
|
||
elements = {
|
||
/** @type {HTMLButtonElement} */ reloadButton: null,
|
||
/** @type {HTMLButtonElement} */ saveButton: null,
|
||
/** @type {HTMLDivElement} */ setPreviewButton: null,
|
||
settings: {
|
||
/** @type {HTMLTextAreaElement} */ 'model-search-always-append': null,
|
||
/** @type {HTMLInputElement} */ 'model-default-browser-model-type': null,
|
||
/** @type {HTMLInputElement} */ 'model-real-time-search': null,
|
||
/** @type {HTMLInputElement} */ 'model-persistent-search': null,
|
||
|
||
/** @type {HTMLInputElement} */ 'model-preview-thumbnail-type': null,
|
||
/** @type {HTMLInputElement} */ 'model-preview-fallback-search-safetensors-thumbnail':
|
||
null,
|
||
/** @type {HTMLInputElement} */ 'model-show-label-extensions': null,
|
||
/** @type {HTMLInputElement} */ 'model-show-add-button': null,
|
||
/** @type {HTMLInputElement} */ 'model-show-copy-button': null,
|
||
/** @type {HTMLInputElement} */ 'model-show-load-workflow-button': null,
|
||
/** @type {HTMLInputElement} */ 'model-show-open-model-url-button': null,
|
||
/** @type {HTMLInputElement} */ 'model-info-button-on-left': null,
|
||
|
||
/** @type {HTMLInputElement} */ 'model-add-embedding-extension': null,
|
||
/** @type {HTMLInputElement} */ 'model-add-drag-strict-on-field': null,
|
||
/** @type {HTMLInputElement} */ 'model-add-offset': null,
|
||
|
||
/** @type {HTMLInputElement} */ 'model-info-autosave-notes': null,
|
||
|
||
/** @type {HTMLInputElement} */ 'download-save-description-as-text-file':
|
||
null,
|
||
|
||
/** @type {HTMLInputElement} */ 'sidebar-default-width': null,
|
||
/** @type {HTMLInputElement} */ 'sidebar-default-height': null,
|
||
/** @type {HTMLInputElement} */ 'sidebar-control-always-compact': null,
|
||
/** @type {HTMLInputElement} */ 'text-input-always-hide-search-button':
|
||
null,
|
||
/** @type {HTMLInputElement} */ 'text-input-always-hide-clear-button':
|
||
null,
|
||
|
||
/** @type {HTMLInputElement} */ 'tag-generator-sampler-method': null,
|
||
/** @type {HTMLInputElement} */ 'tag-generator-count': null,
|
||
/** @type {HTMLInputElement} */ 'tag-generator-threshold': null,
|
||
},
|
||
};
|
||
|
||
/** @return {() => Promise<void>} */
|
||
#updateModels = async () => {};
|
||
|
||
/** @return {() => void} */
|
||
#updateSidebarSettings = () => {};
|
||
|
||
/**
|
||
* @param {Object} settingsData
|
||
* @param {boolean} updateModels
|
||
*/
|
||
async #setSettings(settingsData, updateModels) {
|
||
const settings = this.elements.settings;
|
||
for (const [key, value] of Object.entries(settingsData)) {
|
||
const setting = settings[key];
|
||
if (setting === undefined || setting === null) {
|
||
continue;
|
||
}
|
||
const type = setting.type;
|
||
switch (type) {
|
||
case 'checkbox':
|
||
setting.checked = Boolean(value);
|
||
break;
|
||
case 'range':
|
||
setting.value = parseFloat(value);
|
||
break;
|
||
case 'textarea':
|
||
setting.value = value;
|
||
break;
|
||
case 'number':
|
||
setting.value = parseInt(value);
|
||
break;
|
||
case 'select-one':
|
||
setting.value = value;
|
||
break;
|
||
default:
|
||
console.warn(`Unknown settings input type '${type}'!`);
|
||
}
|
||
}
|
||
|
||
this.#updateSidebarSettings(settings);
|
||
|
||
if (updateModels) {
|
||
await this.#updateModels(); // Is this slow?
|
||
}
|
||
}
|
||
|
||
/**
|
||
* @param {boolean} updateModels
|
||
* @returns {Promise<void>}
|
||
*/
|
||
async reload(updateModels) {
|
||
const data = await comfyRequest('/model-manager/settings/load');
|
||
const settingsData = data['settings'];
|
||
await this.#setSettings(settingsData, updateModels);
|
||
comfyButtonAlert(this.elements.reloadButton, true);
|
||
}
|
||
|
||
/** @returns {Promise<void>} */
|
||
async save() {
|
||
let settingsData = {};
|
||
for (const [setting, el] of Object.entries(this.elements.settings)) {
|
||
if (!el) {
|
||
continue;
|
||
} // hack
|
||
const type = el.type;
|
||
let value = null;
|
||
switch (type) {
|
||
case 'checkbox':
|
||
value = el.checked;
|
||
break;
|
||
case 'range':
|
||
value = el.value;
|
||
break;
|
||
case 'textarea':
|
||
value = el.value;
|
||
break;
|
||
case 'number':
|
||
value = el.value;
|
||
break;
|
||
case 'select-one':
|
||
value = el.value;
|
||
break;
|
||
default:
|
||
console.warn('Unknown settings input type!');
|
||
}
|
||
settingsData[setting] = value;
|
||
}
|
||
|
||
const data = await comfyRequest('/model-manager/settings/save', {
|
||
method: 'POST',
|
||
body: JSON.stringify({ settings: settingsData }),
|
||
}).catch((err) => {
|
||
return { success: false };
|
||
});
|
||
const success = data['success'];
|
||
if (success) {
|
||
const settingsData = data['settings'];
|
||
await this.#setSettings(settingsData, true);
|
||
}
|
||
comfyButtonAlert(this.elements.saveButton, success);
|
||
}
|
||
|
||
/**
|
||
* @param {() => Promise<void>} updateModels
|
||
* @param {() => void} updateSidebarButtons
|
||
* @param {(settings: Object) => void} updateSidebarSettings
|
||
*/
|
||
constructor(updateModels, updateSidebarButtons, updateSidebarSettings) {
|
||
this.#updateModels = updateModels;
|
||
this.#updateSidebarSettings = updateSidebarSettings;
|
||
const settings = this.elements.settings;
|
||
|
||
const sidebarControl = $checkbox({
|
||
$: (el) => (settings['sidebar-control-always-compact'] = el),
|
||
textContent: 'Sidebar controls always compact',
|
||
});
|
||
sidebarControl
|
||
.getElementsByTagName('input')[0]
|
||
.addEventListener('change', () => {
|
||
updateSidebarButtons();
|
||
});
|
||
|
||
const reloadButton = new ComfyButton({
|
||
content: 'Reload',
|
||
tooltip: 'Reload settings and model manager files',
|
||
action: async (e) => {
|
||
const [button, icon, span] = comfyButtonDisambiguate(e.target);
|
||
button.disabled = true;
|
||
await this.reload(true);
|
||
button.disabled = false;
|
||
},
|
||
}).element;
|
||
this.elements.reloadButton = reloadButton;
|
||
|
||
const saveButton = new ComfyButton({
|
||
content: 'Save',
|
||
tooltip: 'Save settings and reload model manager',
|
||
action: async (e) => {
|
||
const [button, icon, span] = comfyButtonDisambiguate(e.target);
|
||
button.disabled = true;
|
||
await this.save();
|
||
button.disabled = false;
|
||
},
|
||
}).element;
|
||
this.elements.saveButton = saveButton;
|
||
|
||
const correctPreviewsButton = new ComfyButton({
|
||
content: 'Fix Preview Extensions',
|
||
tooltip: 'Correct image file extensions in all model directories',
|
||
action: async (e) => {
|
||
const [button, icon, span] = comfyButtonDisambiguate(e.target);
|
||
button.disabled = true;
|
||
const data = await comfyRequest(
|
||
'/model-manager/preview/correct-extensions',
|
||
).catch((err) => {
|
||
return { success: false };
|
||
});
|
||
const success = data['success'];
|
||
if (success) {
|
||
const detectPlural = data['detected'] === 1 ? '' : 's';
|
||
const correctPlural = data['corrected'] === 1 ? '' : 's';
|
||
const message = `Detected ${data['detected']} extension${detectPlural}.\nCorrected ${data['corrected']} extension${correctPlural}.`;
|
||
window.alert(message);
|
||
}
|
||
comfyButtonAlert(e.target, success);
|
||
if (data['corrected'] > 0) {
|
||
await this.reload(true);
|
||
}
|
||
button.disabled = false;
|
||
},
|
||
}).element;
|
||
|
||
const scanDownloadModelInfosButton = new ComfyButton({
|
||
content: 'Download Model Info',
|
||
tooltip: 'Scans all model files and tries to download and save model info, notes and urls.',
|
||
action: async (e) => {
|
||
const confirmation = window.confirm(
|
||
'WARNING: This may take a while and generate MANY server requests!\nUSE AT YOUR OWN RISK!',
|
||
);
|
||
if (!confirmation) {
|
||
return;
|
||
}
|
||
|
||
const [button, icon, span] = comfyButtonDisambiguate(e.target);
|
||
button.disabled = true;
|
||
const data = await comfyRequest('/model-manager/models/scan', {
|
||
method: 'POST',
|
||
body: JSON.stringify({}),
|
||
}).catch((err) => {
|
||
return { success: false };
|
||
});
|
||
const successMessage = data['success'] ? "Scan Finished!" : "Scan Failed!";
|
||
const infoCount = data['infoCount'];
|
||
const notesCount = data['notesCount'];
|
||
const urlCount = data['urlCount'];
|
||
window.alert(`${successMessage}\nScanned: ${infoCount}\nSaved Notes: ${notesCount}\nSaved Url: ${urlCount}`);
|
||
comfyButtonAlert(e.target, success);
|
||
if (infoCount > 0 || notesCount > 0 || urlCount > 0) {
|
||
await this.reload(true);
|
||
}
|
||
button.disabled = false;
|
||
},
|
||
}).element;
|
||
|
||
$el(
|
||
'div.model-manager-settings',
|
||
{
|
||
$: (el) => (this.element = el),
|
||
},
|
||
[
|
||
$el('h1', ['Settings']),
|
||
$el('div', [reloadButton, saveButton]),
|
||
$el(
|
||
'a',
|
||
{
|
||
style: { color: 'var(--fg-color)' },
|
||
href: 'https://github.com/hayden-fr/ComfyUI-Model-Manager/issues/',
|
||
target: '_blank',
|
||
},
|
||
['File bugs and issues here.'],
|
||
),
|
||
$el('h2', ['Model Search']),
|
||
$el('div', [
|
||
$el('div.search-settings-text', [
|
||
$el('p', ['Always include in model search:']),
|
||
$el('textarea.comfy-multiline-input', {
|
||
$: (el) => (settings['model-search-always-append'] = el),
|
||
name: 'always include in model search',
|
||
placeholder: 'example: /0/sd1.5/styles "pastel style" -3d',
|
||
rows: '6',
|
||
}),
|
||
]),
|
||
]),
|
||
$select({
|
||
$: (el) => (settings['model-default-browser-model-type'] = el),
|
||
textContent: 'Default model search type (on start up)',
|
||
options: [
|
||
'checkpoints',
|
||
'clip',
|
||
'clip_vision',
|
||
'controlnet',
|
||
'diffusers',
|
||
'embeddings',
|
||
'gligen',
|
||
'hypernetworks',
|
||
'loras',
|
||
'photomaker',
|
||
'style_models',
|
||
'unet',
|
||
'vae',
|
||
'vae_approx',
|
||
],
|
||
}),
|
||
$select({
|
||
$: (el) => (settings['sidebar-default-state'] = el),
|
||
textContent: 'Default model manager position (on start up)',
|
||
options: ['Left', 'Right', 'Top', 'Bottom', 'None'],
|
||
}),
|
||
$checkbox({
|
||
$: (el) => (settings['model-real-time-search'] = el),
|
||
textContent: 'Real-time search',
|
||
}),
|
||
$checkbox({
|
||
$: (el) => (settings['model-persistent-search'] = el),
|
||
textContent: 'Persistent search text (across model types)',
|
||
}),
|
||
$el('h2', ['Model Search Thumbnails']),
|
||
$select({
|
||
$: (el) => (settings['model-preview-thumbnail-type'] = el),
|
||
textContent: 'Preview thumbnail type',
|
||
options: ['AUTO', 'JPEG'], // should use AUTO to avoid artifacts from changing between formats; use JPEG for backward compatibility
|
||
}),
|
||
$checkbox({
|
||
$: (el) =>
|
||
(settings['model-preview-fallback-search-safetensors-thumbnail'] =
|
||
el),
|
||
textContent: 'Fallback to embedded safetensors image (slow)',
|
||
}),
|
||
$checkbox({
|
||
$: (el) => (settings['model-show-label-extensions'] = el),
|
||
textContent: 'Show file extension',
|
||
}),
|
||
$checkbox({
|
||
$: (el) => (settings['model-show-copy-button'] = el),
|
||
textContent: 'Show "Copy" button',
|
||
}),
|
||
$checkbox({
|
||
$: (el) => (settings['model-show-add-button'] = el),
|
||
textContent: 'Show "Add" button',
|
||
}),
|
||
$checkbox({
|
||
$: (el) => (settings['model-show-load-workflow-button'] = el),
|
||
textContent: 'Show "Load Workflow" button',
|
||
}),$checkbox({
|
||
$: (el) => (settings['model-show-open-model-url-button'] = el),
|
||
textContent: 'Show "Open Model Url" button',
|
||
}),
|
||
$checkbox({
|
||
$: (el) => (settings['model-info-button-on-left'] = el),
|
||
textContent: '"Model Info" button on left',
|
||
}),
|
||
$el('h2', ['Node Graph']),
|
||
$checkbox({
|
||
$: (el) => (settings['model-add-embedding-extension'] = el),
|
||
textContent: 'Add embedding with extension',
|
||
}),
|
||
$checkbox({
|
||
$: (el) => (settings['model-add-drag-strict-on-field'] = el), // true -> must drag on field; false -> can drag on node when unambiguous
|
||
textContent: "Must always drag thumbnail onto node's input field",
|
||
}),
|
||
$el('label', [
|
||
'Add offset', // if a node already was added to the same spot, add the next one with an offset
|
||
$el('input', {
|
||
$: (el) => (settings['model-add-offset'] = el),
|
||
type: 'number',
|
||
name: 'model add offset',
|
||
step: 5,
|
||
}),
|
||
]),
|
||
$el('h2', ['Model Info']),
|
||
$checkbox({
|
||
$: (el) => (settings['model-info-autosave-notes'] = el), // note history deleted on model info close
|
||
textContent: 'Autosave notes',
|
||
}),
|
||
$el('h2', ['Download']),
|
||
$checkbox({
|
||
$: (el) => (settings['download-save-description-as-text-file'] = el),
|
||
textContent: 'Save notes by default.',
|
||
}),
|
||
$el('h2', ['Window']),
|
||
sidebarControl,
|
||
$el('label', [
|
||
'Sidebar width (on start up)',
|
||
$el('input', {
|
||
$: (el) => (settings['sidebar-default-width'] = el),
|
||
type: 'range',
|
||
name: 'default sidebar width',
|
||
value: 0.5,
|
||
min: 0.0,
|
||
max: 1.0,
|
||
step: 0.05,
|
||
}),
|
||
]),
|
||
$el('label', [
|
||
'Sidebar height (on start up)',
|
||
$el('input', {
|
||
$: (el) => (settings['sidebar-default-height'] = el),
|
||
type: 'range',
|
||
name: 'default sidebar height',
|
||
value: 0.5,
|
||
min: 0.0,
|
||
max: 1.0,
|
||
step: 0.05,
|
||
}),
|
||
]),
|
||
$checkbox({
|
||
$: (el) => (settings['text-input-always-hide-search-button'] = el),
|
||
textContent: 'Always hide "Search" buttons.',
|
||
}),
|
||
$checkbox({
|
||
$: (el) => (settings['text-input-always-hide-clear-button'] = el),
|
||
textContent: 'Always hide "Clear Search" buttons.',
|
||
}),
|
||
$el('h2', ['Scan Files']),
|
||
$el('div', [correctPreviewsButton]),
|
||
$el('div', [scanDownloadModelInfosButton]),
|
||
$el('h2', ['Random Tag Generator']),
|
||
$select({
|
||
$: (el) => (settings['tag-generator-sampler-method'] = el),
|
||
textContent: 'Sampling method',
|
||
options: ['Frequency', 'Uniform'],
|
||
}),
|
||
$el('label', [
|
||
'Generation count',
|
||
$el('input', {
|
||
$: (el) => (settings['tag-generator-count'] = el),
|
||
type: 'number',
|
||
name: 'tag generator count',
|
||
step: 1,
|
||
min: 1,
|
||
}),
|
||
]),
|
||
$el('label', [
|
||
'Minimum frequency threshold',
|
||
$el('input', {
|
||
$: (el) => (settings['tag-generator-threshold'] = el),
|
||
type: 'number',
|
||
name: 'tag generator threshold',
|
||
step: 1,
|
||
min: 1,
|
||
}),
|
||
]),
|
||
],
|
||
);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* @param {String[]} labels
|
||
* @param {[(event: Event) => Promise<void>]} callbacks
|
||
* @returns {HTMLDivElement}
|
||
*/
|
||
function GenerateRadioButtonGroup(labels, callbacks = []) {
|
||
const RADIO_BUTTON_GROUP_ACTIVE = 'radio-button-group-active';
|
||
const radioButtonGroup = $el('div.radio-button-group', []);
|
||
const buttons = [];
|
||
for (let i = 0; i < labels.length; i++) {
|
||
const text = labels[i];
|
||
const callback = callbacks[i] ?? (() => {});
|
||
buttons.push(
|
||
$el('button.radio-button', {
|
||
textContent: text,
|
||
onclick: (event) => {
|
||
const targetIsActive = event.target.classList.contains(
|
||
RADIO_BUTTON_GROUP_ACTIVE,
|
||
);
|
||
if (targetIsActive) {
|
||
return;
|
||
}
|
||
const children = radioButtonGroup.children;
|
||
for (let i = 0; i < children.length; i++) {
|
||
children[i].classList.remove(RADIO_BUTTON_GROUP_ACTIVE);
|
||
}
|
||
event.target.classList.add(RADIO_BUTTON_GROUP_ACTIVE);
|
||
callback(event);
|
||
},
|
||
}),
|
||
);
|
||
}
|
||
radioButtonGroup.append.apply(radioButtonGroup, buttons);
|
||
buttons[0]?.classList.add(RADIO_BUTTON_GROUP_ACTIVE);
|
||
return radioButtonGroup;
|
||
}
|
||
|
||
/**
|
||
* @param {String[]} labels
|
||
* @param {[(event: Event) => Promise<void>]} activationCallbacks
|
||
* @param {(event: Event) => Promise<void>} deactivationCallback
|
||
* @returns {HTMLDivElement}
|
||
*/
|
||
function GenerateToggleRadioButtonGroup(
|
||
labels,
|
||
activationCallbacks = [],
|
||
deactivationCallback = () => {},
|
||
) {
|
||
const RADIO_BUTTON_GROUP_ACTIVE = 'radio-button-group-active';
|
||
const radioButtonGroup = $el('div.radio-button-group', []);
|
||
const buttons = [];
|
||
for (let i = 0; i < labels.length; i++) {
|
||
const text = labels[i];
|
||
const activationCallback = activationCallbacks[i] ?? (() => {});
|
||
buttons.push(
|
||
$el('button.radio-button', {
|
||
textContent: text,
|
||
onclick: (event) => {
|
||
const targetIsActive = event.target.classList.contains(
|
||
RADIO_BUTTON_GROUP_ACTIVE,
|
||
);
|
||
const children = radioButtonGroup.children;
|
||
for (let i = 0; i < children.length; i++) {
|
||
children[i].classList.remove(RADIO_BUTTON_GROUP_ACTIVE);
|
||
}
|
||
if (targetIsActive) {
|
||
deactivationCallback(event);
|
||
} else {
|
||
event.target.classList.add(RADIO_BUTTON_GROUP_ACTIVE);
|
||
activationCallback(event);
|
||
}
|
||
},
|
||
}),
|
||
);
|
||
}
|
||
radioButtonGroup.append.apply(radioButtonGroup, buttons);
|
||
return radioButtonGroup;
|
||
}
|
||
|
||
/**
|
||
* Coupled-state select and radio buttons (hidden first radio button)
|
||
* @param {String[]} labels
|
||
* @param {[(button: HTMLButtonElement) => Promise<void>]} activationCallbacks
|
||
* @returns {[HTMLDivElement, HTMLSelectElement]}
|
||
*/
|
||
function GenerateSidebarToggleRadioAndSelect(labels, activationCallbacks = []) {
|
||
const RADIO_BUTTON_GROUP_ACTIVE = 'radio-button-group-active';
|
||
const radioButtonGroup = $el('div.radio-button-group', []);
|
||
const buttons = [];
|
||
|
||
const select = $el(
|
||
'select',
|
||
{
|
||
name: 'sidebar-select',
|
||
classList: 'icon-button',
|
||
onchange: (event) => {
|
||
const select = event.target;
|
||
const children = select.children;
|
||
let value = undefined;
|
||
for (let i = 0; i < children.length; i++) {
|
||
const child = children[i];
|
||
if (child.selected) {
|
||
value = child.value;
|
||
}
|
||
}
|
||
for (let i = 0; i < buttons.length; i++) {
|
||
const button = buttons[i];
|
||
if (button.textContent === value) {
|
||
for (let i = 0; i < buttons.length; i++) {
|
||
buttons[i].classList.remove(RADIO_BUTTON_GROUP_ACTIVE);
|
||
}
|
||
button.classList.add(RADIO_BUTTON_GROUP_ACTIVE);
|
||
activationCallbacks[i](button);
|
||
break;
|
||
}
|
||
}
|
||
},
|
||
},
|
||
labels.map((option) => {
|
||
return $el(
|
||
'option',
|
||
{
|
||
value: option,
|
||
},
|
||
option,
|
||
);
|
||
}),
|
||
);
|
||
|
||
for (let i = 0; i < labels.length; i++) {
|
||
const text = labels[i];
|
||
const activationCallback = activationCallbacks[i] ?? (() => {});
|
||
buttons.push(
|
||
$el('button.radio-button', {
|
||
textContent: text,
|
||
onclick: (event) => {
|
||
const button = event.target;
|
||
let textContent = button.textContent;
|
||
const targetIsActive = button.classList.contains(
|
||
RADIO_BUTTON_GROUP_ACTIVE,
|
||
);
|
||
if (
|
||
button === buttons[0] &&
|
||
buttons[0].classList.contains(RADIO_BUTTON_GROUP_ACTIVE)
|
||
) {
|
||
// do not deactivate 0
|
||
return;
|
||
}
|
||
// update button
|
||
const children = radioButtonGroup.children;
|
||
for (let i = 0; i < children.length; i++) {
|
||
children[i].classList.remove(RADIO_BUTTON_GROUP_ACTIVE);
|
||
}
|
||
if (targetIsActive) {
|
||
// return to 0
|
||
textContent = labels[0];
|
||
buttons[0].classList.add(RADIO_BUTTON_GROUP_ACTIVE);
|
||
activationCallbacks[0](buttons[0]);
|
||
} else {
|
||
// move to >0
|
||
button.classList.add(RADIO_BUTTON_GROUP_ACTIVE);
|
||
activationCallback(button);
|
||
}
|
||
// update selection
|
||
for (let i = 0; i < select.children.length; i++) {
|
||
const option = select.children[i];
|
||
option.selected = option.value === textContent;
|
||
}
|
||
},
|
||
}),
|
||
);
|
||
}
|
||
radioButtonGroup.append.apply(radioButtonGroup, buttons);
|
||
buttons[0].style.display = 'none';
|
||
return [radioButtonGroup, select];
|
||
}
|
||
|
||
class ModelManager extends ComfyDialog {
|
||
/** @type {HTMLDivElement} */
|
||
element = null;
|
||
|
||
/** @type {ModelData} */
|
||
#modelData = null;
|
||
|
||
/** @type {ModelInfo} */
|
||
#modelInfo = null;
|
||
|
||
/** @type {DownloadView} */
|
||
#downloadView = null;
|
||
|
||
/** @type {BrowseView} */
|
||
#browseView = null;
|
||
|
||
/** @type {SettingsView} */
|
||
#settingsView = null;
|
||
|
||
/** @type {HTMLDivElement} */
|
||
#topbarRight = null;
|
||
|
||
/** @type {HTMLDivElement} */
|
||
#tabManagerButtons = null;
|
||
|
||
/** @type {HTMLDivElement} */
|
||
#tabManagerContents = null;
|
||
|
||
/** @type {HTMLDivElement} */
|
||
#tabInfoButtons = null;
|
||
|
||
/** @type {HTMLDivElement} */
|
||
#tabInfoContents = null;
|
||
|
||
/** @type {HTMLButtonElement} */
|
||
#sidebarButtonGroup = null;
|
||
|
||
/** @type {HTMLButtonElement} */
|
||
#sidebarSelect = null;
|
||
|
||
/** @type {HTMLButtonElement} */
|
||
#closeModelInfoButton = null;
|
||
|
||
/** @type {String} */
|
||
#dragSidebarState = '';
|
||
|
||
constructor() {
|
||
super();
|
||
|
||
this.#modelData = new ModelData();
|
||
|
||
this.#settingsView = new SettingsView(this.#refreshModels, () =>
|
||
this.#updateSidebarButtons(),
|
||
this.#updateSidebarSettings,
|
||
);
|
||
|
||
this.#modelInfo = new ModelInfo(
|
||
this.#modelData,
|
||
this.#refreshModels,
|
||
this.#settingsView.elements.settings,
|
||
() => this.#tryHideModelInfo(),
|
||
);
|
||
|
||
this.#browseView = new BrowseView(
|
||
this.#refreshModels,
|
||
this.#modelData,
|
||
this.#showModelInfo,
|
||
this.#resetManagerContentsScroll,
|
||
this.#settingsView.elements.settings, // TODO: decouple settingsData from elements?
|
||
);
|
||
|
||
this.#downloadView = new DownloadView(
|
||
this.#modelData,
|
||
this.#settingsView.elements.settings,
|
||
this.#refreshModels,
|
||
);
|
||
|
||
const [tabManagerButtons, tabManagerContents] = GenerateTabGroup([
|
||
{
|
||
name: 'Download',
|
||
icon: 'arrow-collapse-down',
|
||
tabContent: this.#downloadView.element,
|
||
},
|
||
{
|
||
name: 'Models',
|
||
icon: 'folder-search-outline',
|
||
tabContent: this.#browseView.element,
|
||
},
|
||
{
|
||
name: 'Settings',
|
||
icon: 'cog-outline',
|
||
tabContent: this.#settingsView.element,
|
||
},
|
||
]);
|
||
tabManagerButtons[0]?.click();
|
||
|
||
const tabInfoButtons = this.#modelInfo.elements.tabButtons;
|
||
const tabInfoContents = this.#modelInfo.elements.tabContents;
|
||
|
||
const [sidebarButtonGroup, sidebarSelect] =
|
||
GenerateSidebarToggleRadioAndSelect(
|
||
['◼', '◨', '⬒', '⬓', '◧'],
|
||
[
|
||
() => {
|
||
this.element.dataset['sidebarState'] = 'none';
|
||
},
|
||
() => {
|
||
this.element.dataset['sidebarState'] = 'right';
|
||
},
|
||
() => {
|
||
this.element.dataset['sidebarState'] = 'top';
|
||
},
|
||
() => {
|
||
this.element.dataset['sidebarState'] = 'bottom';
|
||
},
|
||
() => {
|
||
this.element.dataset['sidebarState'] = 'left';
|
||
},
|
||
],
|
||
);
|
||
this.#sidebarButtonGroup = sidebarButtonGroup;
|
||
this.#sidebarSelect = sidebarSelect;
|
||
sidebarButtonGroup.classList.add('sidebar-buttons');
|
||
const sidebarButtonGroupChildren = sidebarButtonGroup.children;
|
||
for (let i = 0; i < sidebarButtonGroupChildren.length; i++) {
|
||
sidebarButtonGroupChildren[i].classList.add('icon-button');
|
||
}
|
||
|
||
const closeModelInfoButton = new ComfyButton({
|
||
icon: 'arrow-u-left-bottom',
|
||
tooltip: 'Return to model search',
|
||
classList: 'comfyui-button icon-button',
|
||
action: async () => await this.#tryHideModelInfo(true),
|
||
}).element;
|
||
this.#closeModelInfoButton = closeModelInfoButton;
|
||
closeModelInfoButton.style.display = 'none';
|
||
|
||
const modelManager = $el(
|
||
'div.comfy-modal.model-manager',
|
||
{
|
||
$: (el) => (this.element = el),
|
||
parent: document.body,
|
||
dataset: {
|
||
sidebarState: 'none',
|
||
sidebarLeftWidthDecimal: '',
|
||
sidebarRightWidthDecimal: '',
|
||
sidebarTopHeightDecimal: '',
|
||
sidebarBottomHeightDecimal: '',
|
||
},
|
||
},
|
||
[
|
||
$el('div.comfy-modal-content', [
|
||
// TODO: settings.top_bar_left_to_right or settings.top_bar_right_to_left
|
||
$el('div.model-manager-panel', [
|
||
$el('div.model-manager-head', [
|
||
$el(
|
||
'div.topbar-right',
|
||
{
|
||
$: (el) => (this.#topbarRight = el),
|
||
},
|
||
[
|
||
new ComfyButton({
|
||
icon: 'window-close',
|
||
tooltip: 'Close model manager',
|
||
classList: 'comfyui-button icon-button',
|
||
action: async () => {
|
||
const saved = await this.#modelInfo.trySave(true);
|
||
if (saved) {
|
||
this.close();
|
||
}
|
||
},
|
||
}).element,
|
||
closeModelInfoButton,
|
||
sidebarSelect,
|
||
sidebarButtonGroup,
|
||
],
|
||
),
|
||
$el('div.topbar-left', [
|
||
$el('div', [
|
||
$el(
|
||
'div.model-tab-group.no-highlight',
|
||
{
|
||
$: (el) => (this.#tabManagerButtons = el),
|
||
},
|
||
tabManagerButtons,
|
||
),
|
||
$el(
|
||
'div.model-tab-group.no-highlight',
|
||
{
|
||
$: (el) => (this.#tabInfoButtons = el),
|
||
style: { display: 'none' },
|
||
},
|
||
tabInfoButtons,
|
||
),
|
||
]),
|
||
]),
|
||
]),
|
||
$el('div.model-manager-body', [
|
||
$el(
|
||
'div.tab-contents',
|
||
{
|
||
$: (el) => (this.#tabManagerContents = el),
|
||
},
|
||
tabManagerContents,
|
||
),
|
||
$el(
|
||
'div.tab-contents',
|
||
{
|
||
$: (el) => (this.#tabInfoContents = el),
|
||
style: { display: 'none' },
|
||
},
|
||
tabInfoContents,
|
||
),
|
||
]),
|
||
]),
|
||
]),
|
||
],
|
||
);
|
||
|
||
new ResizeObserver(
|
||
GenerateDynamicTabTextCallback(modelManager, tabManagerButtons, 704),
|
||
).observe(modelManager);
|
||
new ResizeObserver(
|
||
GenerateDynamicTabTextCallback(modelManager, tabInfoButtons, 704),
|
||
).observe(modelManager);
|
||
new ResizeObserver(() => this.#updateSidebarButtons()).observe(
|
||
modelManager,
|
||
);
|
||
window.addEventListener('resize', () => {
|
||
const width = window.innerWidth;
|
||
const height = window.innerHeight;
|
||
|
||
const leftDecimal = modelManager.dataset['sidebarLeftWidthDecimal'];
|
||
const rightDecimal = modelManager.dataset['sidebarRightWidthDecimal'];
|
||
const topDecimal = modelManager.dataset['sidebarTopHeightDecimal'];
|
||
const bottomDecimal = modelManager.dataset['sidebarBottomHeightDecimal'];
|
||
|
||
// restore decimal after resize
|
||
modelManager.style.setProperty(
|
||
'--model-manager-sidebar-width-left',
|
||
leftDecimal * width + 'px',
|
||
);
|
||
modelManager.style.setProperty(
|
||
'--model-manager-sidebar-width-right',
|
||
rightDecimal * width + 'px',
|
||
);
|
||
modelManager.style.setProperty(
|
||
'--model-manager-sidebar-height-top',
|
||
+(topDecimal * height) + 'px',
|
||
);
|
||
modelManager.style.setProperty(
|
||
'--model-manager-sidebar-height-bottom',
|
||
bottomDecimal * height + 'px',
|
||
);
|
||
});
|
||
|
||
const EDGE_DELTA = 8;
|
||
|
||
const endDragSidebar = (e) => {
|
||
this.#dragSidebarState = '';
|
||
|
||
modelManager.classList.remove('cursor-drag-left');
|
||
modelManager.classList.remove('cursor-drag-top');
|
||
modelManager.classList.remove('cursor-drag-right');
|
||
modelManager.classList.remove('cursor-drag-bottom');
|
||
|
||
// cache for window resize
|
||
modelManager.dataset['sidebarLeftWidthDecimal'] =
|
||
parseInt(
|
||
modelManager.style.getPropertyValue(
|
||
'--model-manager-sidebar-width-left',
|
||
),
|
||
) / window.innerWidth;
|
||
modelManager.dataset['sidebarRightWidthDecimal'] =
|
||
parseInt(
|
||
modelManager.style.getPropertyValue(
|
||
'--model-manager-sidebar-width-right',
|
||
),
|
||
) / window.innerWidth;
|
||
modelManager.dataset['sidebarTopHeightDecimal'] =
|
||
parseInt(
|
||
modelManager.style.getPropertyValue(
|
||
'--model-manager-sidebar-height-top',
|
||
),
|
||
) / window.innerHeight;
|
||
modelManager.dataset['sidebarBottomHeightDecimal'] =
|
||
parseInt(
|
||
modelManager.style.getPropertyValue(
|
||
'--model-manager-sidebar-height-bottom',
|
||
),
|
||
) / window.innerHeight;
|
||
};
|
||
document.addEventListener('mouseup', (e) => endDragSidebar(e));
|
||
document.addEventListener('touchend', (e) => endDragSidebar(e));
|
||
|
||
const detectDragSidebar = (e, x, y) => {
|
||
const left = modelManager.offsetLeft;
|
||
const top = modelManager.offsetTop;
|
||
const width = modelManager.offsetWidth;
|
||
const height = modelManager.offsetHeight;
|
||
const right = left + width;
|
||
const bottom = top + height;
|
||
|
||
if (!(x >= left && x <= right && y >= top && y <= bottom)) {
|
||
// click was not in model manager
|
||
return;
|
||
}
|
||
|
||
const isOnEdgeLeft = x - left <= EDGE_DELTA;
|
||
const isOnEdgeRight = right - x <= EDGE_DELTA;
|
||
const isOnEdgeTop = y - top <= EDGE_DELTA;
|
||
const isOnEdgeBottom = bottom - y <= EDGE_DELTA;
|
||
|
||
const sidebarState = this.element.dataset['sidebarState'];
|
||
if (sidebarState === 'left' && isOnEdgeRight) {
|
||
this.#dragSidebarState = sidebarState;
|
||
} else if (sidebarState === 'right' && isOnEdgeLeft) {
|
||
this.#dragSidebarState = sidebarState;
|
||
} else if (sidebarState === 'top' && isOnEdgeBottom) {
|
||
this.#dragSidebarState = sidebarState;
|
||
} else if (sidebarState === 'bottom' && isOnEdgeTop) {
|
||
this.#dragSidebarState = sidebarState;
|
||
}
|
||
|
||
if (this.#dragSidebarState !== '') {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
}
|
||
};
|
||
modelManager.addEventListener('mousedown', (e) =>
|
||
detectDragSidebar(e, e.clientX, e.clientY),
|
||
);
|
||
modelManager.addEventListener('touchstart', (e) =>
|
||
detectDragSidebar(e, e.touches[0].clientX, e.touches[0].clientY),
|
||
);
|
||
|
||
const updateSidebarCursor = (e, x, y) => {
|
||
if (this.#dragSidebarState !== '') {
|
||
// do not update cursor style while dragging
|
||
return;
|
||
}
|
||
|
||
const left = modelManager.offsetLeft;
|
||
const top = modelManager.offsetTop;
|
||
const width = modelManager.offsetWidth;
|
||
const height = modelManager.offsetHeight;
|
||
const right = left + width;
|
||
const bottom = top + height;
|
||
|
||
const isOnEdgeLeft = x - left <= EDGE_DELTA;
|
||
const isOnEdgeRight = right - x <= EDGE_DELTA;
|
||
const isOnEdgeTop = y - top <= EDGE_DELTA;
|
||
const isOnEdgeBottom = bottom - y <= EDGE_DELTA;
|
||
|
||
const updateClass = (add, className) => {
|
||
if (add) {
|
||
modelManager.classList.add(className);
|
||
} else {
|
||
modelManager.classList.remove(className);
|
||
}
|
||
};
|
||
|
||
const sidebarState = this.element.dataset['sidebarState'];
|
||
updateClass(sidebarState === 'right' && isOnEdgeLeft, 'cursor-drag-left');
|
||
updateClass(sidebarState === 'bottom' && isOnEdgeTop, 'cursor-drag-top');
|
||
updateClass(
|
||
sidebarState === 'left' && isOnEdgeRight,
|
||
'cursor-drag-right',
|
||
);
|
||
updateClass(
|
||
sidebarState === 'top' && isOnEdgeBottom,
|
||
'cursor-drag-bottom',
|
||
);
|
||
};
|
||
modelManager.addEventListener('mousemove', (e) =>
|
||
updateSidebarCursor(e, e.clientX, e.clientY),
|
||
);
|
||
modelManager.addEventListener('touchmove', (e) =>
|
||
updateSidebarCursor(e, e.touches[0].clientX, e.touches[0].clientY),
|
||
);
|
||
|
||
const updateDragSidebar = (e, x, y) => {
|
||
const sidebarState = this.#dragSidebarState;
|
||
if (sidebarState === '') {
|
||
return;
|
||
}
|
||
|
||
e.preventDefault();
|
||
|
||
const width = window.innerWidth;
|
||
const height = window.innerHeight;
|
||
|
||
if (sidebarState === 'left') {
|
||
const pixels = clamp(x, 0, width).toString() + 'px';
|
||
modelManager.style.setProperty(
|
||
'--model-manager-sidebar-width-left',
|
||
pixels,
|
||
);
|
||
} else if (sidebarState === 'right') {
|
||
const pixels = clamp(width - x, 0, width).toString() + 'px';
|
||
modelManager.style.setProperty(
|
||
'--model-manager-sidebar-width-right',
|
||
pixels,
|
||
);
|
||
} else if (sidebarState === 'top') {
|
||
const pixels = clamp(y, 0, height).toString() + 'px';
|
||
modelManager.style.setProperty(
|
||
'--model-manager-sidebar-height-top',
|
||
pixels,
|
||
);
|
||
} else if (sidebarState === 'bottom') {
|
||
const pixels = clamp(height - y, 0, height).toString() + 'px';
|
||
modelManager.style.setProperty(
|
||
'--model-manager-sidebar-height-bottom',
|
||
pixels,
|
||
);
|
||
}
|
||
};
|
||
document.addEventListener('mousemove', (e) =>
|
||
updateDragSidebar(e, e.clientX, e.clientY),
|
||
);
|
||
document.addEventListener('touchmove', (e) =>
|
||
updateDragSidebar(e, e.touches[0].clientX, e.touches[0].clientY),
|
||
);
|
||
|
||
if(IS_FIREFOX){
|
||
app.canvasContainer.addEventListener('drop', (e) => {
|
||
if (e.dataTransfer.types.includes('manager-model')){
|
||
const data = JSON.parse(e.dataTransfer.getData('manager-model'));
|
||
ModelGrid.dragAddModel(
|
||
e,
|
||
data.modelType,
|
||
data.path,
|
||
data.removeEmbeddingExtension,
|
||
data.strictDragToAdd,
|
||
);
|
||
}
|
||
});
|
||
}
|
||
this.#init();
|
||
}
|
||
|
||
async #init() {
|
||
await this.#settingsView.reload(false);
|
||
await this.#refreshModels();
|
||
|
||
const settings = this.#settingsView.elements.settings;
|
||
|
||
{
|
||
// set initial sidebar state
|
||
const newSidebarState = settings['sidebar-default-state'].value;
|
||
let buttonNumb = 0;
|
||
if (newSidebarState === 'Right') {
|
||
buttonNumb = 1;
|
||
} else if (newSidebarState === 'Top') {
|
||
buttonNumb = 2;
|
||
} else if (newSidebarState === 'Bottom') {
|
||
buttonNumb = 3;
|
||
} else if (newSidebarState === 'Left') {
|
||
buttonNumb = 4;
|
||
}
|
||
this.#sidebarButtonGroup.children[buttonNumb].click();
|
||
}
|
||
|
||
{
|
||
// set initial sidebar widths & heights
|
||
const width = window.innerWidth;
|
||
const height = window.innerHeight;
|
||
|
||
const xDecimal = settings['sidebar-default-width'].value;
|
||
const yDecimal = settings['sidebar-default-height'].value;
|
||
|
||
this.element.dataset['sidebarLeftWidthDecimal'] = xDecimal;
|
||
this.element.dataset['sidebarRightWidthDecimal'] = xDecimal;
|
||
this.element.dataset['sidebarTopHeightDecimal'] = yDecimal;
|
||
this.element.dataset['sidebarBottomHeightDecimal'] = yDecimal;
|
||
|
||
const x = Math.floor(width * xDecimal);
|
||
const y = Math.floor(height * yDecimal);
|
||
|
||
const leftPixels = x.toString() + 'px';
|
||
this.element.style.setProperty(
|
||
'--model-manager-sidebar-width-left',
|
||
leftPixels,
|
||
);
|
||
|
||
const rightPixels = x.toString() + 'px';
|
||
this.element.style.setProperty(
|
||
'--model-manager-sidebar-width-right',
|
||
rightPixels,
|
||
);
|
||
|
||
const topPixels = y.toString() + 'px';
|
||
this.element.style.setProperty(
|
||
'--model-manager-sidebar-height-top',
|
||
topPixels,
|
||
);
|
||
|
||
const bottomPixels = y.toString() + 'px';
|
||
this.element.style.setProperty(
|
||
'--model-manager-sidebar-height-bottom',
|
||
bottomPixels,
|
||
);
|
||
}
|
||
}
|
||
|
||
#updateSidebarSettings = (settings) => {
|
||
{
|
||
// update buttons' visibility state
|
||
const hideSearchButtons =
|
||
settings['text-input-always-hide-search-button'].checked;
|
||
const hideClearSearchButtons =
|
||
settings['text-input-always-hide-clear-button'].checked;
|
||
this.#downloadView.elements.searchButton.style.display = hideSearchButtons
|
||
? 'none'
|
||
: '';
|
||
this.#downloadView.elements.clearSearchButton.style.display =
|
||
hideClearSearchButtons ? 'none' : '';
|
||
}
|
||
}
|
||
|
||
#resetManagerContentsScroll = () => {
|
||
this.#tabManagerContents.scrollTop = 0;
|
||
};
|
||
|
||
#refreshModels = async () => {
|
||
const modelData = this.#modelData;
|
||
modelData.systemSeparator = await comfyRequest(
|
||
'/model-manager/system-separator',
|
||
);
|
||
const newModels = await comfyRequest('/model-manager/models/list');
|
||
Object.assign(modelData.models, newModels); // NOTE: do NOT create a new object
|
||
const newModelDirectories = await comfyRequest(
|
||
'/model-manager/models/directory-list',
|
||
);
|
||
modelData.directories.data.splice(0, Infinity, ...newModelDirectories); // NOTE: do NOT create a new array
|
||
|
||
this.#browseView.updateModelGrid();
|
||
await this.#tryHideModelInfo(false);
|
||
|
||
document.getElementById('comfy-refresh-button')?.click();
|
||
};
|
||
|
||
/**
|
||
* @param {searchPath: string}
|
||
* @return {Promise<void>}
|
||
*/
|
||
#showModelInfo = async (searchPath) => {
|
||
await this.#modelInfo
|
||
.update(searchPath, this.#refreshModels, this.#modelData.searchSeparator)
|
||
.then(() => {
|
||
this.#tabManagerButtons.style.display = 'none';
|
||
this.#tabManagerContents.style.display = 'none';
|
||
|
||
this.#closeModelInfoButton.style.display = '';
|
||
this.#tabInfoButtons.style.display = '';
|
||
this.#tabInfoContents.style.display = '';
|
||
|
||
this.#tabInfoButtons.children[0]?.click();
|
||
this.#modelInfo.show();
|
||
this.#tabInfoContents.scrollTop = 0;
|
||
});
|
||
};
|
||
|
||
/**
|
||
* @param {boolean} promptSave
|
||
* @returns {Promise<boolean>}
|
||
*/
|
||
#tryHideModelInfo = async (promptSave) => {
|
||
if (this.#tabInfoContents.style.display !== 'none') {
|
||
if (!(await this.#modelInfo.tryHide(promptSave))) {
|
||
return false;
|
||
}
|
||
|
||
this.#closeModelInfoButton.style.display = 'none';
|
||
this.#tabInfoButtons.style.display = 'none';
|
||
this.#tabInfoContents.style.display = 'none';
|
||
|
||
this.#tabManagerButtons.style.display = '';
|
||
this.#tabManagerContents.style.display = '';
|
||
}
|
||
return true;
|
||
};
|
||
|
||
#updateSidebarButtons = () => {
|
||
const managerRect = this.element.getBoundingClientRect();
|
||
const isNarrow = managerRect.width < 768; // TODO: `minWidth` is a magic value
|
||
const alwaysShowCompactSidebarControls =
|
||
this.#settingsView.elements.settings['sidebar-control-always-compact']
|
||
.checked;
|
||
if (isNarrow || alwaysShowCompactSidebarControls) {
|
||
this.#sidebarButtonGroup.style.display = 'none';
|
||
this.#sidebarSelect.style.display = '';
|
||
} else {
|
||
this.#sidebarButtonGroup.style.display = '';
|
||
this.#sidebarSelect.style.display = 'none';
|
||
}
|
||
};
|
||
}
|
||
|
||
/** @type {ModelManager | undefined} */
|
||
let instance;
|
||
|
||
/** @type {ComfyDialog | undefined} */
|
||
let modelManagerDialog;
|
||
|
||
/**
|
||
* @returns {ModelManager}
|
||
*/
|
||
function getInstance() {
|
||
if (!instance) {
|
||
instance = new ModelManager();
|
||
|
||
modelManagerDialog = new ComfyDialog();
|
||
modelManagerDialog.element.classList.add("model-manager-dialog");
|
||
instance.element.appendChild(modelManagerDialog.element);
|
||
}
|
||
return instance;
|
||
}
|
||
|
||
const toggleModelManager = () => {
|
||
const modelManager = getInstance();
|
||
const style = modelManager.element.style;
|
||
if (style.display === '' || style.display === 'none') {
|
||
modelManager.show();
|
||
} else {
|
||
modelManager.close();
|
||
}
|
||
};
|
||
|
||
app.registerExtension({
|
||
name: 'Comfy.ModelManager',
|
||
init() {},
|
||
async setup() {
|
||
const cssFileUrl = new URL(import.meta.url).pathname.replace('.js', '.css');
|
||
|
||
$el('link', {
|
||
parent: document.head,
|
||
rel: 'stylesheet',
|
||
href: cssFileUrl,
|
||
});
|
||
|
||
app.ui?.menuContainer?.appendChild(
|
||
$el('button', {
|
||
id: 'comfyui-model-manager-button',
|
||
parent: document.querySelector('.comfy-menu'),
|
||
textContent: 'Models',
|
||
onclick: () => toggleModelManager(),
|
||
}),
|
||
);
|
||
|
||
// [Beta] mobile menu
|
||
app.menu?.settingsGroup?.append(
|
||
new ComfyButton({
|
||
icon: 'folder-search',
|
||
tooltip: 'Opens model manager',
|
||
action: () => toggleModelManager(),
|
||
content: 'Model Manager',
|
||
popup: getInstance(),
|
||
}),
|
||
);
|
||
},
|
||
});
|