Fix multiple bugs found in code review
- save_config calls now pass full config to preserve comfy settings - Mass update section moved inside refreshable to stay in sync - Deep copy source data to prevent shared mutable references - Clipboard copy uses json.dumps instead of repr() for safe JS - Comfy monitor uses async IO (run_in_executor) to avoid blocking - Auto-timeout now updates checkbox and refreshes live view UI - Image URLs properly URL-encoded with urllib.parse.urlencode Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
11
main.py
11
main.py
@@ -1,3 +1,4 @@
|
|||||||
|
import json
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from nicegui import ui
|
from nicegui import ui
|
||||||
@@ -175,7 +176,7 @@ def render_sidebar(state: AppState):
|
|||||||
if p is not None and p.is_dir():
|
if p is not None and p.is_dir():
|
||||||
state.current_dir = p
|
state.current_dir = p
|
||||||
state.config['last_dir'] = str(p)
|
state.config['last_dir'] = str(p)
|
||||||
save_config(state.current_dir, state.config['favorites'])
|
save_config(state.current_dir, state.config['favorites'], state.config)
|
||||||
state.loaded_file = None
|
state.loaded_file = None
|
||||||
state.file_path = None
|
state.file_path = None
|
||||||
path_input.set_value(str(p))
|
path_input.set_value(str(p))
|
||||||
@@ -192,7 +193,7 @@ def render_sidebar(state: AppState):
|
|||||||
d = str(state.current_dir)
|
d = str(state.current_dir)
|
||||||
if d not in state.config['favorites']:
|
if d not in state.config['favorites']:
|
||||||
state.config['favorites'].append(d)
|
state.config['favorites'].append(d)
|
||||||
save_config(state.current_dir, state.config['favorites'])
|
save_config(state.current_dir, state.config['favorites'], state.config)
|
||||||
render_favorites.refresh()
|
render_favorites.refresh()
|
||||||
|
|
||||||
ui.button('Pin Folder', icon='push_pin', on_click=pin_folder).classes('w-full')
|
ui.button('Pin Folder', icon='push_pin', on_click=pin_folder).classes('w-full')
|
||||||
@@ -213,7 +214,7 @@ def render_sidebar(state: AppState):
|
|||||||
def _jump_to(fav: str):
|
def _jump_to(fav: str):
|
||||||
state.current_dir = Path(fav)
|
state.current_dir = Path(fav)
|
||||||
state.config['last_dir'] = fav
|
state.config['last_dir'] = fav
|
||||||
save_config(state.current_dir, state.config['favorites'])
|
save_config(state.current_dir, state.config['favorites'], state.config)
|
||||||
state.loaded_file = None
|
state.loaded_file = None
|
||||||
state.file_path = None
|
state.file_path = None
|
||||||
path_input.set_value(fav)
|
path_input.set_value(fav)
|
||||||
@@ -224,7 +225,7 @@ def render_sidebar(state: AppState):
|
|||||||
def _unpin(fav: str):
|
def _unpin(fav: str):
|
||||||
if fav in state.config['favorites']:
|
if fav in state.config['favorites']:
|
||||||
state.config['favorites'].remove(fav)
|
state.config['favorites'].remove(fav)
|
||||||
save_config(state.current_dir, state.config['favorites'])
|
save_config(state.current_dir, state.config['favorites'], state.config)
|
||||||
render_favorites.refresh()
|
render_favorites.refresh()
|
||||||
|
|
||||||
render_favorites()
|
render_favorites()
|
||||||
@@ -260,7 +261,7 @@ def render_sidebar(state: AppState):
|
|||||||
with ui.row().classes('w-full items-center'):
|
with ui.row().classes('w-full items-center'):
|
||||||
async def copy_snippet(c=content):
|
async def copy_snippet(c=content):
|
||||||
await ui.run_javascript(
|
await ui.run_javascript(
|
||||||
f'navigator.clipboard.writeText({c!r})', timeout=3.0)
|
f'navigator.clipboard.writeText({json.dumps(c)})', timeout=3.0)
|
||||||
ui.notify('Copied to clipboard')
|
ui.notify('Copied to clipboard')
|
||||||
|
|
||||||
ui.button(
|
ui.button(
|
||||||
|
|||||||
@@ -194,13 +194,13 @@ def render_batch_processor(state: AppState):
|
|||||||
_add_sequence(DEFAULTS.copy())
|
_add_sequence(DEFAULTS.copy())
|
||||||
|
|
||||||
def add_from_source():
|
def add_from_source():
|
||||||
item = DEFAULTS.copy()
|
item = copy.deepcopy(DEFAULTS)
|
||||||
src_batch = _src_cache['batch']
|
src_batch = _src_cache['batch']
|
||||||
sel_idx = src_seq_select.value
|
sel_idx = src_seq_select.value
|
||||||
if src_batch and sel_idx is not None:
|
if src_batch and sel_idx is not None:
|
||||||
item.update(src_batch[int(sel_idx)])
|
item.update(copy.deepcopy(src_batch[int(sel_idx)]))
|
||||||
elif _src_cache['data']:
|
elif _src_cache['data']:
|
||||||
item.update(_src_cache['data'])
|
item.update(copy.deepcopy(_src_cache['data']))
|
||||||
_add_sequence(item)
|
_add_sequence(item)
|
||||||
|
|
||||||
ui.button('Add Empty', icon='add', on_click=add_empty)
|
ui.button('Add Empty', icon='add', on_click=add_empty)
|
||||||
@@ -208,9 +208,6 @@ def render_batch_processor(state: AppState):
|
|||||||
|
|
||||||
ui.separator()
|
ui.separator()
|
||||||
|
|
||||||
# --- Mass Update ---
|
|
||||||
_render_mass_update(batch_list, data, file_path, state)
|
|
||||||
|
|
||||||
# --- Standard / LoRA / VACE key sets ---
|
# --- Standard / LoRA / VACE key sets ---
|
||||||
lora_keys = ['lora 1 high', 'lora 1 low', 'lora 2 high', 'lora 2 low',
|
lora_keys = ['lora 1 high', 'lora 1 low', 'lora 2 high', 'lora 2 low',
|
||||||
'lora 3 high', 'lora 3 low']
|
'lora 3 high', 'lora 3 low']
|
||||||
@@ -230,9 +227,12 @@ def render_batch_processor(state: AppState):
|
|||||||
ui.notify('Sorted by sequence number!', type='positive')
|
ui.notify('Sorted by sequence number!', type='positive')
|
||||||
render_sequence_list.refresh()
|
render_sequence_list.refresh()
|
||||||
|
|
||||||
# --- Sequence list (count label + cards inside refreshable) ---
|
# --- Sequence list + mass update (inside refreshable so they stay in sync) ---
|
||||||
@ui.refreshable
|
@ui.refreshable
|
||||||
def render_sequence_list():
|
def render_sequence_list():
|
||||||
|
# Mass update (rebuilt on refresh so checkboxes match current sequences)
|
||||||
|
_render_mass_update(batch_list, data, file_path, state)
|
||||||
|
|
||||||
with ui.row().classes('w-full items-center'):
|
with ui.row().classes('w-full items-center'):
|
||||||
ui.label(f'Batch contains {len(batch_list)} sequences.')
|
ui.label(f'Batch contains {len(batch_list)} sequences.')
|
||||||
ui.button('Sort by Number', icon='sort', on_click=sort_by_number).props('flat')
|
ui.button('Sort by Number', icon='sort', on_click=sort_by_number).props('flat')
|
||||||
@@ -289,13 +289,13 @@ def _render_sequence_card(i, seq, batch_list, data, file_path, state,
|
|||||||
with ui.row().classes('w-full q-gutter-sm action-row'):
|
with ui.row().classes('w-full q-gutter-sm action-row'):
|
||||||
# Copy from source
|
# Copy from source
|
||||||
def copy_source(idx=i, sn=seq_num):
|
def copy_source(idx=i, sn=seq_num):
|
||||||
item = DEFAULTS.copy()
|
item = copy.deepcopy(DEFAULTS)
|
||||||
src_batch = src_cache['batch']
|
src_batch = src_cache['batch']
|
||||||
sel_idx = src_seq_select.value
|
sel_idx = src_seq_select.value
|
||||||
if src_batch and sel_idx is not None:
|
if src_batch and sel_idx is not None:
|
||||||
item.update(src_batch[int(sel_idx)])
|
item.update(copy.deepcopy(src_batch[int(sel_idx)]))
|
||||||
elif src_cache['data']:
|
elif src_cache['data']:
|
||||||
item.update(src_cache['data'])
|
item.update(copy.deepcopy(src_cache['data']))
|
||||||
item[KEY_SEQUENCE_NUMBER] = sn
|
item[KEY_SEQUENCE_NUMBER] = sn
|
||||||
item.pop(KEY_PROMPT_HISTORY, None)
|
item.pop(KEY_PROMPT_HISTORY, None)
|
||||||
item.pop(KEY_HISTORY_TREE, None)
|
item.pop(KEY_HISTORY_TREE, None)
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import asyncio
|
||||||
import html
|
import html
|
||||||
import time
|
import time
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
@@ -76,17 +77,33 @@ def render_comfy_monitor(state: AppState):
|
|||||||
render_instance_tabs()
|
render_instance_tabs()
|
||||||
|
|
||||||
# --- Auto-poll timer (every 300s) ---
|
# --- Auto-poll timer (every 300s) ---
|
||||||
|
# Store live_checkbox references so the timer can update them
|
||||||
|
_live_checkboxes = state._live_checkboxes = getattr(state, '_live_checkboxes', {})
|
||||||
|
_live_refreshables = state._live_refreshables = getattr(state, '_live_refreshables', {})
|
||||||
|
|
||||||
def poll_all():
|
def poll_all():
|
||||||
# Timeout checks for live toggles
|
|
||||||
timeout_val = config.get('monitor_timeout', 0)
|
timeout_val = config.get('monitor_timeout', 0)
|
||||||
if timeout_val > 0:
|
if timeout_val > 0:
|
||||||
for key, start_time in list(state.live_toggles.items()):
|
for key, start_time in list(state.live_toggles.items()):
|
||||||
if start_time and (time.time() - start_time) > (timeout_val * 60):
|
if start_time and (time.time() - start_time) > (timeout_val * 60):
|
||||||
state.live_toggles[key] = None
|
state.live_toggles[key] = None
|
||||||
|
if key in _live_checkboxes:
|
||||||
|
_live_checkboxes[key].set_value(False)
|
||||||
|
if key in _live_refreshables:
|
||||||
|
_live_refreshables[key].refresh()
|
||||||
|
|
||||||
ui.timer(300, poll_all)
|
ui.timer(300, poll_all)
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_blocking(url, timeout=1.5):
|
||||||
|
"""Run a blocking GET request; returns (response, error)."""
|
||||||
|
try:
|
||||||
|
res = requests.get(url, timeout=timeout)
|
||||||
|
return res, None
|
||||||
|
except Exception as e:
|
||||||
|
return None, e
|
||||||
|
|
||||||
|
|
||||||
def _render_single_instance(state: AppState, instance_config: dict, index: int,
|
def _render_single_instance(state: AppState, instance_config: dict, index: int,
|
||||||
all_instances: list, refresh_fn):
|
all_instances: list, refresh_fn):
|
||||||
config = state.config
|
config = state.config
|
||||||
@@ -120,11 +137,13 @@ def _render_single_instance(state: AppState, instance_config: dict, index: int,
|
|||||||
# --- Status Dashboard ---
|
# --- Status Dashboard ---
|
||||||
status_container = ui.row().classes('w-full items-center q-gutter-md')
|
status_container = ui.row().classes('w-full items-center q-gutter-md')
|
||||||
|
|
||||||
def refresh_status():
|
async def refresh_status():
|
||||||
status_container.clear()
|
status_container.clear()
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
res, err = await loop.run_in_executor(
|
||||||
|
None, lambda: _fetch_blocking(f'{comfy_url}/queue'))
|
||||||
with status_container:
|
with status_container:
|
||||||
try:
|
if res is not None:
|
||||||
res = requests.get(f'{comfy_url}/queue', timeout=1.5)
|
|
||||||
queue_data = res.json()
|
queue_data = res.json()
|
||||||
running_cnt = len(queue_data.get('queue_running', []))
|
running_cnt = len(queue_data.get('queue_running', []))
|
||||||
pending_cnt = len(queue_data.get('queue_pending', []))
|
pending_cnt = len(queue_data.get('queue_pending', []))
|
||||||
@@ -139,13 +158,14 @@ def _render_single_instance(state: AppState, instance_config: dict, index: int,
|
|||||||
with ui.card().classes('q-pa-sm'):
|
with ui.card().classes('q-pa-sm'):
|
||||||
ui.label('Running')
|
ui.label('Running')
|
||||||
ui.label(str(running_cnt))
|
ui.label(str(running_cnt))
|
||||||
except Exception:
|
else:
|
||||||
with ui.card().classes('q-pa-sm'):
|
with ui.card().classes('q-pa-sm'):
|
||||||
ui.label('Status')
|
ui.label('Status')
|
||||||
ui.label('Offline').classes('text-negative')
|
ui.label('Offline').classes('text-negative')
|
||||||
ui.label(f'Could not connect to {comfy_url}').classes('text-negative')
|
ui.label(f'Could not connect to {comfy_url}').classes('text-negative')
|
||||||
|
|
||||||
refresh_status()
|
# Initial status fetch (non-blocking via button click handler pattern)
|
||||||
|
ui.timer(0.1, refresh_status, once=True)
|
||||||
ui.button('Refresh Status', icon='refresh', on_click=refresh_status).props('flat dense')
|
ui.button('Refresh Status', icon='refresh', on_click=refresh_status).props('flat dense')
|
||||||
|
|
||||||
# --- Live View ---
|
# --- Live View ---
|
||||||
@@ -153,6 +173,8 @@ def _render_single_instance(state: AppState, instance_config: dict, index: int,
|
|||||||
toggle_key = f'live_toggle_{index}'
|
toggle_key = f'live_toggle_{index}'
|
||||||
|
|
||||||
live_checkbox = ui.checkbox('Enable Live Preview', value=False)
|
live_checkbox = ui.checkbox('Enable Live Preview', value=False)
|
||||||
|
# Store reference so poll_all timer can disable it on timeout
|
||||||
|
state._live_checkboxes[toggle_key] = live_checkbox
|
||||||
|
|
||||||
@ui.refreshable
|
@ui.refreshable
|
||||||
def render_live_view():
|
def render_live_view():
|
||||||
@@ -199,6 +221,7 @@ def _render_single_instance(state: AppState, instance_config: dict, index: int,
|
|||||||
else:
|
else:
|
||||||
ui.label('No valid viewer URL configured.').classes('text-warning')
|
ui.label('No valid viewer URL configured.').classes('text-warning')
|
||||||
|
|
||||||
|
state._live_refreshables[toggle_key] = render_live_view
|
||||||
live_checkbox.on_value_change(lambda _: render_live_view.refresh())
|
live_checkbox.on_value_change(lambda _: render_live_view.refresh())
|
||||||
render_live_view()
|
render_live_view()
|
||||||
|
|
||||||
@@ -206,36 +229,40 @@ def _render_single_instance(state: AppState, instance_config: dict, index: int,
|
|||||||
ui.label('Latest Output').classes('text-subtitle1 q-mt-md')
|
ui.label('Latest Output').classes('text-subtitle1 q-mt-md')
|
||||||
img_container = ui.column().classes('w-full')
|
img_container = ui.column().classes('w-full')
|
||||||
|
|
||||||
def check_image():
|
async def check_image():
|
||||||
img_container.clear()
|
img_container.clear()
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
res, err = await loop.run_in_executor(
|
||||||
|
None, lambda: _fetch_blocking(f'{comfy_url}/history', timeout=2))
|
||||||
with img_container:
|
with img_container:
|
||||||
try:
|
if err is not None:
|
||||||
hist_res = requests.get(f'{comfy_url}/history', timeout=2)
|
ui.label(f'Error fetching image: {err}').classes('text-negative')
|
||||||
history = hist_res.json()
|
return
|
||||||
if not history:
|
history = res.json()
|
||||||
ui.label('No history found.').classes('text-caption')
|
if not history:
|
||||||
return
|
ui.label('No history found.').classes('text-caption')
|
||||||
last_prompt_id = list(history.keys())[-1]
|
return
|
||||||
outputs = history[last_prompt_id].get('outputs', {})
|
last_prompt_id = list(history.keys())[-1]
|
||||||
found_img = None
|
outputs = history[last_prompt_id].get('outputs', {})
|
||||||
for node_output in outputs.values():
|
found_img = None
|
||||||
if 'images' in node_output:
|
for node_output in outputs.values():
|
||||||
for img_info in node_output['images']:
|
if 'images' in node_output:
|
||||||
if img_info['type'] == 'output':
|
for img_info in node_output['images']:
|
||||||
found_img = img_info
|
if img_info['type'] == 'output':
|
||||||
break
|
found_img = img_info
|
||||||
if found_img:
|
break
|
||||||
break
|
|
||||||
if found_img:
|
if found_img:
|
||||||
img_name = found_img['filename']
|
break
|
||||||
folder = found_img['subfolder']
|
if found_img:
|
||||||
img_type = found_img['type']
|
params = urllib.parse.urlencode({
|
||||||
img_url = f'{comfy_url}/view?filename={img_name}&subfolder={folder}&type={img_type}'
|
'filename': found_img['filename'],
|
||||||
ui.image(img_url).classes('w-full').style('max-width: 600px')
|
'subfolder': found_img['subfolder'],
|
||||||
ui.label(f'Last Output: {img_name}').classes('text-caption')
|
'type': found_img['type'],
|
||||||
else:
|
})
|
||||||
ui.label('Last run had no image output.').classes('text-caption')
|
img_url = f'{comfy_url}/view?{params}'
|
||||||
except Exception as e:
|
ui.image(img_url).classes('w-full').style('max-width: 600px')
|
||||||
ui.label(f'Error fetching image: {e}').classes('text-negative')
|
ui.label(f'Last Output: {found_img["filename"]}').classes('text-caption')
|
||||||
|
else:
|
||||||
|
ui.label('Last run had no image output.').classes('text-caption')
|
||||||
|
|
||||||
ui.button('Check Latest Image', icon='image', on_click=check_image).props('flat')
|
ui.button('Check Latest Image', icon='image', on_click=check_image).props('flat')
|
||||||
|
|||||||
Reference in New Issue
Block a user