From 304918baec48167318f64e6db80277220d44284a Mon Sep 17 00:00:00 2001
From: Vladimir Mandic
Date: Wed, 29 Apr 2026 19:53:14 +0200
Subject: [PATCH] monkey-patch multi-image
Co-authored-by: Copilot
Signed-off-by: Vladimir Mandic
---
CHANGELOG.md | 12 ++++-----
extensions-builtin/sdnext-kanvas | 2 +-
installer.py | 4 +--
javascript/control.js | 8 ++++--
modules/ui_control.py | 13 +++++----
modules/ui_control_helpers.py | 46 ++++++++++++++++++++------------
6 files changed, 49 insertions(+), 36 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 56461699b..c17c8c085 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,19 +1,17 @@
# Change Log for SD.Next
-### TODO
-
-- *Separate init image* currently not functional
-
## Update for 2026-04-29
-- **UI**
- - all ui panels can be minimized/maximized by clicking on their header
- state is preserved across sessions and can be used to hide rarely used panels and declutter the workspace
- **Control**
- remove buttons: *input/control/process*
- move params *control input type* to control menu section
- remove "processed preview" from ui
preprocessor output can still be generated by clicking preview button in in control unit and it will render into normal output area
+- **UI**
+ - all ui panels can be minimized/maximized by clicking on their header
+ state is preserved across sessions and can be used to hide rarely used panels and declutter the workspace
+- **Kanvas**
+ - re-order stages by clicking on active stage
## Update for 2026-04-28
diff --git a/extensions-builtin/sdnext-kanvas b/extensions-builtin/sdnext-kanvas
index ae5dc5ee3..5c71a0144 160000
--- a/extensions-builtin/sdnext-kanvas
+++ b/extensions-builtin/sdnext-kanvas
@@ -1 +1 @@
-Subproject commit ae5dc5ee374189b1d2ca1038e93da164b11f8223
+Subproject commit 5c71a01449fe00fcc36525b2a10fe4b90b86731e
diff --git a/installer.py b/installer.py
index b9cf4ff0c..48998f651 100644
--- a/installer.py
+++ b/installer.py
@@ -1530,7 +1530,7 @@ def check_version(reset=True): # pylint: disable=unused-argument
api_base = f'https://api.github.com/repos/{url_parts}'
else:
api_base = 'https://api.github.com/repos/vladmandic/sdnext'
- branches = requests.get(f'{api_base}/branches', timeout=10).json()
+ branches = requests.get(f'{api_base}/branches', timeout=5).json()
branch_names = [b['name'] for b in branches if 'name' in b]
log.trace(f'Repository branches: active={branch_name} available={branch_names}')
except Exception as e:
@@ -1541,7 +1541,7 @@ def check_version(reset=True): # pylint: disable=unused-argument
ts('latest', t_start)
return
try:
- commits = requests.get(f'{api_base}/branches/{branch_name}', timeout=10).json()
+ commits = requests.get(f'{api_base}/branches/{branch_name}', timeout=5).json()
latest = commits['commit']['sha']
if len(latest) != 40:
log.error(f'Repository error: commit={latest} invalid')
diff --git a/javascript/control.js b/javascript/control.js
index 2dacbde06..1505a6b8d 100644
--- a/javascript/control.js
+++ b/javascript/control.js
@@ -8,11 +8,15 @@ function controlInputMode(inputMode, ...args) {
const tabNames = ['Image', 'Video', 'Batch', 'Folder'];
let inputTab = tabNames[tabIdx] || 'Image';
log('controlInputMode', { mode: inputMode, tab: inputTab, kanvas: typeof Kanvas });
+
+ // if kanvas is available overwrite image inputs with kanvas images
if ((inputTab === 'Image') && (typeof 'Kanvas' !== 'undefined')) {
inputTab = 'Kanvas';
- args[0] = window.kanvas.getImage();
- args[1] = window.kanvas.getImage(); // TODO support separate control
+ for (let i = 0; i < window.kanvas.stages.maxStages; i++) {
+ args[4 + i] = window.kanvas.getImage(1 + i, false, false);
+ }
}
+
return [inputTab, ...args];
}
diff --git a/modules/ui_control.py b/modules/ui_control.py
index e34f3bf07..abdda0fec 100644
--- a/modules/ui_control.py
+++ b/modules/ui_control.py
@@ -238,7 +238,10 @@ def create_ui(_blocks: gr.Blocks=None):
input_folder = gr.File(label="Input", show_label=False, file_count='directory', file_types=['image'], interactive=True, height=gr_height)
with gr.Column(scale=9, elem_id='control-init-column', visible=False) as column_init:
gr.HTML('Init input
')
- init_image = gr.Image(label="Input", show_label=False, type="pil", interactive=True, tool="editor", height=gr_height, elem_classes=['control-image'])
+ if (installer.version['kanvas'] == 'disabled') or (installer.version['kanvas'] == 'unavailable'):
+ init_image = gr.Image(label="Input", show_label=False, type="pil", interactive=True, tool="editor", height=gr_height, elem_classes=['control-image'])
+ else:
+ init_image = gr.HTML(value='Kanvas not initialized
', elem_id='kanvas-container')
with gr.Column(scale=9, elem_id='control-output-column', visible=True) as _column_output:
gr.HTML('Output')
with gr.Tabs(elem_classes=['control-tabs'], elem_id='control-tab-output') as output_tabs:
@@ -255,11 +258,6 @@ def create_ui(_blocks: gr.Blocks=None):
with gr.Row(elem_id="control_script_container"):
input_script_args = scripts_manager.scripts_current.setup_ui(parent='control', accordion=True)
- # handlers
- # for btn in input_buttons:
- # btn.click(fn=helpers.copy_input, inputs=[input_mode, btn, input_image, input_resize, input_inpaint], outputs=[input_image, input_resize, input_inpaint], _js='controlInputMode')
- # btn.click(fn=helpers.transfer_input, inputs=[btn], outputs=[input_image, input_resize, input_inpaint] + input_buttons)
-
# hidden button to update gradio control values
for u in units:
controls.extend(u.controls)
@@ -280,10 +278,11 @@ def create_ui(_blocks: gr.Blocks=None):
show_progress = 'hidden',
)
+ image_inputs = 5 * [input_image, init_image] # need to repeat controls for kanvas and non-kanvas modes
select_dict = dict(
fn=helpers.select_input,
_js="controlInputMode",
- inputs=[input_mode, input_image, init_image, input_type, input_video, input_batch, input_folder],
+ inputs=[input_mode, input_type, input_video, input_batch, input_folder] + image_inputs,
outputs=[output_tabs, result_txt, width_before, height_before],
show_progress='hidden',
queue=False,
diff --git a/modules/ui_control_helpers.py b/modules/ui_control_helpers.py
index a789ecad1..45e933f76 100644
--- a/modules/ui_control_helpers.py
+++ b/modules/ui_control_helpers.py
@@ -16,7 +16,6 @@ busy = False # used to synchronize select_input and generate_click
input_source = None
input_init = None
input_mask = None
-input_prev = None
def initialize():
@@ -85,6 +84,8 @@ def get_video(filepath: str):
def process_kanvas(x): # only used when kanvas overrides gr.Image object
image = None
mask = None
+ if x is None:
+ return image, mask
try: # try base64 decode
t0 = time.time()
image_data = x.get('image', '')
@@ -130,33 +131,35 @@ def process_kanvas(x): # only used when kanvas overrides gr.Image object
return image, mask
-def select_input(input_mode, input_image, init_image, init_type, input_video, input_batch, input_folder):
- global busy, input_source, input_init, input_mask, input_prev # pylint: disable=global-statement
+def select_input(input_mode, init_type, input_video, input_batch, input_folder, *args):
+ global busy, input_source, input_init, input_mask # pylint: disable=global-statement
t0 = time.time()
busy = False
- selected_input = input_image # default: Image or Kanvas
+ selected_input = args[0]
+ init_image = args[1]
if input_mode == 'Video':
selected_input = input_video
elif input_mode == 'Batch':
selected_input = input_batch
elif input_mode == 'Folder':
selected_input = input_folder
+ elif input_mode == 'Kanvas':
+ pass # temp assignment only until we process kanvas inputs
+ else:
+ log.error(f'Input: type={input_mode} unrecognized')
+ selected_input = None
size = [gr.update(), gr.update()]
if selected_input is None:
- # log.debug(f'Select input: image={selected_input}')
input_source = None
return [gr.Tabs.update(), ''] + size
- elif selected_input == input_prev:
- # log.debug(f'Select input: image={selected_input} no change')
- return [gr.Tabs.update(), ''] + size
- input_prev = selected_input
busy = True
input_type = type(selected_input)
input_mask = None
status = 'Control input | Unknown'
res = [gr.Tabs.update(selected='out-gallery'), status]
+
# control inputs
if isinstance(selected_input, Image.Image): # image via upload -> image
if input_mode == 'Outpaint':
@@ -164,23 +167,31 @@ def select_input(input_mode, input_image, init_image, init_type, input_video, in
selected_input, input_mask = masking.outpaint(input_image=selected_input)
input_source = [selected_input]
input_type = 'PIL.Image'
- status = f'Control input | Image | Size {selected_input.width if selected_input else 0}x{selected_input.height if selected_input else 0} | Mode {selected_input.mode if selected_input else "Unknown"}'
+ status = f'Input | Image | Size {selected_input.width if selected_input else 0}x{selected_input.height if selected_input else 0} | Mode {selected_input.mode if selected_input else "Unknown"}'
size = [gr.update(value=selected_input.width), gr.update(value=selected_input.height)]
res = [gr.Tabs.update(selected='out-gallery'), status]
elif isinstance(selected_input, dict) and 'kanvas' in selected_input: # kanvas via js -> kanvas dict
- selected_input, input_mask = process_kanvas(selected_input)
- input_source = [selected_input]
+ input_source = []
+ for i, selected in enumerate(args):
+ img, mask = process_kanvas(selected)
+ if img is not None: # use all images
+ input_source.append(img)
+ if i == 0: # use only first mask
+ selected_input = img
+ input_mask = mask
+ if i == 1:
+ init_image = img # control: separate init image
input_type = 'Kanvas'
- status = f'Control input | Kanvas | Size {selected_input.width if selected_input else 0}x{selected_input.height if selected_input else 0} | Mode {selected_input.mode if selected_input else "Unknown"}'
- if selected_input:
- size = [gr.update(value=selected_input.width), gr.update(value=selected_input.height)]
+ status = f'Input | Kanvas | Images {len(input_source)}'
+ if len(input_source) > 0:
+ size = [gr.update(value=input_source[0].width), gr.update(value=input_source[0].height)]
res = [gr.Tabs.update(selected='out-gallery'), status]
elif isinstance(selected_input, dict) and 'mask' in selected_input: # inpaint -> dict image+mask
input_mask = selected_input['mask']
selected_input = selected_input['image']
input_source = [selected_input]
input_type = 'PIL.Image'
- status = f'Control input | Image | Size {selected_input.width if selected_input else 0}x{selected_input.height if selected_input else 0} | Mode {selected_input.mode if selected_input else "Unknown"}'
+ status = f'Input | Image | Size {selected_input.width if selected_input else 0}x{selected_input.height if selected_input else 0} | Mode {selected_input.mode if selected_input else "Unknown"}'
res = [gr.Tabs.update(selected='out-gallery'), status]
elif isinstance(selected_input, gr.components.image.Image): # not likely
input_source = [selected_input.value]
@@ -198,10 +209,11 @@ def select_input(input_mode, input_image, init_image, init_type, input_video, in
else:
input_type = 'files'
input_source = selected_input
- status = f'Control input | Images | Files {len(input_source)}'
+ status = f'Input | Images | Files {len(input_source)}'
res = [gr.Tabs.update(selected='out-gallery'), status]
else: # unknown
input_source = None
+
if init_type == 0: # Control only
input_init = None
elif init_type == 1: # Init image same as control assigned during runtime