From 5d483bf307c766aee97caec857d414946fad47db Mon Sep 17 00:00:00 2001 From: Gerschel Date: Mon, 6 Feb 2023 08:18:04 -0800 Subject: [PATCH] aspect ratio for dim's; sliders adjust by ratio MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Default choices added to settings in user interface section Choices are editable by user User selects from dropdown. When you move one slider, the other adjusts according to the ratio chosen. Vice versa for the other slider. Number fields for changes work as well. For disabling ratio, an unlock pad "🔓" is available as a default. This string can be changed to anything to serve as a disable, as long as there is no colon ":". Ratios are entered in this format, floats or ints with a colon "1:1". The string is split at the colon, parses left and right as floats to perform the math. --- javascript/ComponentControllers.js | 257 +++++++++++++++++++++++++++++ javascript/aspectRatioSliders.js | 41 +++++ modules/shared.py | 14 ++ modules/ui.py | 6 + 4 files changed, 318 insertions(+) create mode 100644 javascript/ComponentControllers.js create mode 100644 javascript/aspectRatioSliders.js diff --git a/javascript/ComponentControllers.js b/javascript/ComponentControllers.js new file mode 100644 index 00000000..194589c7 --- /dev/null +++ b/javascript/ComponentControllers.js @@ -0,0 +1,257 @@ +/* This is a basic library that allows controlling elements that take some form of user input. + +This was previously written in typescript, where all controllers implemented an interface. Not +all methods were needed in all the controllers, but it was done to keep a common interface, so +your main app can serve as a controller of controllers. + +These controllers were built to work on the shapes of html elements that gradio components use. + +There may be some notes in it that only applied to my use case, but I left them to help others +along. + +You will need the parent element for these to work. +The parent element can be defined as the element (div) that gets the element id when assigning +an element id to a gradio component. + +Example: + gr.TextBox(value="...", elem_id="THISID") + +Basic usage, grab an element that is the parent container for the component. + +Send it in to the class, like a function, don't forget the "new" keyword so it calls the constructor +and sends back a new object. + +Example: + +let txt2imgPrompt = new TextComponentController(gradioApp().querySelector("#txt2img_prompt")) + +Then use the getVal() method to get the value, or use the setVal(myValue) method to set the value. + +Input types that are groups, like Checkbox groups (not individual checkboxes), take in an array of values. + +Checkbox group has to reset all values to False (unchecked), then set the values in your array to true (checked). +If you don't hold a reference to the values (the labels in string format), you can acquire them using the getVal() method. +*/ +class DropdownComponentController { + constructor(element) { + this.element = element; + this.childSelector = this.element.querySelector('select'); + this.children = new Map(); + Array.from(this.childSelector.querySelectorAll('option')).forEach(opt => this.children.set(opt.value, opt)); + } + getVal() { + return this.childSelector.value; + } + updateVal(optionElement) { + optionElement.selected = true; + } + setVal(name) { + this.updateVal(this.children.get(name)); + this.eventHandler(); + } + eventHandler() { + this.childSelector.dispatchEvent(new Event("change")); + } +} +class CheckboxComponentController { + constructor(element) { + this.element = element; + this.child = this.element.querySelector('input'); + } + getVal() { + return this.child.checked; + } + updateVal(checked) { + this.child.checked = checked; + } + setVal(checked) { + this.updateVal(checked); + this.eventHandler(); + } + eventHandler() { + this.child.dispatchEvent(new Event("change")); + } +} +class CheckboxGroupComponentController { + constructor(element) { + this.element = element; + //this.checkBoxes = new Object; + this.children = new Map(); + Array.from(this.element.querySelectorAll('input')).forEach(input => this.children.set(input.nextElementSibling.innerText, input)); + /* element id gets use fieldset, grab all inputs (the bool val) get the userfriendly label, use as key, put bool value in mapping */ + //Array.from(this.component.querySelectorAll("input")).forEach( _input => this.checkBoxes[_input.nextElementSibling.innerText] = _input) + /*Checkboxgroup structure +
+
css makes translucent + + serves as label for component + +
container for checkboxes + + ... +
+
+ */ + } + updateVal(label) { + /********* + calls updates using a throttle or else the backend does not get updated properly + * ********/ + setTimeout(() => this.conditionalToggle(true, this.children.get(label)), 2); + } + setVal(labels) { + /* Handles reset and updates all in array to true */ + this.reupdateVals(); + labels.forEach(l => this.updateVal(l)); + } + getVal() { + //return the list of values that are true + return [...this.children].filter(([k, v]) => v.checked).map(arr => arr[0]); + } + reupdateVals() { + /************** + * for reupdating all vals, first set to false + **************/ + this.children.forEach(inputChild => this.conditionalToggle(false, inputChild)); + } + conditionalToggle(desiredVal, inputChild) { + //This method behaves like 'set this value to this' + //Using element.checked = true/false, does not register the change, even if you called change afterwards, + // it only sets what it looks like in our case, because there is no form submit, a person then has to click on it twice. + //Options are to use .click() or dispatch an event + if (desiredVal != inputChild.checked) { + inputChild.dispatchEvent(new Event("change")); //using change event instead of click, in case browser ad-blockers blocks the click method + } + } + eventHandler(checkbox) { + checkbox.dispatchEvent(new Event("change")); + } +} +class RadioComponentController { + constructor(element) { + this.element = element; + this.children = new Map(); + Array.from(this.element.querySelectorAll("input")).forEach(input => this.children.set(input.value, input)); + } + getVal() { + //radio groups have a single element that's checked is true + // as array arr k,v pair element.checked ) -> array of len(1) with [k,v] so either [0] [1].value + return [...this.children].filter(([l, e]) => e.checked)[0][0]; + //return Array.from(this.children).filter( ([label, input]) => input.checked)[0][1].value + } + updateVal(child) { + this.eventHandler(child); + } + setVal(name) { + //radio will trigger all false except the one that get the event change + //to keep the api similar, other methods are still called + this.updateVal(this.children.get(name)); + } + eventHandler(child) { + child.dispatchEvent(new Event("change")); + } +} +class NumberComponentController { + constructor(element) { + this.element = element; + this.childNumField = element.querySelector('input[type=number]'); + } + getVal() { + return this.childNumField.value; + } + updateVal(text) { + this.childNumField.value = text; + } + eventHandler() { + this.element.dispatchEvent(new Event("input")); + } + setVal(text) { + this.updateVal(text); + this.eventHandler(); + } +} +class SliderComponentController { + constructor(element) { + this.element = element; + this.childNumField = this.element.querySelector('input[type=number]'); + this.childRangeField = this.element.querySelector('input[type=range]'); + } + getVal() { + return this.childNumField.value; + } + updateVal(text) { + //both are not needed, either works, both are left in so one is a fallback in case of gradio changes + this.childNumField.value = text; + this.childRangeField.value = text; + } + eventHandler() { + this.element.dispatchEvent(new Event("input")); + } + setVal(text) { + this.updateVal(text); + this.eventHandler(); + } +} +class TextComponentController { + constructor(element) { + this.element = element; + this.child = element.querySelector('textarea'); + } + getVal() { + return this.child.value; + } + eventHandler() { + this.element.dispatchEvent(new Event("input")); + this.child.dispatchEvent(new Event("change")); + //Workaround to solve no target with v(o) on eventhandler, define my own target + let ne = new Event("input"); + Object.defineProperty(ne, "target", { value: this.child }); + this.child.dispatchEvent(ne); + } + updateVal(text) { + this.child.value = text; + } + appendValue(text) { + //might add delimiter option + this.child.value += ` ${text}`; + } + setVal(text, append = false) { + if (append) { + this.appendValue(text); + } + else { + this.updateVal(text); + } + this.eventHandler(); + } +} +class JsonComponentController extends TextComponentController { + constructor(element) { + super(element); + } + getVal() { + return JSON.parse(this.child.value); + } +} +class ColorComponentController { + constructor(element) { + this.element = element; + this.child = this.element.querySelector('input[type=color]'); + } + updateVal(text) { + this.child.value = text; + } + getVal() { + return this.child.value; + } + setVal(text) { + this.updateVal(text); + this.eventHandler(); + } + eventHandler() { + this.child.dispatchEvent(new Event("input")); + } +} diff --git a/javascript/aspectRatioSliders.js b/javascript/aspectRatioSliders.js new file mode 100644 index 00000000..f577750a --- /dev/null +++ b/javascript/aspectRatioSliders.js @@ -0,0 +1,41 @@ +class AspectRatioSliderController { + constructor(widthSlider, heightSlider, ratioSource) { + this.widthSlider = new SliderComponentController(widthSlider); + this.heightSlider = new SliderComponentController(heightSlider); + this.ratioSource = new DropdownComponentController(ratioSource); + this.widthSlider.childRangeField.addEventListener("change", () => this.resize("width")); + this.widthSlider.childNumField.addEventListener("change", () => this.resize("width")); + this.heightSlider.childRangeField.addEventListener("change", () => this.resize("height")); + this.heightSlider.childNumField.addEventListener("change", () => this.resize("height")); + } + resize(dimension) { + let val = this.ratioSource.getVal(); + if (!val.includes(":")) { + return; + } + let [width, height] = val.split(":").map(Number); + let ratio = width / height; + if (dimension == 'width') { + this.heightSlider.setVal(Math.round(parseFloat(this.widthSlider.getVal()) / ratio).toString()); + } + else if (dimension == "height") { + this.widthSlider.setVal(Math.round(parseFloat(this.heightSlider.getVal()) * ratio).toString()); + } + } + static observeStartup(widthSliderId, heightSliderId, ratioSourceId) { + let observer = new MutationObserver(() => { + let widthSlider = document.querySelector("gradio-app").shadowRoot.getElementById(widthSliderId); + let heightSlider = document.querySelector("gradio-app").shadowRoot.getElementById(heightSliderId); + let ratioSource = document.querySelector("gradio-app").shadowRoot.getElementById(ratioSourceId); + if (widthSlider && heightSlider && ratioSource) { + observer.disconnect(); + new AspectRatioSliderController(widthSlider, heightSlider, ratioSource); + } + }); + observer.observe(gradioApp(), { childList: true, subtree: true }); + } +} +document.addEventListener("DOMContentLoaded", () => { + AspectRatioSliderController.observeStartup("txt2img_width", "txt2img_height", "txt2img_ratio"); + AspectRatioSliderController.observeStartup("img2img_width", "img2img_height", "img2img_ratio"); +}); diff --git a/modules/shared.py b/modules/shared.py index 79fbf724..ead7be36 100644 --- a/modules/shared.py +++ b/modules/shared.py @@ -139,6 +139,19 @@ ui_reorder_categories = [ "scripts", ] +aspect_ratio_defaults = [ + "🔓" + "1:1", + "1:2", + "2:1", + "2:3", + "3:2", + "4:3", + "5:4", + "9:16", + "16:9", +] + cmd_opts.disable_extension_access = (cmd_opts.share or cmd_opts.listen or cmd_opts.server_name) and not cmd_opts.enable_insecure_extension_access devices.device, devices.device_interrogate, devices.device_gfpgan, devices.device_esrgan, devices.device_codeformer = \ @@ -456,6 +469,7 @@ options_templates.update(options_section(('ui', "User interface"), { "keyedit_precision_extra": OptionInfo(0.05, "Ctrl+up/down precision when editing ", gr.Slider, {"minimum": 0.01, "maximum": 0.2, "step": 0.001}), "quicksettings": OptionInfo("sd_model_checkpoint", "Quicksettings list"), "ui_reorder": OptionInfo(", ".join(ui_reorder_categories), "txt2img/img2img UI item order"), + "aspect_ratios": OptionInfo(", ".join(aspect_ratio_defaults), "txt2img/img2img aspect ratios"), "ui_extra_networks_tab_reorder": OptionInfo("", "Extra networks tab order"), "localization": OptionInfo("None", "Localization (requires restart)", gr.Dropdown, lambda: {"choices": ["None"] + list(localization.localizations.keys())}, refresh=lambda: localization.list_localizations(cmd_opts.localizations_dir)), })) diff --git a/modules/ui.py b/modules/ui.py index f5df1ffe..6853485c 100644 --- a/modules/ui.py +++ b/modules/ui.py @@ -424,6 +424,10 @@ def ordered_ui_categories(): yield category +def aspect_ratio_list(): + return [ratio.strip() for ratio in shared.opts.aspect_ratios.split(",")] + + def get_value_for_setting(key): value = getattr(opts, key) @@ -480,6 +484,7 @@ def create_ui(): height = gr.Slider(minimum=64, maximum=2048, step=8, label="Height", value=512, elem_id="txt2img_height") res_switch_btn = ToolButton(value=switch_values_symbol, elem_id="txt2img_res_switch_btn") + aspect_ratio_dropdown = gr.Dropdown(value="🔓", choices=aspect_ratio_list(), interactive=True, type="value", elem_id="txt2img_ratio", show_label=False, label="Aspect Ratio") if opts.dimensions_and_batch_together: with gr.Column(elem_id="txt2img_column_batch"): batch_count = gr.Slider(minimum=1, step=1, label='Batch count', value=1, elem_id="txt2img_batch_count") @@ -758,6 +763,7 @@ def create_ui(): height = gr.Slider(minimum=64, maximum=2048, step=8, label="Height", value=512, elem_id="img2img_height") res_switch_btn = ToolButton(value=switch_values_symbol, elem_id="img2img_res_switch_btn") + aspect_ratio_dropdown = gr.Dropdown(value="🔓", choices=aspect_ratio_list(), interactive=True, type="value", elem_id="img2img_ratio", show_label=False, label="Aspect Ratio") if opts.dimensions_and_batch_together: with gr.Column(elem_id="img2img_column_batch"): batch_count = gr.Slider(minimum=1, step=1, label='Batch count', value=1, elem_id="img2img_batch_count")