From 17b7176e0dbb95caebc5002928a2bb7f541a0cf3 Mon Sep 17 00:00:00 2001 From: CalamitousFelicitousness Date: Mon, 29 Jun 2026 00:20:36 +0100 Subject: [PATCH] fix(ui): debounce aspect-ratio linked width/height controls A single handler bound to the AR dropdown, width and height (in the shared create_resolution_inputs and the resize section) wrote both sliders on every change. With a ratio selected it locked one axis and snapped it back on each edit, the two sliders looped, and the math ran server-side one round-trip per keystroke, echoing a value back into the field being typed in and yanking it. Move aspect-ratio linking to the browser (ui/resolutionLock.ts): a debounced edit writes only the partner axis, never the field being edited, and commits immediately on blur, enter, or slider release. Keep the kanvas notify on notifyKanvasResize wired to the resize sliders' gradio .change, so it still fires on programmatic size updates (detect, paste, swap) that client-side listeners miss. Drop the per-change AR wiring, res_apply, and the resolutionChange* helpers. --- modules/ui_sections.py | 42 ++------------- ui/globals.d.ts | 2 +- ui/index.ts | 1 + ui/resolutionLock.ts | 114 +++++++++++++++++++++++++++++++++++++++++ ui/ui.ts | 24 ++++----- 5 files changed, 131 insertions(+), 52 deletions(-) create mode 100644 ui/resolutionLock.ts diff --git a/modules/ui_sections.py b/modules/ui_sections.py index 730677729..078477daa 100644 --- a/modules/ui_sections.py +++ b/modules/ui_sections.py @@ -1,4 +1,3 @@ -import time import gradio as gr from modules import shared, modelloader, ui_symbols, ui_common, sd_samplers from modules.logger import log @@ -65,42 +64,11 @@ def create_toprow(is_img2img: bool = False, id_part: str | None = None, generate return prompt, styles, negative_prompt, submit, reprocess, button_paste, button_extra, token_counter, token_button, negative_token_counter, negative_token_button -def ar_change(ar, width, height): - if ar == 'AR': - return gr.update(), gr.update() - try: - parts = [float(x) for x in ar.split(':')] - if len(parts) != 2: - raise ValueError(f"Expected 2 values, got {len(parts)}") - w, h = parts - except Exception as e: - log.warning(f"Invalid aspect ratio: {ar} {e}") - return gr.update(), gr.update() - if w > h: - return gr.update(), gr.update(value=int(width * h / w)) - elif w < h: - return gr.update(value=int(height * w / h)), gr.update() - else: - return gr.update(), gr.update() - -last_ar_update = None -def ar_update(_ar, width, height): - global last_ar_update # pylint: disable=global-statement - if _ar == 'AR': - return gr.update(), gr.update() - if (last_ar_update is not None and time.time()) - (last_ar_update < 0.5): - return gr.update(), gr.update() - last_ar_update = time.time() - return gr.update(value=width), gr.update(value=height) - - def create_resolution_inputs(tab, default_width=1024, default_height=1024): width = gr.Slider(minimum=64, maximum=4096, step=8, label="Width", value=default_width, elem_id=f"{tab}_width") height = gr.Slider(minimum=64, maximum=4096, step=8, label="Height", value=default_height, elem_id=f"{tab}_height") ar_list = ['AR'] + [x.strip() for x in shared.opts.aspect_ratios.split(',') if x.strip() != ''] - ar_dropdown = gr.Dropdown(show_label=False, interactive=True, choices=ar_list, value=ar_list[0], elem_id=f"{tab}_ar", elem_classes=["ar-dropdown"]) - for c in [ar_dropdown, width, height]: - c.change(fn=ar_change, inputs=[ar_dropdown, width, height], outputs=[width, height], show_progress='hidden') + gr.Dropdown(show_label=False, interactive=True, choices=ar_list, value=ar_list[0], elem_id=f"{tab}_ar", elem_classes=["ar-dropdown"]) # aspect-ratio linking wired client-side in ui/resolutionLock.ts res_switch_btn = ToolButton(value=ui_symbols.switch, elem_id=f"{tab}_res_btn_swap") res_switch_btn.click(lambda w, h: (h, w), inputs=[width, height], outputs=[width, height], show_progress='hidden') return width, height @@ -415,15 +383,15 @@ def create_resize_inputs(tab, images, accordion=True, latent=False, non_zero=Tru height = gr.Slider(minimum=64 if non_zero else 0, maximum=8192, step=8, label=f"Height{prefix}" if non_zero else "Resize height", value=1024 if non_zero else 0, elem_id=f"{tab}{suffix}_height") with gr.Column(elem_id=f"{tab}_column_fixed2", scale=1): ar_list = ['AR'] + [x.strip() for x in shared.opts.aspect_ratios.split(',') if x.strip() != ''] - ar_dropdown = gr.Dropdown(show_label=False, interactive=True, choices=ar_list, value=ar_list[0], elem_id=f"{tab}_resize_ar", elem_classes=["ar-dropdown"]) - for c in [ar_dropdown, width, height]: - # c.change(fn=ar_change, inputs=[ar_dropdown, width, height], outputs=[width, height], show_progress='hidden') - c.change(fn=ar_update, _js='resolutionChange', inputs=[ar_dropdown, width, height], outputs=[width, height], show_progress='hidden') + gr.Dropdown(show_label=False, interactive=True, choices=ar_list, value=ar_list[0], elem_id=f"{tab}_resize_ar", elem_classes=["ar-dropdown"]) # aspect-ratio linking wired client-side in ui/resolutionLock.ts res_switch_btn = ToolButton(value=ui_symbols.switch, elem_id=f"{tab}_resize_size_swap") res_switch_btn.click(lambda w, h: (h, w), inputs=[width, height], outputs=[width, height], show_progress='hidden') detect_image_size_btn = ToolButton(value=ui_symbols.detect, elem_id=f"{tab}_resize_detect_size") el = tab.split('_')[0] detect_image_size_btn.click(fn=lambda w, h, _: (w or gr.update(), h or gr.update()), _js=f'currentImageResolution{el}', inputs=[dummy_component, dummy_component, dummy_component], outputs=[width, height], show_progress='hidden') + # keep the kanvas stage synced to the resize sliders; .change fires on user and programmatic updates (detect, paste, swap) and writes nothing back + width.change(fn=None, _js='notifyKanvasResize', inputs=[width, height], outputs=[], show_progress='hidden') + height.change(fn=None, _js='notifyKanvasResize', inputs=[width, height], outputs=[], show_progress='hidden') with gr.Tab(label="Scale", id=1, elem_id=f"{tab}_scale_tab_scale") as tab_scale_by: scale_by = gr.Slider(minimum=0.05, maximum=8.0, step=0.05, label=f"Scale{prefix}" if non_zero else "Resize scale", value=1.0, elem_id=f"{tab}_scale") if images is not None: diff --git a/ui/globals.d.ts b/ui/globals.d.ts index 0b647cc80..d680162a6 100644 --- a/ui/globals.d.ts +++ b/ui/globals.d.ts @@ -33,6 +33,7 @@ declare global { // global functions args_to_array?: typeof Array.from; // ui/ui.ts updateInput?: (target: EventTarget) => void; // ui/ui.ts + notifyKanvasResize?: (width: number, height: number) => void; // ui/ui.ts cycleImageFit?: () => void; // ui/imageViewer.ts clip_gallery_urls?: (gallery: { data: string }[]) => void; // ui/ui.ts extract_image_from_gallery?: (gallery: { data?: string }[]) => ({ data?: string } | null)[]; // ui/ui.ts @@ -75,7 +76,6 @@ declare global { recalculate_prompts_img2img?: (...args: unknown[]) => unknown[]; // ui/ui.ts recalculate_prompts_inpaint?: (...args: unknown[]) => unknown[]; // ui/ui.ts recalculate_prompts_control?: (...args: unknown[]) => unknown[]; // ui/ui.ts - resolutionChange?: (ar: string, width: number, height: number) => unknown[]; // ui/ui.ts consumeDesiredCheckpointName?: (...args: unknown[]) => unknown[]; // ui/ui.ts create_submit_args?: (args: unknown[]) => unknown[]; // ui/ui.ts selectCheckpoint?: (name: string) => void; // ui/ui.ts diff --git a/ui/index.ts b/ui/index.ts index 58f66f854..4c7e5e50f 100644 --- a/ui/index.ts +++ b/ui/index.ts @@ -28,6 +28,7 @@ import './setHints'; import './monitor'; import './history'; import './aspectRatioOverlay'; +import './resolutionLock'; import './authWrap'; import './autocomplete'; import './autocomplete_xn'; diff --git a/ui/resolutionLock.ts b/ui/resolutionLock.ts new file mode 100644 index 000000000..21f593389 --- /dev/null +++ b/ui/resolutionLock.ts @@ -0,0 +1,114 @@ +import { gradioApp, onAfterUiUpdate } from './script'; + +// Aspect-ratio lock for the paired width/height sliders. The math runs client-side and debounced, +// and only the partner axis is ever written, never the field being edited; writing the edited field +// back is what yanked the value mid-type when this went through a gradio round-trip. Programmatic +// updates dispatch a synthetic input event so gradio's store stays in sync. State is keyed per +// dropdown element so duplicate elem ids across tabs stay isolated. + +const RES_DEBOUNCE = 350; +const AR_DEBOUNCE = 120; +const timers = new WeakMap>(); +const busy = new WeakSet(); + +function parseAR(ar: string): [number, number] | null { + if (!ar || ar === 'AR') return null; + const parts = ar.split(':'); + if (parts.length !== 2) return null; + const w = parseInt(parts[0], 10); + const h = parseInt(parts[1], 10); + return (w > 0 && h > 0) ? [w, h] : null; +} + +function numberInput(group: Element): HTMLInputElement | null { + const inp = group.querySelector('input[type=number]') || group.querySelector('input'); + return inp instanceof HTMLInputElement ? inp : null; +} + +function readValue(group: Element): number { + const inp = numberInput(group); + return inp ? Number(inp.value) : 0; +} + +function writeValue(group: Element, raw: number): void { + const inp = numberInput(group); + if (!inp) return; + const step = Number(inp.step) || 8; + const min = inp.min !== '' ? Number(inp.min) : 0; + const max = inp.max !== '' ? Number(inp.max) : 8192; + const value = Math.max(min, Math.min(max, Math.round(raw / step) * step)); + if (value === Number(inp.value)) return; // unchanged: skip so the listeners do not refire + group.querySelectorAll('input').forEach((el) => { + if (!(el instanceof HTMLInputElement)) return; + el.value = String(value); + const e = new Event('input', { bubbles: true }); + Object.defineProperty(e, 'target', { value: el }); + el.dispatchEvent(e); + }); +} + +function arValue(arEl: Element): string { + const inp = arEl.querySelector('input'); + return inp instanceof HTMLInputElement ? inp.value : 'AR'; +} + +function pairOf(arEl: Element): { width: Element; height: Element } | null { + let container: Element | null = arEl.parentElement; + for (let i = 0; i < 6 && container; i++) { + const width = container.querySelector('[id$="_width"]'); + const height = container.querySelector('[id$="_height"]'); + if (width && height) return { width, height }; + container = container.parentElement; + } + return null; +} + +function settle(arEl: Element, source: 'width' | 'height'): void { + const ar = parseAR(arValue(arEl)); + if (!ar) return; + const pair = pairOf(arEl); + if (!pair) return; + const [rw, rh] = ar; + busy.add(arEl); + if (source === 'height') writeValue(pair.width, (readValue(pair.height) * rw) / rh); + else writeValue(pair.height, (readValue(pair.width) * rh) / rw); + busy.delete(arEl); +} + +function schedule(arEl: Element, source: 'width' | 'height', delay: number): void { + if (busy.has(arEl)) return; // ignore the input events our own writes dispatch + clearTimeout(timers.get(arEl)); + timers.set(arEl, setTimeout(() => settle(arEl, source), delay)); +} + +function flush(arEl: Element, source: 'width' | 'height'): void { + if (busy.has(arEl)) return; + clearTimeout(timers.get(arEl)); + settle(arEl, source); +} + +function bind(arEl: Element, group: Element, source: 'width' | 'height'): void { + group.querySelectorAll('input').forEach((el) => { + if (!(el instanceof HTMLInputElement) || el.classList.contains('ar-lock-bound')) return; + el.classList.add('ar-lock-bound'); + el.addEventListener('input', () => schedule(arEl, source, RES_DEBOUNCE)); + el.addEventListener('change', () => flush(arEl, source)); // commit on blur, enter, or slider release + }); +} + +export function setupResolutionLock(): void { + gradioApp().querySelectorAll('.ar-dropdown').forEach((arEl) => { + const pair = pairOf(arEl); + if (!pair) return; + bind(arEl, pair.width, 'width'); + bind(arEl, pair.height, 'height'); + arEl.querySelectorAll('input').forEach((el) => { + if (!(el instanceof HTMLInputElement) || el.classList.contains('ar-lock-bound')) return; + el.classList.add('ar-lock-bound'); + el.addEventListener('change', () => flush(arEl, 'width')); // new ratio: keep width, derive height + el.addEventListener('input', () => schedule(arEl, 'width', AR_DEBOUNCE)); + }); + }); +} + +onAfterUiUpdate(setupResolutionLock); diff --git a/ui/ui.ts b/ui/ui.ts index cd34b8506..caf8e995b 100644 --- a/ui/ui.ts +++ b/ui/ui.ts @@ -15,7 +15,6 @@ let fontSizeApplyRaf = 0; let pendingFontSize: number | null = null; let appliedFontSize: number | null = null; let cachedGradioRoot: any = null; -let resizeDebounce: ReturnType | undefined; const wait_time = 800; const token_timeouts = {}; let uiLoaded = false; @@ -770,20 +769,17 @@ async function browseFolder() { return null; } -export function resolutionChange(ar: string, width: number, height: number) { - let desired = ar; - if (desired === 'AR') desired = '1:1'; - try { - const [w, h] = desired.split(':').map((x) => parseInt(x)); - if (w > h) height = Math.round(width * h / w); - else if (h > w) width = Math.round(height * w / h); - } catch { /**/ } - // min/max size handled in gradio debounce +let kanvasNotifyTimer: ReturnType | undefined; +// Notify kanvas to resize its stage when the resize-panel width/height change. Wired through gradio's +// .change so it also fires on programmatic updates (detect-size, paste params, swap, send-to) that the +// client-side resolutionLock input listeners never see. Writes nothing back, so it cannot loop. +export function notifyKanvasResize(width: number, height: number) { if (window.resizeStage) { - clearTimeout(resizeDebounce); - resizeDebounce = setTimeout(() => window.resizeStage(width, height), 250); // notify kanvas + const w = Number(width); + const h = Number(height); + clearTimeout(kanvasNotifyTimer); + kanvasNotifyTimer = setTimeout(() => window.resizeStage?.(w, h), 250); } - return [ar, width, height]; } export async function reconnectUI() { @@ -824,6 +820,7 @@ export async function reconnectUI() { window.restartReload = restartReload; window.updateInput = updateInput; +window.notifyKanvasResize = notifyKanvasResize; window.clip_gallery_urls = clip_gallery_urls; window.extract_image_from_gallery = extract_image_from_gallery; window.getCaptionActiveTab = getCaptionActiveTab; @@ -855,7 +852,6 @@ window.recalculate_prompts_txt2img = recalculate_prompts_txt2img; window.recalculate_prompts_img2img = recalculate_prompts_img2img; window.recalculate_prompts_inpaint = recalculate_prompts_inpaint; window.recalculate_prompts_control = recalculate_prompts_control; -window.resolutionChange = resolutionChange; window.selectCheckpoint = selectCheckpoint; window.selectVAE = selectVAE; window.selectUNet = selectUNet;