Added settings & config yaml.

This commit is contained in:
Christian Bastian
2024-01-05 07:35:24 -05:00
parent 724a9425c4
commit a8fa7c6c15
6 changed files with 452 additions and 91 deletions

1
.gitignore vendored
View File

@@ -158,3 +158,4 @@ cython_debug/
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
ui_settings.yaml

View File

@@ -18,16 +18,27 @@ Currently it is still missing some features it should have.
- Include models listed in ComfyUI's `extra_model_paths.yaml`.
- Button to copy a model to the ComfyUI clipboard or embedding to system clipboard. (Embedding copying requires secure http connection.)
- Button to add model to ComfyUI graph or embedding to selected nodes. (For small screens/low resolution.)
- Right, left and bottom toggleable sidebar modes.
- Right, left, top and bottom toggleable sidebar modes.
- Drag a model onto the graph to add a new node.
- Drag a model onto an existing node to set the model field.
- Drag an embedding onto a text area to add it to the end.
- Increased supported preview image types.
- Correctly change colors using ComfyUI's theme colors.
- Simplified UI.
- Settings tab and config file.
- Hide/Show 'add' and 'copy-to-clipboard' buttons.
- Text to always search.
- Show/Hide add embedding extension.
## TODO:
### Code
- ☐ Javascript cleanup.
- ☐ Seperate into classes per tab?
- ☐ HTML generation all inside main class?
- ☐ More server driven, HTMX-like HTML generation? (Avoid x2 states)
### Model Copying
- ☐ Copy image?
@@ -41,15 +52,9 @@ Currently it is still missing some features it should have.
### Settings
- ☐ Add `settings.yaml` and add file to `.gitignore`. (Generate if not there.)
- ☐ Exclude hidden folders with a `.` prefix.
- ☐ Include a optional string to always add to searches.
- ☐ Enable optional checksum to detect if a model is already downloaded.
- ☐ Enable/Disable add and copy buttons.
- ☐ Allow user to drag width of sidebar or height of bottom bar and remember it.
- ☐ Hide/Show model extension.
- ☐ Optionally remove embedding extension.
- ☐ Strict model drag on node widget textbox.
- ☐ Sidebar width.
### Search filtering and sort
@@ -76,6 +81,10 @@ Currently it is still missing some features it should have.
- ☐ Proper naming and labeling.
- ☐ Tool tips?
### Sidebar
- ☐ Drag sidebar width/height dynamically.
### Directory Browser and Downloading tab
(NOTE: It is a impossible to put a model automatically in the correct folder if model type information is not given or ambigious. To fully solve this requires making a file browser where files can be moved around.)

View File

