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 parse_style(styles): 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 resize_mode_change(mode): 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;