Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 5 additions & 37 deletions modules/ui_sections.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion ui/globals.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions ui/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import './setHints';
import './monitor';
import './history';
import './aspectRatioOverlay';
import './resolutionLock';
import './authWrap';
import './autocomplete';
import './autocomplete_xn';
Expand Down
114 changes: 114 additions & 0 deletions ui/resolutionLock.ts
Original file line number Diff line number Diff line change
@@ -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<Element, ReturnType<typeof setTimeout>>();
const busy = new WeakSet<Element>();

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);
24 changes: 10 additions & 14 deletions ui/ui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@ let fontSizeApplyRaf = 0;
let pendingFontSize: number | null = null;
let appliedFontSize: number | null = null;
let cachedGradioRoot: any = null;
let resizeDebounce: ReturnType<typeof setTimeout> | undefined;
const wait_time = 800;
const token_timeouts = {};
let uiLoaded = false;
Expand Down Expand Up @@ -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<typeof setTimeout> | 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() {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
Loading