@@ -1,21 +1,29 @@
import os
import sys
import hashlib
import importlib
from aiohttp import web
import server
import urllib.parse
import struct
import json
import requests
requests.packages.urllib3.disable_warnings()
import folder_paths
requests.packages.urllib3.disable_warnings()
config_loader_path = os.path.join(os.path.dirname(__file__), 'config_loader.py')
config_loader_spec = importlib.util.spec_from_file_location('config_loader', config_loader_path)
config_loader = importlib.util.module_from_spec(config_loader_spec)
config_loader_spec.loader.exec_module(config_loader)
comfyui_model_uri = os.path.join(os.getcwd(), "models")
extension_uri = os.path.join(os.getcwd(), "custom_nodes" + os.path.sep + "ComfyUI-Model-Manager")
index_uri = os.path.join(extension_uri, "index.json")
#checksum_cache_uri = os.path.join(extension_uri, "checksum_cache.txt")
no_preview_image = os.path.join(extension_uri, "no-preview.png")
ui_settings_uri = os.path.join(extension_uri, "ui_settings.yaml")
image_extensions = (".apng", ".gif", ".jpeg", ".jpg", ".png", ".webp")
#video_extensions = (".avi", ".mp4", ".webm") # TODO: Requires ffmpeg or cv2. Cache preview frame?
@@ -38,8 +46,7 @@ def folder_paths_get_supported_pt_extensions(folder_name): # Missing API functio
paths = folder_paths.folder_names_and_paths
if folder_name in paths:
return paths[folder_name][1]
return set(['.ckpt', '.pt', '.bin', '.pth', '.safetensors'])
return set([".ckpt", ".pt", ".bin", ".pth", ".safetensors"])
def get_safetensor_header(path):
@@ -76,6 +83,49 @@ def model_type_to_dir_name(model_type):
else: return model_type
def ui_rules():
Rule = config_loader.Rule
return [
Rule("sidebar-default-height", 0.5, float, 0.0, 1.0),
Rule("sidebar-default-width", 0.5, float, 0.0, 1.0),
Rule("model-search-always-append", "", str),
Rule("model-show-label-extensions", False, bool),
Rule("model-show-add-button", True, bool),
Rule("model-show-copy-button", True, bool),
Rule("model-add-embedding-extension", False, bool),
Rule("model-add-drag-strict-on-field", False, bool),
Rule("model-add-offset", 25, int),
]
#def server_rules():
# Rule = config_loader.Rule
# return [
# Rule("model_extension_download_whitelist", [".safetensors"], list),
# Rule("civitai_api_key", "", str),
# ]
@server.PromptServer.instance.routes.get("/model-manager/settings/load")
async def load_ui_settings(request):
rules = ui_rules()
settings = config_loader.yaml_load(ui_settings_uri, rules)
return web.json_response({ "settings": settings })
@server.PromptServer.instance.routes.post("/model-manager/settings/save")
async def save_ui_settings(request):
body = await request.json()
settings = body.get("settings")
rules = ui_rules()
validated_settings = config_loader.validated(rules, settings)
success = config_loader.yaml_save(ui_settings_uri, rules, validated_settings)
return web.json_response({
"success": success,
"settings": validated_settings if success else "",
})
@server.PromptServer.instance.routes.get("/model-manager/image-preview")
async def img_preview(request):
uri = request.query.get("uri")
@@ -83,7 +133,7 @@ async def img_preview(request):
image_path = no_preview_image
image_extension = "png"
if (uri != "no-post"):
if uri != "no-post":
rel_image_path = os.path.dirname(uri)
i = uri.find(os.path.sep)
@@ -225,9 +275,9 @@ async def load_download_models(request):
model_items = []
for model, image, base_path_index, rel_path in file_names:
name, _ = os.path.splitext(model)
# TODO: Stop sending redundant information
item = {
"name": name,
"name": model,
"search-path": os.path.join(model_type, rel_path, model).replace(os.path.sep, "/"), # TODO: Remove hack
"path": os.path.join(rel_path, model),
}
@@ -269,7 +319,7 @@ def download_model_file(url, filename):
with open(dl_filename, "ab") as f:
for chunk in r.iter_content(chunk_size=1024):
if chunk:
if chunk is not None:
downloaded_size += len(chunk)
f.write(chunk)
f.flush()

65
config_loader.py Normal file
View File

@@ -0,0 +1,65 @@
import yaml
from dataclasses import dataclass
@dataclass
class Rule:
key: any
value_default: any
value_type: type
value_min: int | float | None
value_max: int | float | None
def __init__(self, key, value_default, value_type: type, value_min: int | float | None = None, value_max: int | float | None = None):
self.key = key
self.value_default = value_default
self.value_type = value_type
self.value_min = value_min
self.value_max = value_max
def _get_valid_value(data: dict, r: Rule):
if r.value_type != type(r.value_default):
raise Exception(f"'value_type' does not match type of 'value_default'!")
value = data.get(r.key)
if value is None:
value = r.value_default
else:
try:
value = r.value_type(value)
except:
value = r.value_default
value_is_numeric = r.value_type == int or r.value_type == float
if value_is_numeric and r.value_min:
if r.value_type != type(r.value_min):
raise Exception(f"Type of 'value_type' does not match the type of 'value_min'!")
value = max(r.value_min, value)
if value_is_numeric and r.value_max:
if r.value_type != type(r.value_max):
raise Exception(f"Type of 'value_type' does not match the type of 'value_max'!")
value = min(r.value_max, value)
return value
def validated(rules: list[Rule], data: dict = {}):
valid = {}
for r in rules:
valid[r.key] = _get_valid_value(data, r)
return valid
def yaml_load(path, rules: list[Rule]):
data = {}
try:
with open(path, 'r') as file:
data = yaml.safe_load(file)
except:
pass
return validated(rules, data)
def yaml_save(path, rules: list[Rule], data: dict) -> bool:
data = validated(rules, data)
try:
with open(path, 'w') as file:
yaml.dump(data, file)
return True
except:
return False

View File

@@ -123,16 +123,6 @@
opacity: 1;
}
.comfy-grid .model-button.model-button-success {
color: green;
border-color: green;
}
.comfy-grid .model-button.model-button-failure {
color: darkred;
border-color: darkred;
}
.comfy-grid .model-label {
user-select: text;
}
@@ -347,3 +337,41 @@
.model-manager .model-type-dropdown {
flex: 1;
}
.model-manager .button-success {
color: green;
border-color: green;
}
.model-manager .button-failure {
color: darkred;
border-color: darkred;
}
/* model manager settings */
.model-manager .model-manager-settings > div {
display: flex;
flex-direction: row;
align-items: center;
gap: 8px;
}
.model-manager .model-manager-settings button {
height: 40px;
width: 120px;
}
.model-manager .model-manager-settings input[type="number"] {
width: 50px;
}
.search-settings-text {
width: 100%;
}
.model-manager .model-manager-settings textarea {
width: 100%;
font-size: 1.2em;
border: solid 2px var(--border-color);
border-radius: 8px;
}

View File

@@ -48,13 +48,36 @@ function pathToFileString(path) {
return path.slice(i);
}
function insertEmbeddingIntoText(currentText, embeddingFile, extensionRegex = null) {
if (extensionRegex) {
// TODO: setting.remove_extension_embedding
function removeModelExtension(file) {
// This is a bit sloppy (can assume server sends without)
const i = file.lastIndexOf(".");
if (i != -1) {
return file.substring(0, i);
}
// TODO: don't add if it is already in the text?
const sep = currentText.length === 0 || currentText.slice(-1).match(/\s/) ? "" : " ";
return currentText + sep + "(embedding:" + embeddingFile + ":1.0)";
}
function insertEmbeddingIntoText(text, file, removeExtension) {
let name = file;
if (removeExtension) {
name = removeModelExtension(name)
}
const sep = text.length === 0 || text.slice(-1).match(/\s/) ? "" : " ";
return text + sep + "(embedding:" + name + ":1.0)";
}
function buttonAlert(element, success, successText = "", failureText = "", resetText = "") {
const name = success ? "button-success" : "button-failure";
element.classList.add(name);
if (successText != "" && failureText != "") {
element.innerHTML = success ? successText : failureText;
}
// TODO: debounce would be nice to get working...
window.setTimeout((element, name, innerHTML) => {
element.classList.remove(name);
if (innerHTML != "") {
element.innerHTML = innerHTML;
}
}, 500, element, name, resetText);
}
class Tabs {
@@ -281,20 +304,8 @@ class ModelGrid {
});
}
static #buttonAlert(event, successful, innerHTML) {
const element = event.target;
const name = successful ? "model-button-success" : "model-button-failure";
element.classList.add(name);
element.innerHTML = successful ? "✔" : "✖";
// TODO: debounce would be nice to get working...
window.setTimeout((element, name) => {
element.classList.remove(name);
element.innerHTML = innerHTML;
}, 500, element, name);
}
static #addModel(event, modelType, path) {
let successful = false;
static #addModel(event, modelType, path, removeEmbeddingExtension, addOffset) {
let success = false;
if (modelType !== "embeddings") {
const nodeType = modelNodeType(modelType);
const widgetIndex = modelWidgetIndex(nodeType);
@@ -305,9 +316,8 @@ class ModelGrid {
let isSelectedNode = false;
for (var i in selectedNodes) {
const selectedNode = selectedNodes[i];
// TODO: settings.model_add_offset
node.pos[0] = selectedNode.pos[0] + 25;
node.pos[1] = selectedNode.pos[1] + 25;
node.pos[0] = selectedNode.pos[0] + addOffset;
node.pos[1] = selectedNode.pos[1] + addOffset;
isSelectedNode = true;
break;
}
@@ -318,7 +328,7 @@ class ModelGrid {
}
app.graph.add(node, {doProcessChange: true});
app.canvas.selectNode(node);
successful = true;
success = true;
}
event.stopPropagation();
}
@@ -331,27 +341,33 @@ class ModelGrid {
const widgetIndex = modelWidgetIndex(nodeType);
const target = selectedNode.widgets[widgetIndex].element;
if (target && target.type === "textarea") {
target.value = insertEmbeddingIntoText(target.value, embeddingFile);
successful = true;
target.value = insertEmbeddingIntoText(target.value, embeddingFile, removeEmbeddingExtension);
success = true;
}
}
if (!successful) {
if (!success) {
console.warn("Try selecting a node before adding the embedding.");
}
event.stopPropagation();
}
this.#buttonAlert(event, successful, "✚");
buttonAlert(event.target, success, "✔", "✖", "✚");
}
static #dragAddModel(event, modelType, path) {
static #dragAddModel(event, modelType, path, removeEmbeddingExtension, strictDragToAdd) {
const target = document.elementFromPoint(event.x, event.y);
if (modelType !== "embeddings" && target.id === "graph-canvas") {
const nodeType = modelNodeType(modelType);
const widgetIndex = modelWidgetIndex(nodeType);
const pos = app.canvas.convertEventToCanvasOffset(event);
const nodeAtPos = app.graph.getNodeOnPos(pos[0], pos[1], app.canvas.visible_nodes);
//if (nodeAtPos && nodeAtPos.type === nodeType && app.canvas.processNodeWidgets(nodeAtPos, pos, event) !== nodeAtPos.widgets[widgetIndex]) { // TODO: settings.strict_model_drag
if (nodeAtPos && nodeAtPos.type === nodeType) {
let draggedOnNode = nodeAtPos && nodeAtPos.type === nodeType;
if (strictDragToAdd) {
const draggedOnWidget = app.canvas.processNodeWidgets(nodeAtPos, pos, event) === nodeAtPos.widgets[widgetIndex];
draggedOnNode = draggedOnNode && draggedOnWidget;
}
if (draggedOnNode) {
let node = nodeAtPos;
node.widgets[widgetIndex].value = path;
app.canvas.selectNode(node);
@@ -374,20 +390,21 @@ class ModelGrid {
if (nodeAtPos) {
app.canvas.selectNode(nodeAtPos);
const embeddingFile = pathToFileString(path);
target.value = insertEmbeddingIntoText(target.value, embeddingFile);
target.value = insertEmbeddingIntoText(target.value, embeddingFile, removeEmbeddingExtension);
event.stopPropagation();
}
}
}
static #copyModelToClipboard(event, modelType, path) {
static #copyModelToClipboard(event, modelType, path, removeEmbeddingExtension) {
const nodeType = modelNodeType(modelType);
let successful = false;
let success = false;
if (nodeType === "Embedding") {
if (navigator.clipboard){
const embeddingText = pathToFileString(path);
const embeddingFile = pathToFileString(path);
const embeddingText = insertEmbeddingIntoText("", embeddingFile, removeEmbeddingExtension);
navigator.clipboard.writeText(embeddingText);
successful = true;
success = true;
}
else {
console.warn("Cannot copy the embedding to the system clipboard; Try dragging it instead.");
@@ -398,24 +415,47 @@ class ModelGrid {
const widgetIndex = modelWidgetIndex(nodeType);
node.widgets[widgetIndex].value = path;
app.canvas.copyToClipboard([node]);
successful = true;
success = true;
}
else {
console.warn(`Unable to copy unknown model type '${modelType}.`);
}
this.#buttonAlert(event, successful, "⧉︎");
buttonAlert(event.target, success, "✔", "✖", "⧉︎");
}
static generateInnerHtml(models, modelType) {
static generateInnerHtml(models, modelType, settingsElements) {
const showAddButton = settingsElements["model-show-add-button"].checked;
const showCopyButton = 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 removeEmbeddingExtension = !settingsElements["model-add-embedding-extension"].checked;
if (models.length > 0) {
// TODO: settings.show_model_add_button
// TODO: settings.show_model_copy_button
return models.map((item) => {
const uri = item.post ?? "no-post";
const imgUrl = `/model-manager/image-preview?uri=${uri}`;
const dragAdd = (e) => ModelGrid.#dragAddModel(e, modelType, item.path);
const clickCopy = (e) => ModelGrid.#copyModelToClipboard(e, modelType, item.path);
const clickAdd = (e) => ModelGrid.#addModel(e, modelType, item.path);
let buttons = [];
if (showAddButton) {
buttons.push(
$el("button.icon-button.model-button", {
type: "button",
textContent: "⧉︎",
onclick: (e) => ModelGrid.#copyModelToClipboard(e, modelType, item.path, removeEmbeddingExtension),
draggable: false,
})
);
}
if (showCopyButton) {
buttons.push(
$el("button.icon-button.model-button", {
type: "button",
textContent: "✚",
onclick: (e) => ModelGrid.#addModel(e, modelType, item.path, removeEmbeddingExtension, addOffset),
draggable: false,
})
);
}
const dragAdd = (e) => ModelGrid.#dragAddModel(e, modelType, item.path, removeEmbeddingExtension, strictDragToAdd);
return $el("div.item", {}, [
$el("img.model-preview", {
src: imgUrl,
@@ -429,25 +469,13 @@ class ModelGrid {
$el("div.model-preview-top-right", {
draggable: false,
},
[
$el("button.icon-button.model-button", {
type: "button",
textContent: "⧉︎",
onclick: (e) => clickCopy(e),
draggable: false,
}),
$el("button.icon-button.model-button", {
type: "button",
textContent: "✚",
onclick: (e) => clickAdd(e),
draggable: false,
}),
]),
buttons
),
$el("div.model-label", {
ondragend: (e) => dragAdd(e),
draggable: true,
}, [
$el("p", [item.name])
$el("p", [showModelExtension ? item.name : removeModelExtension(item.name)])
]),
]);
});
@@ -501,10 +529,27 @@ class ModelManager extends ComfyDialog {
sourceInstalledFilter: null,
sourceContentFilter: null,
sourceFilterBtn: null,
modelGrid: null,
modelTypeSelect: null,
modelContentFilter: null,
sidebarButtons: null,
settingsTab: null,
reloadSettingsBtn: null,
saveSettingsBtn: null,
settings: {
"sidebar-default-height": null,
"sidebar-default-width": null,
"model-search-always-append": null,
"model-show-label-extensions": null,
"model-show-add-button": null,
"model-show-copy-button": null,
"model-add-embedding-extension": null,
"model-add-drag-strict-on-field": null,
"model-add-offset": null,
}
};
#data = {
@@ -557,7 +602,7 @@ class ModelManager extends ComfyDialog {
$tabs([
$tab("Install", this.#createSourceInstall()),
$tab("Models", this.#createModelTabHtml()),
$tab("Settings", []),
$tab("Settings", this.#createSettingsTabHtml()),
]),
]),
]
@@ -567,6 +612,7 @@ class ModelManager extends ComfyDialog {
}
#init() {
this.#reloadSettings(false);
this.#refreshSourceList();
this.#modelGridRefresh();
}
@@ -748,15 +794,15 @@ class ModelManager extends ComfyDialog {
}
#modelGridUpdate() {
const searchText = this.#el.modelContentFilter.value;
// TODO: settings.always_append_to_search
const searchAppend = this.#el.settings["model-search-always-append"].value;
const searchText = this.#el.modelContentFilter.value + " " + searchAppend;
const modelType = this.#el.modelTypeSelect.value;
const models = this.#data.models;
const modelList = ModelGrid.filter(models[modelType], searchText);
const modelGrid = this.#el.modelGrid;
modelGrid.innerHTML = [];
const innerHTML = ModelGrid.generateInnerHtml(modelList, modelType);
const innerHTML = ModelGrid.generateInnerHtml(modelList, modelType, this.#el.settings);
modelGrid.append.apply(modelGrid, innerHTML);
};
@@ -766,9 +812,8 @@ class ModelManager extends ComfyDialog {
};
#setSidebar(event) {
// TODO: use checkboxes with 0 or 1 values set at once?
// TODO: settings.sidebar_side_width
// TODO: settings.sidebar_bottom_height
// TODO: settings["sidebar-default-width"]
// TODO: settings["sidebar-default-height"]
// TODO: draggable resize?
const button = event.target;
const sidebarButtons = this.#el.sidebarButtons.children;
@@ -795,6 +840,169 @@ class ModelManager extends ComfyDialog {
modelManager.classList.add(newSidebarState);
}
}
#setSettings(settings, reloadData) {
const el = this.#el.settings;
for (const [key, value] of Object.entries(settings)) {
const setting = el[key];
if (setting) {
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;
default: console.warn("Unknown settings input type!");
}
}
}
if (reloadData) {
// Is this slow?
this.#refreshSourceList();
this.#modelGridRefresh();
}
}
async #reloadSettings(reloadData) {
const data = await request("/model-manager/settings/load");
const settings = data["settings"];
this.#setSettings(settings, reloadData);
buttonAlert(this.#el.reloadSettingsBtn, true);
};
async #saveSettings() {
let settings = {};
for (const [setting, el] of Object.entries(this.#el.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;
default: console.warn("Unknown settings input type!");
}
settings[setting] = value;
}
const data = await request(
"/model-manager/settings/save",
{
method: "POST",
body: JSON.stringify({ "settings": settings }),
}
);
const success = data["success"];
if (success) {
const settings = data["settings"];
this.#setSettings(settings, true);
}
buttonAlert(this.#el.saveSettingsBtn, success);
}
#createSettingsTabHtml() {
const settingsTab = $el("div.model-manager-settings", [
$el("h1", ["Settings"]),
$el("div", [
$el("button", {
$: (el) => (this.#el.reloadSettingsBtn = el),
type: "button",
textContent: "Reload", // ⟳
onclick: () => this.#reloadSettings(true),
}),
$el("button", {
$: (el) => (this.#el.saveSettingsBtn = el),
type: "button",
textContent: "Save", // 💾︎
onclick: () => this.#saveSettings(),
}),
]),
/*
$el("h2", ["Window"]),
$el("div", [
$el("p", ["Default sidebar width"]),
$el("input", {
$: (el) => (this.#el.settings["sidebar-default-width"] = el),
type: "number",
value: 0.5,
min: 0.0,
max: 1.0,
step: 0.05,
}),
]),
$el("div", [
$el("p", ["Default sidebar height"]),
$el("input", {
$: (el) => (this.#el.settings["sidebar-default-height"] = el),
type: "number",
textContent: "Default sidebar height",
value: 0.5,
min: 0.0,
max: 1.0,
step: 0.05,
}),
]),
*/
$el("h2", ["Model Search"]),
$el("div", [
$el("div.search-settings-text", [
$el("p", ["Always append to model search:"]),
$el("textarea.comfy-multiline-input", {
$: (el) => (this.#el.settings["model-search-always-append"] = el),
placeholder: "example: -nsfw",
}),
]),
]),
$el("div", [
$el("input", {
$: (el) => (this.#el.settings["model-show-label-extensions"] = el),
type: "checkbox",
}),
$el("p", ["Show extensions in models tab"]),
]),
$el("div", [
$el("input", {
$: (el) => (this.#el.settings["model-show-add-button"] = el),
type: "checkbox",
}),
$el("p", ["Show add button"]),
]),
$el("div", [
$el("input", {
$: (el) => (this.#el.settings["model-show-copy-button"] = el),
type: "checkbox",
}),
$el("p", ["Show copy button"]),
]),
$el("h2", ["Model Add"]),
$el("div", [
$el("input", {
$: (el) => (this.#el.settings["model-add-embedding-extension"] = el),
type: "checkbox",
}),
$el("p", ["Add extension to embedding"]),
]),
$el("div", [
$el("input", {
$: (el) => (this.#el.settings["model-add-drag-strict-on-field"] = el),
type: "checkbox",
}),
$el("p", ["Strict dragging model onto a node's model field to add"]),
]),
$el("div", [
$el("p", ["Add model offset"]),
$el("input", {
$: (el) => (this.#el.settings["model-add-offset"] = el),
type: "number",
step: 5,
}),
]),
]);
this.#el.settingsTab = settingsTab;
return [settingsTab];
}
}
let instance;